Bulk downloads paused for some clients. Requests identifying as "AlphaHound research importer" are blocked while we rebuild the archive, because they took over half of all download capacity. Bulk downloads are also paused while we roll out v3.1 (a new format with smaller files) for "polytaker-source-audit", a python-requests client on one IPv6 network and a client sending a browser user agent from one server. Please contact us for a mirror arrangement: Discord or X.
Polymarket Orderbook Archive archive.pendulumflow.com

V3.1 data overview

DownloadAudit

v3.1 is in live beta. The format is frozen; if we find a problem, an hour may be re-issued as a new generation. The hour reports are still the v3 design while we build v3.1-specific ones. Coming from v3? Compare the two yourself on the battle test page, and see our step-by-step guide to switching.

Now shipped, in quickstart.py (README):

  • a one-line way to read any hour
  • a ready-made rebuild of the second token's rows
  • a simpler spread

More is coming soon:

  • a per-hour market and category index
  • hourly crypto settlement data (price to beat, final price, average)
  • Chainlink and Binance reference prices
  • the signed orders behind each fill, with links to their blockchain transactions
  • a public order-latency series
  • Polymarket's liquidity-reward parameters, published daily
  • each recorder's connection and gap log for every hour
  • a correction policy, with a changelog for every change that affects the data
  • an opening state with no look-ahead (only what a live observer could have known at the top of the hour)
  • the history back to August

Please tell us what you find, and which of these matter to you: Feedback and requests.

v3.1/, from 2026-10-27T00: several files per UTC hour, listed by that hour's manifest, under v3.1/YYYY-MM-DD/HH/. Receive and exchange times are in milliseconds. The v3.1 preview sets out what v3.1 adds to v3; the notice above links the battle test and the guide to switching.

Published under CC BY-NC 4.0: free for non-commercial use with attribution. Commercial use needs a separate licence from us; ask through the contact links on the site.

Military Grade

Good enough to replay the orderbook. Several of our machines record the feed, and every row names the machines that saw it (witness_set). Each hour names one primary recorder, chosen once a day from the previous week's coverage (or that hour's best recorder when the primary did badly); keep is_primary_copy = true to see that one recorder's own view. In a two-outcome market a row for the second token is dropped only when it is an exact mirror of the first token's row, and it can be rebuilt. Where the hour's manifest lists one, its twin_check file gives counts and fingerprints of the rows as we captured them, so you can check your rebuild. Repeat book snapshots are dropped on purpose and cannot be rebuilt. Every stored row carries its receive time to the millisecond, rounded down (timestamp + recv_lag_ms); a rebuilt mirror row takes its own from the provenance file. Within a token, rows are ordered by venue timestamp, then sequence, then row_ordinal. Each hour is verified before it goes up, and the report of that check is published beside its manifest. The replay engine checks every file an hour lists against its sha256 before it replays it.

In short

How an hour is laid out

An hour is several files, so start from its manifest (for example 2026-10-11T01.manifest.json). Download the manifest first, then exactly the files it lists, plus the token map its canonical_token_map.object names.

The hour's rows are split by category, and busy sub_tags into their own units. Each unit is a lean file and a provenance file, or N such pairs when the unit is in parts. The lean file holds the event columns and each row's receive time, and the provenance file holds each row's sequence and which of our recorders saw it. The two are joined on row_ordinal. Our twin checks and replay engine check that each provenance file holds exactly its lean file's row_ordinal values. A file name reads left to right as <hour>.<category>[.<sub_tag>][.p<N>][.provenance].parquet.

A busy (category, sub_tag) gets its own pair, <hour>.<category>.<sub_tag>..., and the rest of the category stays in <hour>.<category>..., so one category can span several files. Which units are split is decided once a day from the busiest hour of each of the seven days ending two days earlier, and then held for at least 28 days. The manifest's layout_split.split names the units split out in that hour, and layout_split.parts gives the number of parts N of each unit. A unit in one file has no .p<k> in its name; a unit in parts is named .p1 to .p<N>, the first included.

Each row's category and sub_tag come from one category map, named in the manifest by category_map_sha256. Two category values do not come from a venue tag: uncategorised, a market with no venue tag our map turns into a category, and unknown, a market with no category in our metadata when the hour was built, mostly one not yet asked. The hour's audit gives how many. It appears only in hours built with our category gate or re-sorted later.

<hour>.manifest.json

The hour's index. Its files list gives each listed file with its role, category, sub_tag, part and sha256 (a companion's entries have category and sub_tag null or absent). The manifest itself, the verify report and the token map are not in files. products gives each event type's row_count, min_sequence and max_sequence. An event type the hour was merged for but that has no rows is listed with row_count 0 and null min_sequence and max_sequence. recorders gives each recorder's summary and outages, and primary_witness the hour's primary recorder. layout_version names the format; generation changes each time the hour is re-issued (below). Ignore any top-level key you do not know.

canonical_token_map.object

The manifest's path to the token map, under v3.1/maps/: each market's canonical and complement token. You need it to rebuild mirror rows. It is not in files.

Other files of the hour, among them the verify report, twin_check, opening_state.parquet and the recorder logs, are under The hour's sidecars below.

Columns

Every column is nullable in the Parquet schema. In an hour that passes verify, the columns marked never null below never are. The lean file has 27 columns. The provenance file has 11. The provenance file has no timestamp_received column. The receive time is stored once, in the lean file. Each lean file's footer declares the sort category, sub_tag, market, asset_id, timestamp, row_ordinal, and each provenance file row_ordinal. The types below are Arrow types.

Lean file: <hour>.<category>[.<sub_tag>][.p<N>].parquet, 27 columns

event_typedictionary<values=string, indices=int8>never null

One of seven: best_bid_ask, book, last_trade_price, market_resolved, new_market, price_change, tick_size_change.

timestamptimestamp[ms, tz=UTC]never null

The venue's time for the message, in milliseconds. On book, market_resolved and new_market rows it is often long before we received the message.

marketdictionary<values=binary, indices=int32>never null

The market.

asset_iddictionary<values=binary, indices=int32>

The outcome token, as its 32-byte token id.

best_biddecimal128(9, 4) best_askdecimal128(9, 4)

Carried by price_change and best_bid_ask rows.

bidslist<item: struct<price: decimal128(9, 4), size: decimal128(18, 6)>> askslist<item: struct<price: decimal128(9, 4), size: decimal128(18, 6)>>

A book has bid levels and ask levels. Prices and sizes are exact decimals.

pricedecimal128(9, 4) sizedecimal128(18, 6) sidestring

A price change has a price and a side, BUY or SELL. Prices and sizes are exact decimals.

slugstring

A market's slug is on its new_market row.

categorystring sub_tagstringnever null

From the hour's category map (above). Every category, and every sub_tag that is split into its own files, matches [a-z0-9_]+(-[a-z0-9_]+)*: lower case, digits, _ and single inner hyphens, never a dot.

mirror_droppedboolnever null

True on a row whose matching row for the market's other token was dropped as an exact mirror. You can rebuild that row. Only price_change, best_bid_ask and book rows are mirrored (the manifest's mirror_event_types).

recv_lag_msint64never null

Our receive time minus timestamp, in milliseconds. Set on every row of every event type. timestamp + recv_lag_ms is the time we received the message, to the millisecond, rounded down. It can be negative when the venue's clock is ahead of ours.

row_ordinalint64never null

The row's number in the hour. It takes each value from 0 to the manifest's row_count - 1 once across all the hour's files. Within one file it need not be contiguous.

fee_rate_bpsuint16

Not documented yet. The file carries it; this page does not yet say what it is for.

transaction_hashbinary

Not documented yet. The file carries it; this page does not yet say what it is for.

idstring

Not documented yet. The file carries it; this page does not yet say what it is for.

assets_idslist<item: binary>

Not documented yet. The file carries it; this page does not yet say what it is for.

winning_asset_idbinary

Not documented yet. The file carries it; this page does not yet say what it is for.

winning_outcomestring

Not documented yet. The file carries it; this page does not yet say what it is for.

outcomeslist<item: string>

Not documented yet. The file carries it; this page does not yet say what it is for.

questionstring

Not documented yet. The file carries it; this page does not yet say what it is for.

old_tick_sizedecimal128(9, 4)

Not documented yet. The file carries it; this page does not yet say what it is for.

new_tick_sizedecimal128(9, 4)

Not documented yet. The file carries it; this page does not yet say what it is for.

Provenance file: <hour>.<category>[.<sub_tag>][.p<N>].provenance.parquet, 11 columns

row_ordinalint64never null

The join key to the lean file.

sequenceuint64never null

An unsigned integer. A primary copy's sequence is the primary recorder's own sequence number.

arrival_skewint64

Kept to the millisecond. With witness_set, it shows row by row how receive times differ between recorders. That difference mixes the network path with clock error.

witness_setdictionary<values=string, indices=int16>never null

The recorders that saw the row, each id between bars, with a bar at both ends (|a|b|).

twin_seq_deltaint64 twin_recv_delta_nsint64 twin_arrival_skewint64

twin_seq_delta and twin_recv_delta_ns are set only on rows with mirror_dropped = true; twin_arrival_skew can be null there too. A rebuilt mirror row's sequence is sequence + twin_seq_delta (the delta is signed). Its receive time is its canonical row's plus twin_recv_delta_ns, in nanoseconds.

spread_differsboolnever null

See spread_raw.

spread_rawdecimal128(9, 4)

From pair-3 on there is no spread column. Where the venue's spread equals what the rule gives (best_ask - best_bid on a best_bid_ask row with both sides, compared exactly at the venue's four decimals; nothing on any other row), it is dropped. The venue's exceptions are kept in spread_raw and flagged by spread_differs. The spread is CASE WHEN spread_differs THEN spread_raw WHEN event_type = 'best_bid_ask' THEN best_ask - best_bid END. Use CASE, not COALESCE, or a best_bid_ask row whose message had no spread field, although the rule gives one, comes out wrong.

source_witnessdictionary<values=string, indices=int8>never null

Which recorder's copy of the row we kept.

is_primary_copyboolnever null

True when source_witness is the hour's primary recorder.

The hour's sidecars

Beside its category files, an hour has a verify report and, when the manifest lists them, a twin_check file and an opening state.

<hour>.verify_report.json

Each hour is checked before it goes up, and the report of that check is published beside the manifest. It is not in files. Check it by its sha256, which must equal verify_check_id in the manifest's mirror_audit and book_audit blocks. That id is set once our private lists of dropped rows are deleted. Until then the audit blocks say kept until verify and verify_check_id is null. Each check in the report has ok: true is a pass, false a failure, and null means the check did not run (skipped: true). The top-level ok is true only when every check that ran passed. layout_version, generation and manifest_sha256 tie the report to one manifest. manifest_sha256 is the hash of the manifest as built, before the private lists were deleted, so once they are deleted it is not the hash of the published manifest. as_built_manifest_sha256 in quickstart.py rebuilds it from the published bytes. Do not re-serialise the JSON. verify_rules names the set of checks it ran, as <layout_version>/r<n>.

twin_check

One line per category, market and event type, with three row counts and three fingerprints of the rows as we captured them: the dropped mirror rows (twin_rows, fingerprint), the rows the files store (stored_rows, stored_fingerprint), and both together (all_rows, all_fingerprint). Take the column names from the manifest's twin_check.sets. check_twins refuses a manifest that does not list exactly one twin_check file. The fingerprint format, twin-check-v1, is described in quickstart.py. twin_check_vectors.json has test values, so you can check another tool against it. It covers the 28 v3 columns. It does not cover sub_tag, is_primary_copy, row_ordinal or row order, and it does not bring back the repeat books we drop.

opening_state.parquet

Every token's state at the top of the hour. It is the previous hour's end state, carried forward, then this hour's own rows stamped at or before hh:00, in (timestamp, sequence, row) order. book_as_of gives the venue time of the last row that changed the token's known depth, bba_as_of that of the last book, price_change or best_bid_ask row applied, and trade_as_of that of the last last_trade_price row applied. In an hour that passes verify, each is at or before hh:00. It goes by the venue's time, not by when we received a row, so rows received up to about 60 minutes after hh:00 can enter it. It is not what a live observer knew at hh:00. For a start with no lookahead, order and filter by timestamp + recv_lag_ms instead.

Also published with some hours: each recorder's own connection log, <hour>.connection_events.<recorder>.parquet, when we have it. It is the recorder's file, copied byte for byte. Its columns are the recorder's, and its timestamp_received is in nanoseconds. A pair-4 manifest has a companions block for files that will come later. In pair-4 hours as first published, order_intent and chain_match are pending.

More than one machine is watching

Several of our recorders capture the feed. Every row names the recorders that saw it, in witness_set. The manifest's recorders block gives each recorder's summary and outages, and, since absent_fields is hour-wide, recorders.witnesses.<recorder>.status gives each recorder's own state for its connection log. Its three clock figures, clock_steps, clock_step_total_ns and clock_step_max_abs_ns, show how much a recorder's clock was corrected in the hour, not how far it was from true time. In pair-3, the absolute error of a recorder's wall clock against UTC is not measured.

The primary witness

Each hour names one primary recorder, primary_witness, with primary_reason saying why it was chosen. The choice is made once a day from the previous week's coverage, so it can be up to a day out of date. An hour where the primary recorder did badly uses that hour's best recorder instead (primary_reason = bad_hour). Rows with is_primary_copy = true carry the primary recorder's own receive times and sequence numbers. The other rows are what the other recorders added. Hours merged before this rule have primary_witness = null and is_primary_copy = false on every row.

The hour boundary

An hour holds rows stamped well before it. In 2026-10-11T01, 47.3% of book rows were stamped before the hour (median lag 7.4 h), and 0.0% of price_change, best_bid_ask, last_trade_price and tick_size_change rows. Hour 01 has 624,528 book rows stamped before 01:00. opening_state.parquet carries the previous hour's end state across hh:00. A reference-price window that ends exactly on the hour, for example 02:00:00, is in the next hour's file. A recorder's outage interval can reach outside the hour.

What is new in v3.1

Ordering

The manifest's notes give a token's row order as (timestamp, sequence, row_ordinal). Recipe 2, given the provenance file, returns each token's rows in that order. A rebuilt mirror row sorts by its own sequence. Stored rows that tie on timestamp and sequence come out in their published order, row_ordinal. Rows stamped in the same millisecond are ordered by sequence only if you read the provenance file, where sequence is.

row_ordinal is unique: it takes each value once across the hour's files. In an expansion with mirror rows rebuilt, a rebuilt row keeps its canonical row's row_ordinal, so there (row_ordinal, is_twin) is unique.

The order in the file is not the order anyone saw

Each lean file holds one run of rows per event type, in this order: best_bid_ask, book, last_trade_price, market_resolved, new_market, price_change, tick_size_change. A type with no rows in the file has no run. Within each run, rows are sorted by category, sub_tag, market, asset_id, timestamp and row_ordinal, nulls last. The footer declares this sort (sorting_columns) for each row group. row_ordinal is numbered the same way, by event type, then category, sub_tag, market, asset_id, timestamp and sequence. So one market's rows of one event type take one unbroken range of row_ordinal. An hour re-sorted under a new category map keeps every row's row_ordinal, so there it need not rise through a file.

timestamp is the venue's time. For an exact cut by arrival, use timestamp + to_milliseconds(recv_lag_ms), never timestamp. To see one recorder's own view, keep the rows with is_primary_copy = true. They may miss a token's first book of the hour, so rebuild books from all the rows, or start from opening_state.parquet.

Which machine recorded each row

The provenance columns answer it.

witness_set names the recorders that saw the row, written |a|b| with a bar at both ends.

source_witness names the recorder whose copy we kept.

is_primary_copy is true when that recorder is the hour's primary_witness. Keep those rows to see the primary recorder's own receive times and sequence numbers. Use that filter for timing and coverage questions, not to rebuild books.

arrival_skew, next to witness_set, shows how receive times differ between recorders, but it mixes the network path with clock error.

An hour is a container

Never glob for an hour's files: a missing or extra part gives wrong answers. Take the files from the manifest. quickstart.py picks up a category's split files for you. Its hour_files refuses a manifest whose lean and provenance files are not exactly the parts layout_split.parts gives. A companion in parts is all of its listed parts: read every one.

When an hour is re-sorted, its files keep their names, so the update is published in order: new files first, then the manifest, then any file no longer listed is removed. For a short window, someone holding the old manifest can download a file that has already been replaced.

An hour is also not a claim about when events happened. A book row's timestamp is the venue's time for the book, which can be hours before we received it. Hour 01 has 624,528 book rows stamped before 01:00. If you need arrival time, use timestamp + to_milliseconds(recv_lag_ms), not timestamp.

How to check what you downloaded

Three checks.

1. The manifest

Check <hour>.manifest.json against the archive's SHA256SUMS.txt, as the manifest's verification note says.

2. Every file the manifest lists

Each files entry gives the file's sha256.

sha256sum <hour>.<category>.parquet
# compare with that file's entry in <hour>.manifest.json

For a downloaded hour, quickstart.py checks the sha256 of each file it hands you: hour_files the lean and provenance files, sidecar_file the opening state or reference prices file, and check_twins the twin_check file. It refuses a mismatch and a listed file that is not there. The replay engine checks every file the manifest lists. quickstart.py does not check files read straight from the site, because that would mean downloading them whole. On a mismatch, download the hour again. The token map is not in files, so this check does not cover it.

3. The verify report

Hash <hour>.verify_report.json. The result must equal verify_check_id in the manifest's audit blocks. Until the private lists are deleted, verify_check_id is null and this check cannot be made. The report's manifest_sha256 must equal as_built_manifest_sha256 of the published manifest, and its ok must be true.

One category, without the whole hour

Every lean and provenance file has its own entry and sha256, so you can download and check one category's files on their own. check_twins takes a category, and checks the mirror rows of that category alone against the twin_check file.

What a checksum here does and does not prove

A file whose sha256 does not match its files entry is not the file that manifest lists. A mismatch is how you catch a file replaced when an hour is re-sorted, while you still hold the old manifest.

Our readers check only what they can see without the source an hour was built from. Damage that the manifest describes consistently, and that only a comparison with the source shows, is verify's to find. Our readers trust an hour whose verify report passed: the report's sha256 is the manifest's verify_check_id, its manifest_sha256 is the published manifest's as_built_manifest_sha256, and its ok is true. On any other hour, that kind of damage can change their answers without an error.

A twin_check match shows that your rebuild matches what we captured. We check that the right rows were dropped in the first place before we publish an hour.

Corrections and generations

An hour can be re-issued as a new generation under the same layout_version: when a companion file is added, or when the hour is re-sorted under a new category map. The manifest it replaces is kept beside the hour as <hour>.generation-<generation>.json, byte for byte. The new manifest's previous_generation gives the old generation and manifest_sha256.

Re-sorts. Polymarket sometimes tags a market after we have published its hour. We then re-sort the hour under the new category map instead of rebuilding it. Every row stays exactly as it was, with the same row_ordinal. Only category, sub_tag and the file a row sits in change. recategorised lists every market that moved, with its old and new category. A market takes a category only if our fetch of it from Polymarket succeeded.

The verify report is not replaced. The report we publish is the one for the hour's first generation. We publish no replacement of it and no other verify report. We check a re-sort against the hour's twin_check file and the kept previous manifest, and do not publish that report. <hour>.verify_report.<generation>.json is reserved for a later generation's report. No hour has one yet.

Keys and rules. Within a layout_version, a manifest's keys do not change in place. A new key arrives only with a new generation, and never changes the meaning of a key already there. A change that would is a new layout_version. Once hours are published under a layout_version, a change to a verify check, threshold or detail text raises n in verify_rules and leaves the layout_version as it is.

Pendulum Flow Discord

Something wrong with an hour, or a file you need that is not here? Say so in the Discord, where the people who run the site and prediction market data geeks compare notes.

Join the Discord

Credit the collector: pendulumflow for V3 and V3.1, PMXT for V1 and V2, AG6 for their V2 archive. Ours is CC BY-NC 4.0, commercial licence by contact; PMXT's is CC BY 4.0; AG6 states no licence. How to cite.

Serving these bytes is not endorsing them. We are not affiliated with Polymarket.

For AI readers: llms.txt, what this archive holds and the questions it cannot answer.

https://x.com/PendulumFlow

Join the Pendulum Flow Discord: other people who build on prediction-market data, comparing notes and sharing tooling and findings.