API docs
Two resources: /v1/access tells you what your key reaches; /v1/days lists days and hands out each stream's file.
Everything is plain HTTPS with an API key; files are gzip-compressed JSON Lines, one per stream per UTC day.
Quick start
- Log in with your email and buy a period or subscribe on the pricing page.
- Copy your API key from the account page.
- Call the API:
export POLYTICKTAPE_KEY=ptt_your_key_here
# What your key can reach
curl -s -H "Authorization: Bearer $POLYTICKTAPE_KEY" https://polyticktape.com/v1/access
# Download one file and check it
curl -fL -H "Authorization: Bearer $POLYTICKTAPE_KEY" -o btc-twap60-2026-08-14.jsonl.gz \
https://polyticktape.com/v1/days/2026-08-14/twap60
shasum -a 256 btc-twap60-2026-08-14.jsonl.gz # Linux: sha256sum
gzip -dc btc-twap60-2026-08-14.jsonl.gz | head -3
The base URL is https://polyticktape.com. All dates are UTC days in YYYY-MM-DD form; data starts on
2026-08-14.
Use with AI
AI assistants and coding agents (Claude, ChatGPT, Cursor, Copilot…) can do the integration for you. We publish guides written for them: what the site offers, every endpoint, how to handle errors, and a tested download script. Point your assistant at them, for example:
Read https://polyticktape.com/llms.txt, then use the Poly Tick Tape API to download the BTC TWAP60 files for 2026-09-01 to 2026-09-07 into ./data and check their SHA-256. My API key is in the POLYTICKTAPE_KEY environment variable. /llms.txt— what Poly Tick Tape is, prices, and how an assistant should help you./docs/api.md— this API reference in Markdown, for agents./llms-full.txt— both in one file, for tools that read a single document.
Keep the key in the POLYTICKTAPE_KEY environment variable rather than pasting it into the chat. If your key does not cover the days you
ask for, the assistant tells you which period to buy or that a subscription covers it.
Authentication
Send your key in the Authorization header on every request:
Authorization: Bearer ptt_your_key_here -
One key per account. Purchases and subscription renewals keep it. Only Rotate key on the account page replaces it; the old key
stops working at once (it then gets
401 key_revoked). -
If a tool can only open a plain URL, add
?key=…to/v1/days/{date}/{stream}. No other route accepts it; use the header whenever you can. - Keys are personal. Using the key in any way — including downloads from the account page — makes a purchase non-refundable.
GET /v1/access
What this key can download right now: the date ranges (periods within their download window and the subscription window merged), the
periods you bought, subscription state and today's quota. Each period has expiresAt — the end of its 7-day download
window in Unix seconds, null while a period bought in progress is not fully published — and active, which turns
false once that has passed.
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 }
} lastPublished is the latest day on the site. subscription is null without one; window is
null when it is not active. Timestamps are Unix seconds.
GET /v1/days
Lists the days in a range. Each day carries one object per stream: file name, size, row count, coverage, SHA-256 and download URL.
| Parameter | Meaning |
|---|---|
from | Required. First day of the range. |
to | Last day, inclusive (default: same as from). At most 92 days per request. |
streams | price, twap60 or price,twap60 (default: both). |
from is required. For the newest published day, read lastPublished from /v1/access.
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": []
}
Streams in the range that you cannot download are listed in missing instead of failing the request: not_entitled
(outside your access) or not_published (not out yet, or no data that day). A day with neither stream available appears only there.
"missing": [
{ "date": "2026-09-13", "stream": "price", "reason": "not_entitled" },
{ "date": "2026-10-12", "stream": "price", "reason": "not_published" }
] coverage is the share of the day's 86,400 seconds that have a row.
GET /v1/days/{date}/{stream}
stream is price or twap60. Answers 302 with a link to the file that stays valid for
5 minutes and needs no key; it supports Range, so a broken download can resume. Use curl -L
(Python requests follows it on its own). The file is saved as btc-{stream}-{date}.jsonl.gz.
GET /v1/days/2026-08-14/twap60
HTTP/1.1 302 Found
Location: https://polyticktape.com/blob/eyJrIjoi…
X-Ptt-Sha256: bef953289d716b3360d5afdd30db4902edf8849097f3291add330abb81605415 Add ?as=json to get the link as JSON instead:
GET /v1/days/2026-08-14/twap60?as=json
{
"url": "https://polyticktape.com/blob/eyJrIjoi…",
"expiresAt": 1786752300,
"name": "btc-twap60-2026-08-14.jsonl.gz",
"sha256": "bef953289d716b3360d5afdd30db4902edf8849097f3291add330abb81605415",
"bytes": 2411945,
"records": 75392
} Each call counts as one download toward the daily limit; fetching the link it returns does not count again.
File format
- Names:
btc-price-YYYY-MM-DD.jsonl.gzandbtc-twap60-YYYY-MM-DD.jsonl.gz. - gzip-compressed, one JSON object per line, UTF-8, every line ends with
\n. -
A file holds one UTC day:
tsfrom 00:00:00 to 23:59:59. Rows are sorted byts, at most one row per second. -
A second with no quote has no row. A day's
coveragein/v1/daysis the share of its 86,400 seconds that have one. - A day is published by 05:00 UTC the next day and never changed afterwards. Check downloads against
sha256.
| Field | Type | Meaning |
|---|---|---|
ts | int | Second the quote belongs to (Unix seconds) |
p | string | Price at full precision — use this to compare and settle |
v | float | The same price as a float, for charts and maths |
lag | int | Receive delay in ms: recv_ms − feed_ts_ms |
feed_ts_ms | int | Quote time (ms) |
server_ts_ms | int | null | When the upstream server sent it (ms); null if not provided |
recv_ms | int | When our collector received it (ms) |
{"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}
Compare and settle with p: it is a string so that every digit survives. Parse it with a decimal type (Python Decimal), not a
float. v is the same value as a float, for charts and statistics.
What a key covers
- Periods: 30-day blocks counted from 2026-08-14 — period 1 is 2026-08-14 → 2026-09-12, period 2 is 2026-09-13 → 2026-10-12, and so on. A bought period can be downloaded for 7 days after payment; after that it has to be bought again. A period bought while in progress unlocks each new day as it is published, and its 7 days start when its last day is published. Each purchase counts its own days: buying another period does not extend an earlier one.
- Subscription: the newest 60 published days, moving forward one day each time a day is published. If a renewal payment fails, access continues for one day while the payment is retried; after that the window closes until the payment succeeds.
- Both together: you can download the union.
/v1/accessreturns it asranges.
A request outside your access gets 403 not_entitled with a hint that says why (period not bought or its download window over, subscription ended, payment failed) and what to do.
Python
Download a range, skipping files you already have and checking every file against its SHA-256:
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 into pandas. Pass dtype=False, otherwise pandas turns p into a float and the exact digits are lost:
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 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 rounds
A 5-minute round runs from a start second that is a multiple of 300 to the start of the next round. It is Up when TWAP60 at the end second is greater than or equal to TWAP60 at the start second, otherwise Down. The last round of a day ends at 00:00:00 of the next day, so load the next day's file too.
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) When the collector has no TWAP60 row for a boundary second, the file has no row for it either, and the script reports the round instead of guessing. Decide for yourself how to treat those rounds (skip them, or take the result from Polymarket); the files never contain substituted values.
Errors
Errors are JSON with a stable code, a readable message and sometimes a hint:
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/."
}
} | HTTP | code | When |
|---|---|---|
| 400 | bad_request | Missing from, bad date, unknown stream in streams, range longer than 92 days. |
| 401 | unauthorized | No key, or the key does not exist. |
| 401 | key_revoked | The key was rotated. Copy the current one from the account page. |
| 403 | not_entitled | The day is outside your access; see hint. |
| 404 | not_found | Unknown stream or path. |
| 404 | not_published | The day is not published yet (by 05:00 UTC the next day). |
| 410 | link_expired | The signed link is older than 5 minutes. Call /v1/days/{date}/{stream} again. |
| 429 | quota_exceeded | Daily download limit reached. Retry-After gives the seconds until 00:00 UTC. |
| 500 | internal | Our fault. Retry later; tell us if it persists. |
Limits
- 300 downloads per account per UTC day (a day has two files).
/v1/accessand the/v1/dayslisting do not count. - Signed download links expire after 5 minutes.
/v1/dayscovers at most 92 days per request.- Download each file once and keep it: published files never change.