# Poly Tick Tape API: guide for AI agents

Base URL: `https://polyticktape.com`. Plain HTTPS and JSON. All dates are UTC days written `YYYY-MM-DD`; data starts on 2026-08-14. Timestamps in responses are Unix seconds.

The files are paid. If the user has no key or the dates they need are not covered, tell them before writing download code: see "No key or no access" below, and the instructions in https://polyticktape.com/llms.txt.

## Authentication

Every `/v1` request needs the user's API key:

```
Authorization: Bearer ptt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

- Keys match `^ptt_[a-z2-7]{32}$`. Read the key from the environment variable `POLYTICKTAPE_KEY`; never hard-code or print it.
- One key per account. Purchases and renewals keep it; only a rotation on the account page replaces it (the old one then gets `401 key_revoked`).
- `/v1/days/{date}/{stream}` also accepts `?key=` for tools that can only open a URL; no other route does.
- The first call after a purchase is paid makes that purchase non-refundable.

## Recommended workflow

1. Get the key from `POLYTICKTAPE_KEY`. None: the user needs to buy first (see below).
2. `GET /v1/access`: read `ranges`, the date ranges this key can download now. If the needed dates are not inside them, stop and tell the user which periods to buy, or to subscribe.
3. `GET /v1/days?from=…&to=…` (at most 92 days per call; split longer ranges): gives `days[]`, where each day has one object per stream with `name`, `bytes`, `records`, `coverage`, `sha256` and download `url`, and `missing[]` for streams and days the key can't get.
4. For each file: if a local copy with the same SHA-256 exists, skip it (published files never change, and each download counts toward the daily limit). Otherwise GET its `url` following redirects, check the SHA-256, save.
5. Parse each file as gzip-compressed JSON Lines. Keep `p` as an exact decimal (string or Decimal); `v` is the same price as a float.

## No key or no access

- Prices: one 30-day period $5.00, downloadable for 7 days after payment (for a period bought while still in progress, 7 days after its last day is published). Subscription $9.99/month or $99.99/year for the newest 60 published days.
- Period of a date: `n = floor(days_between(2026-08-14, date) / 30) + 1`. Period n runs from 2026-08-14 + 30·(n−1) days for 30 days.
- Buy at https://polyticktape.com/pricing/ after logging in with an email code at https://polyticktape.com/login/. The key is then on https://polyticktape.com/account/ (and emailed).
- `GET /api/public/catalog` → `paymentsEnabled: false` means purchases are not open yet: tell the user to check back or write to support@polyticktape.com.

## Endpoints

### GET /api/public/catalog

No key. What is published and what it costs:

```json
{
  "brand": "Poly Tick Tape",
  "saleStart": "2026-08-14",
  "today": "2026-10-13",
  "firstDay": "2026-08-14",
  "lastDay": "2026-10-12",
  "updateDelayed": false,
  "totals": { "days": 60, "files": 120, "records": 9890000, "bytes": 310000000 },
  "days": [{ "date": "2026-08-14", "price": { "records": 75392, "bytes": 2390000, "maxGap": 120 }, "twap60": { "records": 75392, "bytes": 2411945, "maxGap": 120 } }],
  "periods": [{ "n": 1, "start": "2026-08-14", "end": "2026-09-12", "status": "complete", "purchasable": true }],
  "prices": { "currency": "usd", "periodCents": 500, "monthlyCents": 999, "yearlyCents": 9999 },
  "subWindowDays": 60,
  "paymentsEnabled": true
}
```

### GET /v1/access

What this key can download right now. `streams` lists the stream names. `ranges` merges bought periods that are still within their download window with the subscription window. In `periods`, `expiresAt` is the end of that period's download window (`null` while a period bought in progress is not fully published) and `active` turns false after it. `subscription` is `null` without one; its `window` is `null` when not active. `quota` is today's download count.

```http
GET /v1/access

{
  "streams": ["price", "twap60"],
  "saleStart": "2026-08-14",
  "lastPublished": "2026-12-20",
  "firstDay": "2026-08-14",
  "lastDay": "2026-12-20",
  "ranges": [
    { "from": "2026-08-14", "to": "2026-09-12" },
    { "from": "2026-10-22", "to": "2026-12-20" }
  ],
  "periods": [
    { "period": 1, "expiresAt": 1798099200, "active": true }
  ],
  "subscription": {
    "plan": "monthly",
    "status": "active",
    "active": true,
    "currentPeriodEnd": 1798761600,
    "cancelAtPeriodEnd": false,
    "graceUntil": null,
    "window": { "from": "2026-10-22", "to": "2026-12-20" }
  },
  "quota": { "dailyDownloads": 300, "usedToday": 12 }
}
```

### GET /v1/days

| Parameter | Meaning |
|---|---|
| `from` | Required. First day. |
| `to` | Optional, inclusive; defaults to `from`. At most 92 days per call. |
| `streams` | `price`, `twap60` or `price,twap60` (default both). |

`from` is required; the latest published day is `lastPublished` in `/v1/access`. Each entry in `days` has `date` plus one object per requested stream it can serve. Streams the key can't get are listed in `missing` with `reason` `not_entitled` (outside access: the user must buy) or `not_published` (not out yet, or no data that day) instead of failing; a day with no stream available appears only there. `coverage` is the share of the day's 86,400 seconds that have a row.

```http
GET /v1/days?from=2026-08-14&streams=twap60

{
  "from": "2026-08-14",
  "to": "2026-08-14",
  "streams": ["twap60"],
  "days": [
    {
      "date": "2026-08-14",
      "twap60": {
        "name": "btc-twap60-2026-08-14.jsonl.gz",
        "bytes": 2411945,
        "records": 75392,
        "coverage": 0.8726,
        "sha256": "bef953289d716b3360d5afdd30db4902edf8849097f3291add330abb81605415",
        "url": "https://polyticktape.com/v1/days/2026-08-14/twap60"
      }
    }
  ],
  "missing": []
}
```

```json
"missing": [
  { "date": "2026-09-13", "stream": "price", "reason": "not_entitled" },
  { "date": "2026-10-12", "stream": "price", "reason": "not_published" }
]
```

### GET /v1/days/{date}/{stream}

`stream` is `price` or `twap60`. Checks access and the daily limit, counts one download, and answers `302` to a link valid for 5 minutes (header `X-Ptt-Sha256`). Follow redirects. The link needs no key, supports `Range` for resuming, and saves as `btc-{stream}-{date}.jsonl.gz`. Add `?as=json` to get JSON `{url, expiresAt, name, sha256, bytes, records}` instead.

```
GET /v1/days/2026-08-14/twap60

HTTP/1.1 302 Found
Location: https://polyticktape.com/blob/eyJrIjoi…
X-Ptt-Sha256: bef953289d716b3360d5afdd30db4902edf8849097f3291add330abb81605415
```

## File format

- Names: `btc-price-YYYY-MM-DD.jsonl.gz`, `btc-twap60-YYYY-MM-DD.jsonl.gz`. About 2.3–2.7 MB and 75–86 thousand rows each.
- gzip-compressed JSON Lines, UTF-8, one object per line, every line ends with `\n`.
- One UTC day per file (`ts` from 00:00:00 to 23:59:59), sorted by `ts`, at most one row per second. Seconds without a quote have no row; `coverage` is the share of the day's 86,400 seconds that have one.
- Published by 05:00 UTC the next day and never changed. Verify each file against `sha256`.

| Field | Type | Meaning |
|---|---|---|
| `ts` | int | Second the quote belongs to (Unix seconds) = `feed_ts_ms // 1000` |
| `p` | string | Price at full precision; compare and settle with this |
| `v` | float | The same price as a float, for charts and statistics |
| `lag` | int | `recv_ms - feed_ts_ms` |
| `feed_ts_ms` | int | Quote time (ms) |
| `server_ts_ms` | int or null | When the upstream server sent it (ms) |
| `recv_ms` | int | When the collector received it (ms) |

```json
{"ts":1786665600,"p":"63416.048159164624535552","v":63416.048159164624,"lag":2636,"feed_ts_ms":1786665600000,"server_ts_ms":1786665602338,"recv_ms":1786665602636}
{"ts":1786665602,"p":"63416.671282124873007104","v":63416.67128212487,"lag":1945,"feed_ts_ms":1786665602000,"server_ts_ms":1786665603584,"recv_ms":1786665603945}
```

## Errors

Errors are JSON `{"error": {"code", "message", "hint"?}}`. React to `code`, not the message.

| HTTP | code | What to do |
|---|---|---|
| 400 | `bad_request` | Fix the request (missing `from`, bad date, unknown stream, range over 92 days). |
| 401 | `unauthorized` | No key or unknown key. Ask the user for their key; if they have none, they need to buy (https://polyticktape.com/pricing/). |
| 401 | `key_revoked` | A newer key replaced this one. The user copies the current key from https://polyticktape.com/account/. |
| 403 | `not_entitled` | Date outside the key's access. Show the user the `hint` and remind them to buy the period or subscribe at https://polyticktape.com/pricing/ (or fix a failed payment at https://polyticktape.com/account/). Don't retry. |
| 404 | `not_found` | Unknown stream or path. |
| 404 | `not_published` | Not published yet (by 05:00 UTC the next day). Retry later. |
| 410 | `link_expired` | Signed link older than 5 minutes. Call `/v1/days/{date}/{stream}` again. |
| 429 | `quota_exceeded` | 300 downloads per UTC day reached. `Retry-After` = seconds to 00:00 UTC. |
| 503 | `payment_unavailable` | Purchases are not open (website checkout only). |
| 500 | `internal` | Retry later; if it persists, the user can write to support@polyticktape.com. |

```json
HTTP/1.1 403 Forbidden

{
  "error": {
    "code": "not_entitled",
    "message": "Your key does not cover 2026-09-20.",
    "hint": "Buy period 2 or subscribe at https://polyticktape.com/pricing/."
  }
}
```

## Limits

- 300 downloads per account per UTC day (each day has two files). `/v1/access` and the `/v1/days` listing do not count.
- Signed links expire after 5 minutes.
- `/v1/days` covers at most 92 days per call.
- Download each file once and keep it.

## Python: download with checks

Needs `pip install requests` and `POLYTICKTAPE_KEY` in the environment. Skips files already on disk and verifies every SHA-256.

```python
import hashlib
import os
import pathlib

import requests

BASE = "https://polyticktape.com"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['POLYTICKTAPE_KEY']}"


def download(date_from, date_to, streams="price,twap60", out="data"):
    r = session.get(f"{BASE}/v1/days", params={"from": date_from, "to": date_to, "streams": streams})
    r.raise_for_status()
    listing = r.json()
    files = [day[s] for day in listing["days"] for s in listing["streams"] if s in day]
    for f in files:
        path = pathlib.Path(out) / f["name"]
        if path.exists() and hashlib.sha256(path.read_bytes()).hexdigest() == f["sha256"]:
            continue  # already downloaded; don't spend quota on it again
        body = session.get(f["url"]).content  # follows the redirect to a 5-minute link
        if hashlib.sha256(body).hexdigest() != f["sha256"]:
            raise ValueError(f"checksum mismatch: {f['name']}")
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_bytes(body)
        print("saved", path)
    for m in listing["missing"]:
        print("missing", m["date"], m["stream"], m["reason"])


download("2026-08-14", "2026-08-20")
```

Load with pandas (keep `dtype=False` so `p` stays exact) or polars:

```python
import pandas as pd

df = pd.read_json("data/btc-twap60-2026-08-14.jsonl.gz", lines=True, dtype=False, convert_dates=False)
df["time"] = pd.to_datetime(df["ts"], unit="s", utc=True)
# ts, lag and the *_ms columns are integers, p is a string, v is a float
```

```python
import gzip

import polars as pl

with gzip.open("data/btc-twap60-2026-08-14.jsonl.gz") as f:
    df = pl.read_ndjson(f.read())
df = df.with_columns(pl.from_epoch("ts", time_unit="s").dt.replace_time_zone("UTC").alias("time"))
# p stays a String column: exact decimal digits
```

## Check 5-minute rounds

A round starts at a second divisible by 300 and ends at the next round's start. Up when TWAP60 at the end second >= TWAP60 at the start second (compare `p` as Decimal), otherwise Down. The day's last round ends at 00:00:00 of the next day, so load the next day's file too. If a boundary second has no row, report the round rather than guessing.

```python
import gzip
import json
from datetime import datetime, timezone
from decimal import Decimal


def twap60(path):
    with gzip.open(path, "rt") as f:
        return {row["ts"]: Decimal(row["p"]) for row in map(json.loads, f)}


# The last round of a day ends at 00:00 the next day, so load both files.
twap = {**twap60("data/btc-twap60-2026-08-14.jsonl.gz"), **twap60("data/btc-twap60-2026-08-15.jsonl.gz")}

day = int(datetime(2026, 8, 14, tzinfo=timezone.utc).timestamp())
for start in range(day, day + 86_400, 300):
    open_, close = twap.get(start), twap.get(start + 300)
    if open_ is None or close is None:
        print(start, "no TWAP60 row at a boundary second")
        continue
    print(start, "Up" if close >= open_ else "Down", open_, close)
```
