Everything you need to load, join and replay the data: the full schema, the delivery layout, and worked examples against the free sample — a one-hour slice of the BTC Up or Down 4h series.
Polymarket organizes everything into four layers:
In Polymarket's terms, a “market” is the smallest of these — a single binary question that may live for only 15 minutes. A high-frequency series like the 15-minute Bitcoin one spawns hundreds of such markets every day. Selling data at that granularity would mean thousands of tiny files and an unusable purchasing experience.
When you select “a market” on tickfoundry, you are selecting the series. You get the complete history of every event and every underlying Polymarket market in that series for your chosen dates — all the 15-minute Bitcoin instances, not one of them. For one-off events that don't belong to a series, the unit is that event, again with all of its underlying markets included.
Not everything repeats. One-off questions are standalone events with no series — and they include the marquee markets on the venue. These are selectable exactly like a series: pick the event and you get every underlying market in it for your chosen dates.
The pattern: series are repeating questions (15-minute BTC, daily games); standalone events are one-off questions with a definite resolution date — tournaments, elections, award shows.
You rarely want 215 World Cup events one by one. Collections are curated groupings of units — built on Polymarket's own tag taxonomy, the same one its site navigation uses — so a whole tournament, election cycle or topic is a single selection:
Collections unify both worlds: one can hold series and standalone events — a Crypto collection carries the 15-minute BTC series alongside one-off ETF-approval events. The customer-facing hierarchy is vertical → collection → market (series or event), with the identical delivery unit underneath in every case. Collections will surface in the catalog picker as single selections.
Collections are catalog metadata, resolved to a concrete set of units at purchase time — archived files are never re-cut. They're curated (Polymarket's raw tags are uncurated and overlapping) and reviewed as new tournaments and cycles appear; your subscription picks up the new units automatically.
Every file delivered carries condition_id and token_id columns, and each bundle includes reference tables (events, markets, tokens), so you can always slice back down to a specific instance, a specific question, or a specific Yes/No token.
Between the two unit kinds every captured market is selectable: roughly 1,340 series account for the bulk of venue activity, with ~5,200 standalone events (≈43,500 markets) covering the one-off tail. The catalog lists what we hold data for, so it grows as capture continues — treat these as a snapshot, not a ceiling.
Three data files per bundle (raw.jsonl additionally in the free sample and Enterprise engagements), plus reference tables. Every record carries two clocks: ts_recv_ns — our collector receive time in nanoseconds since the Unix epoch, authoritative for ordering — and ts_src_ms — Polymarket's upstream timestamp in milliseconds, when present.
The parquets carry ts_recv_ns alone. A string mirror, ts_recv_iso, was dropped on 2026-08-09 (changelog) — it cost about a sixth of the bundle for information already in the integer, and any reader can rebuild it with pd.to_datetime(df.ts_recv_ns, utc=True). On 2026-08-18 the live-feed datasets followed (changelog): do not rely on ts_recv_iso being present in any normalized file — it is stripped for efficiency and is not provided on an ongoing basis. It remains only in raw.jsonl, where it is part of the capture envelope. Legacy files still in circulation may carry it — the free sample is a pre-change vintage, some historical l2.parquet files keep it until the backfill finishes, and feed files pulled before 2026-08-18 include it — so select columns by name rather than by position or count.
Spread precision. In files normalized before 2026-09-01 the spread column is rounded to three decimals. Many Polymarket books quote on a 0.25¢ tick, and 0.0025 is not representable at that precision — it is stored as 0.003, overstating the true spread by 20% at the minimum tick. Files from 2026-09-01 round to four decimals, which holds every legal spread exactly. best_bid and best_ask are exact in all files, so derive spread as best_ask − best_bid whenever the number matters across mixed history.
Each row is a self-contained snapshot of one token's book after an update, padded with NaN past the deepest level present. No delta replay is needed to know the book at any moment: take the latest row at or before your timestamp.
The ladder is capped at 25 levels per side (10 on Free and Explorer). Most snapshots are shallower than the cap and are therefore complete, but a book deeper than the cap is truncated — in the free sample that is about 3% of rows, on a book reaching 45 levels at its deepest. bid_levels and ask_levels carry the true depth, so a row is truncated exactly when they exceed the number of populated columns. Size beyond the cap is not in the file; account for it before treating a deep walk down the ladder as exhaustive.
One row per last_trade_price message on the market websocket — the venue's own print tape, which is what the feed publishes. Treat it as the tape rather than as a complete per-counterparty fill log: a single aggressing order that sweeps several resting orders is reported as a print, not as one row per maker filled. For exhaustive fill-level history — and for the taker wallet behind each fill — take the on-chain trade layer below: settled fills read from Polygon, requested separately from the market·day bundle and included from Premium upward.
A separate product, not a file inside the market·day bundle: request it per unit and date range from POST /api/v1/onchain (Premium and up). These are fills as settled on Polygon, which is why the layer reaches back years further than book capture — coverage starts when a market first traded, and GET /api/v1/onchain?unit_id=… publishes the exact range per unit.
Two properties to design around. Timestamps are second-resolution block times, so ts_ms shares a unit with trades.ts_src_ms for joining but does not imply sub-second precision; and proxy_wallet has no analogue in the websocket tape, which is the main reason to take this layer even for days you already hold. Both sources carry transaction_hash, so where they overlap they join exactly rather than fuzzily.
One caveat worth stating plainly: before a market traded there is nothing to have. Markets are often created — and even resolved — with the order book disabled and zero volume, and those days produce no fills anywhere, including in the venue's own API. An absent early date is a launch boundary, not a gap in the archive.
The system of record: exact inbound messages from Polymarket's market websocket (wss://ws-subscriptions-clob.polymarket.com/ws/market), one JSON object per line. payload_text preserves the original message byte-for-byte (JSON-safe re-encoded) — parse it to recover the upstream object. All parquet files are reproducible from raw + reference.
On payload_sha256. It is our deduplication key: computed at capture as sha256(payload_text) and used to collapse duplicates across collectors and reconnects. It is consumed during archival and is not delivered as a column — it remains exactly reproducible from payload_text if you need it.
Polymarket organizes markets as series → events → markets → outcome tokens. The series level is the bundle itself (a bundle covers one series, or one standalone event) — inside it, three reference CSVs ship: events, markets and tokens. All IDs are Polymarket-native.
events.event_id ──▶ markets.event_id markets.market_id ◀─ tokens.market_id markets.condition_id ◀─ l1 / l2 / trades.condition_id markets.yes_token_id / no_token_id ─▶ tokens.token_id tokens.token_id ◀─ l1 / l2 / trades.token_id
Alongside the order book we capture the feeds Polymarket itself publishes — the inputs markets resolve against. These are sold per day, whole: one file per feed per UTC day, every symbol and game included. There is no per-event splitting, because parquet predicate pushdown on topic / symbol / game_id already gives in-file selection. Included with Premium and Enterprise; an add-on on Explorer; a free account can claim single days.
Polymarket perps — the venue's perpetual futures exchange — is a separate product with its own tables (BBO, 20-level books, full-ladder books, trades, tickers with funding) and its own reference page: /docs/perps.
Capture is continuous and gapless since 2026-06-21. These are not the same thing as the spot ride-along (spot/binance.parquet, spot/chainlink.parquet) that Premium bundles carry inside a market·day — that is two crypto topics pre-sliced for convenience; this is the complete feed.
Every price tick Polymarket's own real-time data stream publishes: exchange spot, Chainlink oracle, and the 30s/60s TWAPs that actually resolve the crypto up/down markets. This is the reference series a crypto market settles against.
One rtds_prices day file mixes several upstream topics, and they do not all span the same era. A June range contains no TWAP rows; an August range contains no equities. Both are correct data, not gaps — every bundle's README states exactly which topics your chosen range contains, and the catalog picker warns before you buy.
crypto_prices2026-06-21 → nowSpot mid (Pyth-style). Exchange spot prices. Symbol form is <asset>usdt.crypto_prices_chainlink2026-06-21 → nowChainlink oracle. Oracle prices. Symbol form is <asset>/usd. Carries hype and zec, which spot does not.crypto_prices_twap_thirty2026-08-04 → nowChainlink 30s TWAP. One of the two topics that RESOLVE Polymarket crypto up/down markets. Days of history, not weeks.crypto_prices_twap_sixty2026-08-04 → nowChainlink 60s TWAP. The second market-resolving TWAP topic. Days of history, not weeks.equity_prices2026-06-21 → 2026-07-30Equities, FX, metals & indices. Closed era — upstream discontinued this topic after 2026-07-30. The data is real and sellable, but no new days will arrive. Despite the name it is not equities-only: it also carries FX pairs, gold/silver, WTI and indices.Live game state for every sports market Polymarket runs — score, period, status and lifecycle flags, sampled continuously, with the full upstream state object preserved per row. The ground truth a sports market reprices against.
There is no shared key: the feeds carry no condition_id or token_id, because they describe the outside world rather than the book. Join on time — every table carries ts_recv_ns on the same collector clock, so an as-of / merge_asof against l1.parquet lines a market up with the price or game state that preceded each quote. For crypto resolution work, the topic you want is crypto_prices_twap_thirty or crypto_prices_twap_sixty — those are what settle the up/down markets, not the spot mid.
import pandas as pdl1 = pd.read_parquet("l1.parquet")rtds = pd.read_parquet("rtds_prices-2026-08-06.parquet")# the TWAP that actually settles the crypto up/down marketstwap = rtds[(rtds.topic == "crypto_prices_twap_thirty") & (rtds.symbol == "btc/usd")]l1 = l1.sort_values("ts_recv_ns")twap = twap.sort_values("ts_recv_ns")# as-of join on the shared collector clock — no key in common by designmerged = pd.merge_asof(l1, twap[["ts_recv_ns", "value"]],on="ts_recv_ns", direction="backward")merged["basis"] = merged["value"] - merged["best_bid"]
The dataset store is Hive-partitioned by entity and UTC date. Entities are Polymarket series, or standalone events. Partition values (kind / id / date) live in the path, not repeated as columns. Bulk and enterprise delivery mirrors this tree.
normalized/v1/
kind=series/series_id=<ID>/date=YYYY-MM-DD/
l1.parquet # top-of-book updates
l2.parquet # order-book snapshots
trades.parquet # prints / last-trade updates
book_state.json.zst # end-of-day book state — seeds next day's replay
kind=event/event_id=<ID>/date=YYYY-MM-DD/ # same files
atoms/v1/
kind={series,event}/<id>=<ID>/date=YYYY-MM-DD/
raw.jsonl.zst # exact inbound websocket messagesWhat a customer actually downloads: one zip per package, named polymarket-<slug>-<date>-<scope>.zip. A parallel CSV bundle (…-csv.zip) ships alongside for no-parquet users.
polymarket-<slug>-<date>-<scope>.zip
l1.parquet # top-of-book updates
l2.parquet # order-book snapshots
trades.parquet # prints
book_state.json.zst # full-depth END-OF-DAY book — seeds the NEXT day's
# replay. Premium & Enterprise only; omitted at the
# 10-level cap because it carries full depth.
reference/
markets.csv # condition_ids, yes/no token ids
tokens.csv # outcome tokens — join key into all data files
events.csv # events under the unit
spot/ # Premium & Enterprise — see below
binance.parquet
chainlink.parquet
README.md # schema + row counts + join guidebook_state.json.zst is the book as it stood at the close of the bundle's UTC day, not its open — so it seeds day N+1, and a cross-day replay reads the seed from the previous day's bundle. On a large split day it is a book_state/ directory with one <event_id>.json.zst per event.
Order bundles do not contain raw.jsonl — the raw capture layer appears only in the free sample bundle and in Enterprise engagements.
Premium and Enterprise bundles include a spot/ directory with binance.parquet and chainlink.parquet covering the bundle's UTC day, at roughly one update per second per symbol, for dates from 2026-06-21 onward (earlier dates ship without spot). Binance carries btcusdt, ethusdt, solusdt, xrpusdt, dogeusdt, bnbusdt; Chainlink carries the same six as btc/usd … plus hype/usd. Prices are captured from Polymarket's real-time data stream (RTDS), which relays Binance spot and Chainlink oracle prices.
Columns: topic, symbol, value, full_accuracy_value, src_ts_ms, emit_ts_ms, ts_recv_ns, ts_recv_iso. Rows are sorted by (symbol, src_ts_ms) — sort by ts_recv_ns for tape order. Spot ships parquet-only: the …-csv.zip twin carries the same spot/ parquet files.
Premium and Enterprise accounts get read-only SFTP access at sftp.tickfoundry.com, port 2022. Auth is by ed25519 key only (no passwords). Two directories:
/requests — the bundles you have pulled, one directory per market·day, each holding the same zip the dashboard would hand you. They appear as staging completes and stay for your 30-day access window. No re-download budget applies.
/ongoing — a rolling 7-day window of the normalized store, laid out as date=YYYY-MM-DD/series=<id>/event=<id>/ with l1, l2 and trades parquet. This one is opt-in and scoped: you choose which markets appear, so the window stays navigable rather than showing every market we capture. The newest date is normally yesterday — a partition closes once its day has ended. Parquet only; for CSV, request a bundle, which arrives under /requests.
Set it up from your account: generate a key pair there (the private half is created in your browser and never reaches us) or register one you already have, then pick your markets and choose parquet or CSV for bundles. Your account page shows your username and tells you when your mounts are live — the server is reconciled on a schedule, so provisioning takes up to ten minutes the first time. Then: sftp -P 2022 your-username@sftp.tickfoundry.com.
From Windows — Windows 10 and 11 ship OpenSSH, so the command above works unchanged in PowerShell. For WinSCP, use protocol SFTP with host sftp.tickfoundry.com, port 2022, your username, a blank password, and set the key under Advanced → SSH → Authentication; WinSCP will offer to convert it to .ppk. For FileZilla, add the key under Settings → SFTP first. If authentication fails, check the key file's permissions before anything else — OpenSSH refuses a private key other accounts can read, and reports it as an auth failure rather than a permissions one.
Bundles are streamed to you through authenticated endpoints — your dashboard session or your API key — not as public links, so a copied URL is useless to anyone else and there is nothing to leak or share. Catalog pulls land within 60 seconds of purchase. Each delivered bundle can be re-downloaded up to 25 times on Free and Explorer; unlimited on Premium and Enterprise.
The REST read API is live on Explorer and above: list the whole venue, request any series·day, and stream the bundle — fully programmatic, no dashboard round-trip. Full reference, auth and worked curl / requests examples are in API below. A thin Python SDK is coming soon.
Raw-archive integrity is tracked by internal manifests and sequence audits today. Per-message hashing still happens at capture — it is what deduplicates the feed — but the hash is consumed during archival rather than carried in the delivered archive; it stays recomputable as sha256(payload_text). A delivery-side SHA-256 manifest per drop is planned and will appear alongside each bundle.
Everything you can do in the dashboard you can do over HTTP: enumerate the whole venue, request any series·day, and stream the bundle. The API is read-only and available on Explorer and above. The base URL is https://tickfoundry.com/api/v1.
Every request carries an API key as a bearer token: Authorization: Bearer tf_live_…. Mint keys in your dashboard — the secret is shown once at creation and stored only as a sha256 hash, so save it then. Each call resolves the key to your account and active plan, consumes rate-limit budget, and gates on tier capabilities (e.g. the L2 level cap). API access requires an Explorer subscription or higher.
All paths are relative to the base URL above.
Live-feed pulls use POST /feeds/downloads rather than /downloads, because a feed-day has no unit — you address it by feed and date range, not by unit_id. The job it returns is polled through the same GET /downloads/{id} endpoint as everything else. One difference on the response: feed bundles are parquet only, so url_csv is absent — rtds_prices is ~2.5M rows/day and a month as CSV would be tens of gigabytes. Check GET /feeds first if you want to know whether your key has feed access before spending a request on a 402.
On-chain fills work the same way and for a related reason: they are addressed by unit and date range rather than being a layer of a market·day, because their coverage is not the market·day coverage. A unit's chain history begins at its first trade — often months before our book capture, and for thousands of units that closed early, without any book data at all. Asking /downloads for those days would be asking for an l1.parquet that cannot exist. Bundles are parquet only, and the job polls through the same GET /downloads/{id} as everything else.
import requests, timeH = {"Authorization": "Bearer tf_live_…"}API = "https://tickfoundry.com/api/v1"inv = requests.get(f"{API}/feeds", headers=H).json()print(inv["access"]) # plan | addon | claim | requires_addon | exhaustedjob = requests.post(f"{API}/feeds/downloads", headers=H, json={"feed": "sports_state","start": "2026-08-01","end": "2026-08-07", # inclusive, 31 days max}).json()while True: # poll is a PATH — join it to the hosts = requests.get(f"https://tickfoundry.com{job['poll']}", headers=H).json()if s["status"] != "staging":breaktime.sleep(s.get("retry_after", 5))open("sports.zip", "wb").write(requests.get(s["url"], headers=H).content) # no url_csv for feeds
A POST /downloads enqueues an on-demand staging job and hands back a job id plus a poll path. Two details the examples above handle and hand-rolled clients often miss: unit_id must be the UUID that /catalog returns in its id field (a slug or series:123 is rejected with a 400), and poll is a path, not an absolute URL — join it to the API host before requesting it. Poll GET /downloads/{id} until status leaves staging; on ready, GET the returned url (it points at /downloads/{id}/file) with your API key to stream the bundle — it is not a public link. The layers field selects among l1, l2, trades. Format is chosen at download time, not at request time: url is parquet and url_csv is the CSV twin of the same job. Raw atoms are available under an Enterprise engagement, delivered per contract — not through this API.
# auth: bearer key, minted in the dashboard, shown once (sha256-stored)API="https://tickfoundry.com/api/v1"KEY="tf_live_xxxxxxxxxxxxxxxxxxxxxxxx"# 1. find a unit — search the whole venue. unit ids are UUIDs, from the catalog.UNIT=$(curl -s "$API/catalog?q=bitcoin+up+or+down&kind=series&limit=5" \-H "Authorization: Bearer $KEY" | jq -r '.units[0].id')# 2. request a series.day — returns {status:"staging", id, poll}JOB=$(curl -s "$API/downloads" \-H "Authorization: Bearer $KEY" \-H "Content-Type: application/json" \-d "{\"unit_id\":\"$UNIT\",\"date\":\"2026-05-19\",\"layers\":[\"l1\",\"l2\",\"trades\"]}")# .poll is a path, not an absolute url — join it to the api hostPOLL="$API/downloads/$(echo "$JOB" | jq -r .id)"# 3. poll until the job leaves "staging" (it may end "ready" or "failed")while [ "$(curl -s "$POLL" -H "Authorization: Bearer $KEY" | jq -r .status)" = "staging" ]; dosleep 5done# 4. download the bundle — the url is this API (key-authed), not a public linkDONE=$(curl -s "$POLL" -H "Authorization: Bearer $KEY")[ "$(echo "$DONE" | jq -r .status)" = "ready" ] || { echo "$DONE" | jq -r .error; exit 1; }curl -L "$(echo "$DONE" | jq -r .url)" -H "Authorization: Bearer $KEY" \-o polymarket-btc-up-or-down-2026-05-19.zip # .url_csv for the CSV twin
import time, requestsAPI = "https://tickfoundry.com/api/v1"# key minted in the dashboard, shown once, sha256-storedS = requests.Session()S.headers["Authorization"] = "Bearer tf_live_xxxxxxxxxxxxxxxxxxxxxxxx"# 1. list the whole venue — unit ids are UUIDs, under "units"hits = S.get(f"{API}/catalog",params={"q": "bitcoin up or down", "kind": "series", "limit": 5}).json()unit = hits["units"][0]["id"]# 2. request a series.dayjob = S.post(f"{API}/downloads",json={"unit_id": unit, "date": "2026-05-19","layers": ["l1", "l2", "trades"]}).json()# 3. poll until the job leaves "staging". job["poll"] is a path, not an# absolute url, so build the request against the api host.while job["status"] == "staging":time.sleep(job.get("retry_after", 5))job = S.get(f"{API}/downloads/{job['id']}").json()if job["status"] != "ready":raise RuntimeError(job.get("error", "staging failed"))# 4. download the bundle — same session carries the API key to the stream urlzipped = S.get(job["url"]).content # job["url_csv"] for the CSV twinopen("polymarket-btc-up-or-down-2026-05-19.zip", "wb").write(zipped)
A thin Python SDK that wraps these endpoints — hiding the poll loop behind a single client.download(unit_id, date) call — is coming soon. Until then the REST API above is the integration surface; the requests example is a ~20-line drop-in.
Limits are enforced per plan. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; exceeding the window returns HTTP 429 with a Retry-After header. Per-day download ceilings apply on top of the request rate.
Free accounts have no API access — keys are mintable from Explorer upward. The same per-day download ceiling that governs dashboard pulls applies to API-driven downloads.
Load the L1 file, attach human-readable market context from the reference tables, and index by receive time. Prefer a worked example? There's a fully executed notebook over the free sample bundle — book heatmap, mid/spread, size at the touch — you can read without signing up.
import pandas as pdl1 = pd.read_parquet("l1.parquet")# token_id is a 77-digit integer. Read it as a string, or pandas parses it# into a Python int and the merge below silently matches nothing.tokens = pd.read_csv("reference/tokens.csv", dtype={"token_id": str})markets = pd.read_csv("reference/markets.csv")# label every quote with its market and outcomel1 = (l1.merge(tokens[["token_id", "market_id", "outcome_label"]], on="token_id").merge(markets[["market_id", "question"]], on="market_id"))# one token's top-of-book, indexed by authoritative receive timetok = l1[l1.token_id == YES_TOKEN].copy()tok.index = pd.to_datetime(tok.ts_recv_ns, unit="ns", utc=True)mid = (tok.best_bid + tok.best_ask) / 2
Every L2 row is a snapshot of the ladder, so fill simulation is a straight walk down it: take liquidity level by level until your order is filled, and compare the volume-weighted price to the touch. Two things decide whether the answer is honest. The ladder is capped to your tier (10 levels on Free and Explorer, 25 on Premium and Enterprise), and a large order can exhaust it — so a fill simulator must report how much it actually filled rather than returning the VWAP of a partial fill as if it were complete.
import numpy as npl2 = pd.read_parquet("l2.parquet")snap = l2[l2.token_id == YES_TOKEN].iloc[-1] # latest snapshotLEVELS = 25 # 10 on Free/Explorer — the delivered ladder is tier-cappeddef fill_vwap(snap, size):"""VWAP for a buy of `size`, walking the ask ladder.Returns (vwap, filled). filled < size means the visible ladder ran out:the VWAP is for a PARTIAL fill and understates the true cost of size."""filled, cost = 0.0, 0.0for i in range(1, LEVELS + 1):px, sz = snap[f"ask_px_{i}"], snap[f"ask_sz_{i}"]if np.isnan(px) or filled >= size:breaktake = min(sz, size - filled)filled += takecost += take * pxreturn (cost / filled if filled else np.nan), filledwant = 5_000vwap, filled = fill_vwap(snap, want)if filled < want:print(f"ladder exhausted: {filled:,.0f} of {want:,} available at this snapshot")slippage = vwap - snap.best_ask # cost of size, in probability
ask_levels / bid_levels record the true depth of the book, which can exceed the delivered ladder — compare them against LEVELS to tell a genuinely exhausted book from one that was simply truncated at your tier's cap.
No delta replay needed: the book at any instant is each token's last snapshot at or before that instant. ts_recv_ns orders events; msg_seq breaks ties.
T = pd.Timestamp("2026-05-19 14:30:00Z").value # ns since epochbook_at_T = (l2[l2.ts_recv_ns <= T].sort_values(["ts_recv_ns", "msg_seq"]).groupby("token_id").tail(1))# cross-day studies: book_state.json.zst carries the end-of-day book,# so day N+1 starts from a known state instead of a cold book.