tickfoundry
capture livesign inget the data →
§ docs

Documentation

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.

New here? Every account gets 5 free market·days — no card.Browse the catalog →or grab the sample
Columns and formats change occasionally. Every one is recorded in the changelog with what to do about it — latest: 2026-09-02.

How Polymarket structures markets — and what “a market” means here

Polymarket organizes everything into four layers:

SeriesA template for a repeating question, e.g. “Bitcoin Up or Down — 15 minute”.
EventOne instance of that question, e.g. “Bitcoin Up or Down — May 13, 2:15 PM ET”. One-off events (an election, a championship final) have no series.
MarketA single tradeable binary question inside an event, identified by a condition_id. Most events contain one market; some contain several.
Outcome tokensThe two sides of each market (Yes/No), identified by token_id. These are what actually trade on the orderbook, and what the raw websocket feed is keyed by.

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.

standalone event · examplesmarkets inside
World Cup Winner48 — one per nation
World Cup: Player to score147
Presidential Election Winner 202836
Next French Presidential Election36
Masters London 2026 MVP (Valorant)60
What will Fed Rate hit before 2027?20
Nobel Peace Prize Winner 202620

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.

Collections — logical bundles of units

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:

collection · examplesunits inside
2026 FIFA World Cup215 events
Midterms565 events
Trump284 events
AI98 events
Masters London (Valorant)event group

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.

Schema reference

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.

l1.parquet — top-of-book updates

columntypedescription
ts_recv_nsint64Collector receive time, ns since epoch. Authoritative ordering.
ts_src_msint64Upstream Polymarket timestamp, ms (when present in payload).
condition_idstringMarket identifier — joins markets.condition_id.
token_idstringOutcome token — joins tokens.token_id.
best_bid · best_askfloat64Best resting bid / ask price, in probability.
best_bid_size · best_ask_sizefloat64Size resting at the touch.
spreadfloat64ask − bid. 4 decimals in files normalized from 2026-09-01 (exact on every tick); 3 decimals before that, which cannot represent a 0.25¢ tick. Compute best_ask − best_bid for exact work across history.
source_event_typestringUpstream event that triggered the update: book, price_change, …
msg_seqint64Capture sequence number — gap-audited, breaks timestamp ties.

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.

l2.parquet — order-book snapshots

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.

columntypedescription
ts_recv_ns · ts_src_msSame two-clock convention as L1. Historical files may still carry the retired ts_recv_iso — see above.
condition_id · token_idstringJoin keys, as in L1.
event_kindstringFull book event vs incremental update.
source_event_typestringUpstream trigger event.
bid_levels · ask_levelsint16True depth of the book each side. Greater than the populated columns ⇒ this snapshot is truncated at the ladder cap.
bid_px_1 … bid_px_25float64Bid ladder prices, best first. NaN beyond depth.
bid_sz_1 … bid_sz_25float64Bid ladder sizes.
ask_px_1 … ask_px_25float64Ask ladder prices, best first.
ask_sz_1 … ask_sz_25float64Ask ladder sizes.
best_bid · best_askfloat64Top of our reconstructed book.
payload_best_bid · payload_best_askfloat64Top of book as declared by the upstream payload.
bid_ask_reconcilesboolTrue when our reconstruction matches the payload’s declared top.
msg_seqint64Capture sequence number.

trades.parquet — prints

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.

columntypedescription
ts_recv_ns · ts_src_msTwo-clock convention.
condition_id · token_idstringJoin keys.
price · sizefloat64Print price (probability) and size.
sidestringAggressor side.
fee_rate_bpsint32Fee rate applied, basis points. Zero on Polymarket fills to date.
transaction_hashstringOn-chain transaction hash.
msg_seqint64Capture sequence number.

trades_onchain.parquet — settled fills

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.

columntypedescription
ts_ms · ts_isoSettlement time, epoch ms and UTC ISO — second resolution.
condition_id · token_idstringJoin keys, same identifiers as the book layers.
price · sizefloat64Fill price (probability) and size in contracts.
sidestringBUY or SELL, taker side.
outcome · outcome_indexOutcome label and its index within the market.
proxy_walletstringTaker wallet — absent from the websocket feed.
transaction_hashstringSettlement transaction; the primary dedup component.
sourcestringAccess path that produced the row: data_api, dune or rpc.

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.

raw.jsonl — capture envelope

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.

columntypedescription
sourcestringAlways polymarket_market_ws.
collector_id · run_id · conn_idstringWhich collector, run and connection captured this message.
msg_seqint64Monotonic per-connection sequence, gap-audited.
ts_recv_ns · ts_recv_iso · ts_srcReceive time (ns + ISO) and upstream timestamp.
event_typestringprice_change · book · tick_size_change · last_trade_price.
condition_id · token_idstringSubject identifiers (token_id may be null).
parse_ok · parse_errorbool · stringWhether the payload parsed cleanly downstream.
payload_textstringThe verbatim upstream message, as a JSON string.

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.

reference/ — joining the hierarchy

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

Live-feed datasets

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.

rtds_prices — price feeds

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.

columntypedescription
topicstringWhich upstream feed the row came from — see the topic eras below.
symbolstringInstrument. Form differs per topic: <asset>usdt for spot, <asset>/usd for oracle and TWAP.
valuedoubleThe price.
full_accuracy_valuestringUnrounded price as sent upstream, when it differs from value.
window_sint32TWAP window in seconds. Null/0 for non-TWAP topics — only meaningful on the twap_* rows.
src_ts_msint64Upstream source timestamp, ms.
emit_ts_msint64Upstream emit timestamp, ms.
ts_recv_nsint64Our receive time, ns since epoch. Authoritative for ordering.
cohortstringWhich of our two collector nodes received it — dual-node provenance.

Topic coverage — read this before selecting a range

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.

topichistorywhat it is
crypto_prices2026-06-21nowSpot mid (Pyth-style). Exchange spot prices. Symbol form is <asset>usdt.
crypto_prices_chainlink2026-06-21nowChainlink oracle. Oracle prices. Symbol form is <asset>/usd. Carries hype and zec, which spot does not.
crypto_prices_twap_thirty2026-08-04nowChainlink 30s TWAP. One of the two topics that RESOLVE Polymarket crypto up/down markets. Days of history, not weeks.
crypto_prices_twap_sixty2026-08-04nowChainlink 60s TWAP. The second market-resolving TWAP topic. Days of history, not weeks.
equity_prices2026-06-212026-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.

sports_state — game states

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.

columntypedescription
game_idint64Upstream game identifier — the join key across days.
leaguestringCompetition, e.g. MLB / EPL.
home_team · away_teamstringTeam names as sent upstream.
statusstringUpstream status string.
scorestringScore as sent upstream — preserved verbatim, not parsed.
periodstringInning / half / quarter, per sport.
live · endedboolLifecycle flags.
sport_typestringSport family.
slugstringUpstream slug.
updated_atstringUpstream update timestamp.
event_state_jsonstringThe FULL upstream state object. Anything not columnised above is still in here.
ts_recv_nsint64Our receive time, ns since epoch. Authoritative for ordering.
cohortstringWhich collector node received it — dual-node provenance.

Joining feeds to market data

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.

feed day + market day, joined as-ofpython
import pandas as pd
l1 = pd.read_parquet("l1.parquet")
rtds = pd.read_parquet("rtds_prices-2026-08-06.parquet")
# the TWAP that actually settles the crypto up/down markets
twap = 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 design
merged = pd.merge_asof(l1, twap[["ts_recv_ns", "value"]],
on="ts_recv_ns", direction="backward")
merged["basis"] = merged["value"] - merged["best_bid"]

File layout & naming

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 messages

Download bundles

What 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 guide

book_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.

spot/ — crypto spot-price ride-along

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.

Delivery

SFTP

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.

Downloads

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.

REST API

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.

IntegrityDRAFT

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.

REST API

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.

Authentication

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.

Endpoints

All paths are relative to the base URL above.

columntypedescription
GET /meYour plan, limits and current usage — including coverage-start.
GET /catalogqueryList units across the whole venue → {units:[{id, kind, slug, title, vertical, first_date, last_date, market_count}], limit, offset, count}. q= search, kind=series|event, vertical=, covers_date=, limit=, offset=.
GET /downloadsqueryList YOUR staging jobs, newest first → {count, limit, jobs:[{id, unit, date, layers, state, bytes, poll, url, …}]}. limit= (≤200), state=pending|staging|ready|failed. Built for idempotent clients: list → reuse ready urls → only POST what is missing.
POST /downloadsbody{unit_id, date, layers?, format?, candles?} → {status:"staging", id, poll, layers, format, candles, max_l2_levels}. unit_id is the UUID from /catalog (field id), not a slug. candles: true adds candles/tape_1m.csv + candles/mid_1m.csv to the bundle (small zipped CSVs); omitted, it defaults on for accounts created after 2026-08-24 and off for older accounts, so existing parsers keep seeing the same members.
GET /downloads/{id}Poll a staging job → {status:"staging", retry_after} while building, {status:"ready", url, url_csv, bytes} when done, {status:"failed", error} if it did not.
GET /downloads/{id}/fileStream the staged bundle (?format=csv for the CSV twin). Send your API key — there is no public URL; available once ready.
GET /feedsLive-feed inventory → {access, feeds:[{feed, label, days, first_date, last_date, bytes, record_count}], topics:[…]}. access tells you up front whether this key can pull feeds: plan | addon | demo | requires_addon. A free account gets demo: one fixed sample day, the same date for every account, at full fidelity and costing none of its 5 market·day claims — any other date returns 402. The legacy claim | insufficient | exhausted values are no longer issued.
POST /feeds/downloadsbody{feed, start, end} → {status:"staging", id, poll, days, bytes, via}. feed is rtds_prices|sports_state; the range is inclusive, 31 days max. Poll the same GET /downloads/{id}.
GET /onchainqueryUnits with on-chain fill coverage → {access, total, limit, offset, units:[{id, slug, title, first_date, last_date, market_count, book_data}]}. q= search, limit= (≤500), offset=. book_data:false means the unit has chain fills only — it closed before book capture, so /downloads can never serve it.
GET /onchain?unit_id=queryOne unit's per-day chain coverage → {unit:{first_date, last_date, book_first_date, book_last_date}, days:[{date, bytes, fills, sha256}], max_range_days}. from=/to= narrow the window.
POST /onchainbody{unit_id, date, date_end?} → {status:"staging", id, poll, days, fills, bytes}. Inclusive range, 31 days max, Premium and up. Poll the same GET /downloads/{id}.

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.

pull a week of the sports feedpython
import requests, time
H = {"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 | exhausted
job = 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 host
s = requests.get(f"https://tickfoundry.com{job['poll']}", headers=H).json()
if s["status"] != "staging":
break
time.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.

curlbash
# 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 host
POLL="$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" ]; do
sleep 5
done
# 4. download the bundle — the url is this API (key-authed), not a public link
DONE=$(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
python · requestspython
import time, requests
API = "https://tickfoundry.com/api/v1"
# key minted in the dashboard, shown once, sha256-stored
S = 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.day
job = 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 url
zipped = S.get(job["url"]).content # job["url_csv"] for the CSV twin
open("polymarket-btc-up-or-down-2026-05-19.zip", "wb").write(zipped)

Python SDKDRAFT

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.

Rate limits

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.

planreq / minreq / daydownloads / day
Explorer601,00015
Premium600unlimitedunlimited
Enterprisecustomcustomunlimited

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.

Quickstart — load and join in pandas

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.

quickstart.pypython
import pandas as pd
l1 = 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 outcome
l1 = (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 time
tok = 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

Worked example — slippage from the L2 ladder

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.

slippage.pypython
import numpy as np
l2 = pd.read_parquet("l2.parquet")
snap = l2[l2.token_id == YES_TOKEN].iloc[-1] # latest snapshot
LEVELS = 25 # 10 on Free/Explorer — the delivered ladder is tier-capped
def 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.0
for 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:
break
take = min(sz, size - filled)
filled += take
cost += take * px
return (cost / filled if filled else np.nan), filled
want = 5_000
vwap, 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.

Worked example — the book at time T

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.

book_at_t.pypython
T = pd.Timestamp("2026-05-19 14:30:00Z").value # ns since epoch
book_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.

Data quality

Dual collectorsTwo independent collectors in separate regions capture the same feed. Each message carries collector_id, run_id and conn_id so provenance is never ambiguous.
Sequence auditingmsg_seq is monotonic per connection. Gaps are detected mechanically, cross-checked against the second collector, and backfilled from it.
Deduplicationpayload_sha256 hashes every upstream message — duplicates across collectors and reconnects are dropped deterministically.
ReconciliationEvery L2 snapshot compares our reconstructed top-of-book against the payload’s declared best bid/ask; bid_ask_reconciles flags any disagreement.
Parse transparencyMessages that fail downstream parsing are kept in raw with parse_ok=false and the error recorded — nothing is silently discarded.
Replayable by designThe raw layer is the system of record; every parquet is reproducible from raw + reference. book_state.json.zst seeds day-boundary replays.

FAQ

What granularity is this, exactly?
Event-level: every websocket message — every book update, price change and trade — receive-timestamped to the nanosecond. Not periodic snapshots; if the book changed, there is a row.
Do I need parquet tooling?
No — every bundle ships a parallel CSV variant (…-csv.zip) with the same tree. Parquet is recommended for L2 at scale.
How do L1 and L2 relate?
L1 rows are derived top-of-book updates; L2 rows are the ladder after each update, up to your tier's cap (25 levels, 10 on Free/Explorer). L1 is smaller and faster for price/spread work; reach for L2 when you need depth.
Can I rebuild your parquets myself?
Yes, under an Enterprise engagement: raw.jsonl.zst plus the reference tables is sufficient to reproduce every normalized file, and the replay-engine source is part of the engagement. Raw is delivered per contract, not through the self-serve API.
What happens at day boundaries?
Each partition closes with book_state.json.zst — the book at the END of that day — so the next day’s replay starts from a known state rather than a cold book. To warm up day N you want day N−1’s seed. It ships full-depth in Premium and Enterprise bundles; it is omitted at the 10-level cap, where l2.parquet is the delivered ladder.
Get the free sample →See pricing