subject
the hash field on Polymarket's market WebSocket, and how to verify a book against it
for
anyone with a WebSocket client and a scripting language who keeps getting mismatches
evidence
our collector's captures and analyses, May to August 2026; the venue's public client and docs
note
Gabbatron is not affiliated with Polymarket. Polymarket is a trademark of its owner.

Polymarket's order-book hash

Every book snapshot and every price_change delta on Polymarket's market channel carries a forty-character hash. It is the venue's own fingerprint of the order book after that message was applied. If you maintain a copy of the book from the deltas, you can recompute the fingerprint after each one and compare. When they agree, your book is the venue's book, byte for byte. When they disagree, something is wrong, and the rest of this page is about what that something usually is. We spent a good part of 2026 finding out, mostly the hard way, while building the collector described on the archiver page.

The hash

a The digest is SHA-1, 160 bits, written as 40 hex characters. Its cryptographic weaknesses do not matter here; nobody is forging book states.b The venue's Python client, py-clob-client, is MIT-licensed. The function is generate_orderbook_summary_hash in utilities.py, version 0.34.6 at the time of writing.

The hash is SHA-1 over a compact JSON document with ten fields in a fixed order.a The venue publishes the recipe in its open-source Python client, and since the function is short we can quote it whole:b

def generate_orderbook_summary_hash(orderbook: OrderBookSummary) -> str:
    payload = {
        "market": orderbook.market,
        "asset_id": orderbook.asset_id,
        "timestamp": orderbook.timestamp,
        "hash": "",
        "bids": [{"price": o.price, "size": o.size} for o in (orderbook.bids or [])],
        "asks": [{"price": o.price, "size": o.size} for o in (orderbook.asks or [])],
        "min_order_size": orderbook.min_order_size,
        "tick_size": orderbook.tick_size,
        "neg_risk": orderbook.neg_risk,
        "last_trade_price": orderbook.last_trade_price,
    }
    serialized = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
    h = hashlib.sha1(serialized.encode("utf-8")).hexdigest()
    orderbook.hash = h
    return h

Six things in that function decide whether your recomputation will ever agree with the venue's.

  1. Key order is fixed. market, asset_id, timestamp, hash, bids, asks, min_order_size, tick_size, neg_risk, last_trade_price. It is the order of the fields in the venue's Go struct. A JSON library that sorts keys will produce a different string and a different digest.
  2. The hash field is present and empty while hashing. It is not omitted.
  3. No whitespace. The separators are , and : with nothing after them.
  4. Every price and size is the venue's string, verbatim. "0.40" and "0.4" are different bytes and give different digests. Do not parse a level into a number and print it again. Store the strings the venue sent.
  5. Level order matters. Bids are listed in ascending price and asks in descending price, so the best of each side is the last element. That is the order the venue sends a book snapshot in, and it is the order it hashes the post-state of a price_change in. A sorted map with the best bid first will hash wrong.
  6. neg_risk is a JSON boolean (false, not "false"); min_order_size and tick_size are strings.

A level whose size becomes zero is removed from the list, not kept at "0". A market that has never traded hashes last_trade_price as the empty string "".

c The event shapes and the keepalive rule are on the venue's real-time data page.

Two of the ten fields are not on the wire

A book frame carries market, asset_id, timestamp, hash, bids, asks, tick_size and last_trade_price.c It does not carry min_order_size, and it does not carry neg_risk. You have to supply both from the market's metadata (the REST market endpoint or the Gamma catalogue). If either is wrong, every hash you compute will be wrong, with no ladder drift at all, and the resulting mismatch looks exactly like a corrupted book. The sixth rule below is about the day that bit us.

d The public client's own test vector, in its tests/test_utilities.py, is bids 0.3 and 0.4 of size 100, asks 0.6 and 0.7 of size 100, timestamp 123456789, min order size 100, tick 0.01, last trade 0.5, and the digest 0458ea5755c9f73d64a14636fa5c36ed460ec394. Reproduce that first; then this one.

A worked example

A tiny book: three bids, three asks, min order size 5, tick 0.01, last trade 0.42, timestamp 1782753357257.d Serialised exactly as the function above does it, the preimage is 348 bytes:

{"market":"0xaabbcc","asset_id":"100","timestamp":"1782753357257","hash":"","bids":[{"price":"0.40","size":"25"},{"price":"0.41","size":"10"},{"price":"0.42","size":"5"}],"asks":[{"price":"0.45","size":"12"},{"price":"0.44","size":"8"},{"price":"0.43","size":"3"}],"min_order_size":"5","tick_size":"0.01","neg_risk":false,"last_trade_price":"0.42"}

and its SHA-1 is 9658449f6aec27f68617836caa6a5f3688596480. Any language will reproduce it from those bytes. The interesting part is how little it takes to change it. Each row below alters one thing about the same book.

ChangeDigest
nothing (the book above)9658449f6aec27f68617836caa6a5f3688596480
write the bid "0.40" as "0.4"45a71cc88ae107e0648146b3de616d6091b3936e
write the last trade "0.42" as "0.420"80274c397bb5fdcad0c28eaec44442cecaafc881
list the asks best-first instead of best-lastf2f3342e4d7e3fc2121d2d9b9054c8e4ff854390
keep the removed ask 0.43 at size "0"7bd8f83d631da81ba9106c36d9265dd453e9be61
remove the ask 0.43 properlyf6af7967dfc2ac3da676059340022b5d1f08ea94
a genuine delta: ask 0.44 goes from size 8 to 609bafb3b4817e8cded3fe1dc49730c184d886e8c
a trade prints and last trade becomes "0.43"7aa040de8514fea9a0397b58d01754e75648f274
min_order_size supplied as "0" instead of "5"9d64a94033f0914f7de12b25e5436308d1027847
tick_size supplied as "0.001" instead of "0.01"60f4ca4f47aa0b3da29d60e89b6dcf32a2312bfc

Four of those rows change nothing about the ladder. That is the whole difficulty of the field: it certifies the book and four scalars at once, and a stale scalar is indistinguishable from a broken ladder until you go looking.

Why you want it

e The market channel is a plain RFC 6455 WebSocket. The venue's docs say to send the text frame PING every 10 seconds; the protocol-level ping frame does not count and a socket that stays silent at the application level gets closed.

A delta stream drifts. Frames get dropped when a socket is closed and reopened, because the venue does not replay what you missed; the fresh book on resubscribe is the only repair. Frames arrive out of order at a resubscribe boundary. A decimal library rewrites "0.40" as 0.4. A level the venue removed is still in your map because your code treated size zero as an update. None of these announce themselves. The book keeps changing and your copy keeps looking plausible.e

The hash is the only in-band signal that your copy matches the venue's. The loop is short: apply the delta, serialise your book the venue's way, hash it, compare with the hash field on the delta you just applied. A match is proof; it means your ladder and your four scalars are identical to the venue's at that instant, because two different books would need a SHA-1 collision to agree.

A mismatch is weaker than it looks. It does not tell you which of the ten fields differs, and, as the next section shows, most mismatches are the venue running slightly ahead of or behind its own stream rather than your book being wrong. Treat one mismatch as a symptom to watch, not a verdict.

The rules

Each rule below was learned from a specific run, capture or incident, and the evidence is stated with it. Where our records give a distribution, it is quoted; where they give one number, that number is all we have.

2026-05-02
one-hour runf Streak duration is measured in the venue's own timestamp field, not in your wall clock, so your latency and clock skew cannot lengthen a streak.

1. One mismatch is not drift. A streak is.

On a full-universe run of about 38,000 tokens on 2026-05-02, our collector's verifier saw 756,810 mismatch streaks begin in one hour and 735,488 of them end on their own, when a later delta brought the two hashes back into agreement. That is 97.2% of mismatches clearing without any action. The remaining 2.8% were repaired by resubscribing, at 3.9 resyncs a second across the whole universe, about one per token every 2.7 hours.

The clearing is fast. An experiment on 2026-05-27 that turned resync off entirely and watched 2.4 million mismatch episodes found that 97% self-healed in under 100 ms and 95% within 7 ms. The venue sometimes emits a price_change whose hash describes a state that only arrives in the next frame, and the next frame fixes it.

So the rule: on a mismatch, record the venue timestamp of the first mismatching frame and keep applying deltas.f Only when a frame mismatches and its timestamp is more than a threshold past that first one do you act. Our threshold was 200 ms. The 2026-05-27 analysis argued for 1,000 ms: at one second only about 1.4% of episodes are still open, and roughly half of those are books that would otherwise wait minutes for the venue's periodic snapshot. Either works; what does not work is acting on the first mismatch.

The same experiment showed what the other tail looks like. Without resync, the books that stayed wrong were active, liquid markets receiving a median of 310 frames while mismatched: one level goes wrong and the continuous delta stream never touches that level again, so the whole-book hash keeps failing until a snapshot arrives, median minutes, tail to hours. Those are the books the resync exists for.

2026-06-01
the incident
2026-07-10
the cause

2. The chronic mismatch that looked like the venue and was not

On 2026-06-01 a small cohort of tokens, four to eight out of about 780 subscribed, tripped the streak threshold every few seconds, all day, rotating as five-minute crypto markets resolved. It burned about 12% of the resync budget and flooded the log. The first investigation blamed two things: the venue's price_change frames batch several level edits under one shared hash, so checking after each edit sees intermediate states that cannot match; and the hash includes last_trade_price, which arrives on its own event, so the local copy lags.

The batching finding was real but not the cause. Every entry in a frame carries the frame's timestamp, so a streak that opens and closes inside one frame measures 0 ms and can never trip. The operative cause took until 2026-07-10 to establish, with a 465,268-frame capture of four consecutive five-minute markets, and it was on the venue's side. Which brings us to the field.

2026-07-10
465,268 framesg A hash that certifies a value you cannot observe in time is a hash you have to solve for. Because last_trade_price is the last field of the preimage, a Merkle–Damgård hash like SHA-1 lets you absorb everything before it once and finish each candidate for a block or two. Rule 8 has the cost.

3. The last trade price in the hash is the venue's, late, and market-level

The last_trade_price the venue hashes is not the value your last_trade_price events give you. Three things differ. It is market-level: one value shared by both outcome tokens of a binary market, expressed as the SELL-side price, so a BUY at p on one token appears as 1 − p; this held on 92.6% of 498,678 frames where the value could be solved unambiguously. It is formatted to the venue's own precision: the hashed string was "" for 24% of 9,624 snapshot books (never traded), three decimals for most of the rest, one decimal ("0.5") on 48 books and four decimals ("0.0025") on 16, all on the 0.0025 tick. And it lags the venue's own trade stream. Of 821,593 hashed items whose value could be solved, 61.8% embedded the latest trade, 34.2% embedded an older one, with a lag of 717 ms at the median, 16.7 s at the 90th percentile, 84.7 s at the 99th and 199.6 s at the worst, and 3.7% embedded a trade whose event had not been sent yet, arriving a median 94 ms before it.

No amount of listening bridges that. A tape of the last four trades and their complements recovered 78% of the mismatches in the capture; a tape of thirty-two recovered 86.5%; the healing value sat more than thirty-two events back, or in the market's pre-subscribe history, for the rest.

What to do when a trade print arrives alongside book updates: apply it to your record, but do not let it clear a mismatch on its own, because the print carries no hash and proves nothing about your ladder. And when a price_change mismatches, before counting it as drift, re-hash the same ladder under a short list of plausible last-trade values: the value you hold at three decimals, its complement, the recent tape and their complements, and, if a resync is otherwise about to fire, a sweep of every price on the market's tick grid in each string form the venue emits.g A candidate counts only if it reproduces the venue's full hash. That makes the heal restore-only: a wrong ladder cannot reproduce the digest under any last-trade value, so you can never talk yourself into trusting a broken book. On the July capture the sweep resolved 95.6% of hashed items; the 4.4% left was genuine ladder drift, three quarters of it single-frame transients.

2026-07-10
same capture

4. tick_size_change rewrites the hash for every frame after it

When a market's price pins near an extreme the venue halves the tick, from 0.01 to 0.001, and sends a tick_size_change frame with the old and new values. The hash includes tick_size as a string, and from that frame on the venue hashes with "0.001". If you keep the value you seeded from the snapshot, every subsequent hash mismatches and no last-trade candidate can heal it, because the field that differs is not the one you are sweeping. Two of the sixteen captured trips in the July investigation were exactly this: substituting the new tick and the right last trade reproduced both venue hashes.

Handle the event, apply the new tick to the book, and treat it like a trade print for trust purposes: it may end a streak, it should not on its own restore trust after a resync has been requested. Then know its limits. In the capture the tick_size_change frames arrived 59 to 74 seconds after the market closed, duplicated once per asset, so on five-minute markets the change shows up in the hash long before the frame that announces it. The snapshot on resubscribe carries the current tick and is the repair that actually works there.

2026-05-24
the storm
2026-05-27
the ruleh A fixed-window rate limit with an unbounded queue and jittered retry. The circuit breaker that preceded it was removed three days later.

5. Resync is not free, and resync on every mismatch melts down

Repairing a book means unsubscribing the token and subscribing again, because the venue only sends a fresh book on a subscribe that follows an unsubscribe. Every subscribe makes the venue push a full snapshot, and that is the expensive operation. On 2026-05-24 our collector, running on an underpowered box whose CPU credits had run out, fell behind on applying deltas, so books lagged the venue by a few hundred milliseconds, so the 200 ms threshold fired everywhere: about 265 resyncs a second, unsubscribe-subscribe pairs faster than the box could service the socket. The venue dropped the connections. Each reconnect re-requested every book on that socket, which deepened the lag, which fired more resyncs. Load average reached 80 on four cores.

Two lessons came out of that day. The first is that a mass resync is a symptom of something upstream, and a rate limit is a guard against your own bug, not a tuning knob. The second came three days later: the first fix, a token bucket with a circuit breaker that suppressed all resyncs for thirty seconds after a burst, was harmful, because it also suppressed the resyncs that repair the slow tail from rule 1. The design that stuck: send the unsubscribe immediately (a dark book is no worse than a wrong one, and unsubscribing only sheds load), rate-limit the subscribes to 50 a second across the whole process, queue what exceeds the cap with a couple of seconds of jitter, retry forever, one pending subscribe per token.h Steady-state demand on a healthy box was 14 to 22 a second; about 166 a second was where the socket drops had started.

2026-07-11
box A/B
2026-07-12
the profilei The state SHA-1 carries between blocks is five 32-bit words plus a length; copying it is the "clone the midstate" operation. The same property underlies length-extension attacks, put to honest use here.

6. At scale, hashing is the cost

A full-universe verifier hashes every price_change for tens of thousands of books, and on a miss it may hash the same ladder a dozen more times under candidate last-trade values. An allocation profile of our collector on 2026-07-12 put the hash pipeline at 75 to 78% of all memory allocated, at a sustained 634 MB/s: turning decimal prices back into strings was 28% of everything, building the preimage bytes 19%. The garbage collector was taking a third of the CPU in off-peak hours. The tick-grid sweep from rule 3, run on every miss, cost the box 3.6 GB of extra resident memory and tripled the load average in an A/B on 2026-07-11, because only 0.259% of the frames it ran on ever went on to trip a resync.

Two changes fixed most of it. Gate the expensive sweep to the frame that would otherwise fire a resync: 114,460 sweeps a minute became 296. And reuse the hash midstate. Because last_trade_price is the last field of the preimage, you can serialise the book once, run SHA-1 over everything up to the opening quote of that value, keep the internal state, and then finish a copy of that state for each candidate with the candidate's few bytes plus "}.i Each candidate becomes one or two compression blocks instead of a full pass over a couple of kilobytes. A sweep of 1,108 candidates measured about 233 µs, against about 25 µs for a single full-serialise-and-hash probe, so the whole grid costs roughly nine full hashes. Applying the same prefix reuse to the cheap tiers cut allocation per miss frame from 74,080 bytes to 6,720 and process allocation by 28% on the box.

2026-07
review roundj Some locales write the decimal separator as a comma; a "thousands-tolerant" parser then reads 0,62 as sixty-two. Use an invariant culture and a decimal type, never a binary float.

7. Formatting and culture

Prices are strings on the wire and strings in the hash, and every formatting decision between the two is a way to be wrong. Our false positives from formatting all had the same shape: a value parsed to a number somewhere and printed again elsewhere. "0.62" became "0.620", or "0.5" became "0.50", or, in a review of our own parsing, a culture-sensitive number parser that accepted thousands separators would have read a comma-formatted 0,62 as 62.j The complement 1 − 0.005 must come out as exactly 0.995, which a binary float will not give you.

Keep the venue's strings for hashing. When you must compute a candidate value, use a decimal type under an invariant culture that accepts only digits, one leading sign and one decimal point, format at the precision the venue used for that market, and treat a value that fails to parse as unparseable rather than as zero.

2026-07-10
restart window

8. At a resubscribe boundary, the snapshot can precede an older delta

Our cross-listener timestamp analysis found one case, in a window where one listener was restarting, where the book snapshot delivered on resubscribe arrived two lines before a singleton book event stamped 11 ms older. The snapshot is generated at subscribe time while an update published just before the subscribe completes is delivered just after it. Applying that older event on top of the fresh snapshot moves the book backwards.

After a resubscribe, compare each incoming frame's timestamp with the snapshot's and discard anything older. The venue's timestamp cannot order events in general (39.6% of frames share their millisecond with another frame for the same token), but it is good enough for this one seam, where the gap is real time and not a tie.

2026-07-05
the repro
2026-07-06
verified live

9. When the catalogue omits min_order_size, the venue hashes "0"

A cohort of thinly traded sports and election books tripped our verifier more than a thousand times each in two hours with zero ladder drift: at every reseed, the local book was byte-identical to the fresh snapshot. The mismatching field was min_order_size. Those markets had no orderMinSize in the catalogue, our collector filled the gap with "", and the venue was hashing "0". A standalone script confirmed it on 2026-07-05: 668 of 668 post-states matched with "0", none with "". Defaulting the missing value to "0" took the cohort from 83,203 streak trips to 108 in a comparable window. If a book never agrees and the ladder is provably fine, the two off-wire scalars are the first place to look.

A verification loop

Language-neutral, and shorter than the explanations above.

subscribe(tokens)                         # {"assets_ids": [...], "type": "market"}
every 10 s: send text "PING"

on book(frame):
    ladder[asset] = frame.bids, frame.asks  # venue strings, verbatim, venue order
    tick[asset]   = frame.tick_size
    ltp[asset]    = frame.last_trade_price
    streak_since[asset] = none
    trusted[asset] = true
    snapshot_ts[asset] = frame.timestamp

on price_change(frame):
    for change in frame.price_changes:
        if frame.timestamp < snapshot_ts[change.asset_id]: continue      # rule 8
        apply(ladder[change.asset_id], change)                            # size "0" removes the level
    for change in frame.price_changes:                                    # hash the post-state once per asset
        a = change.asset_id
        mine = sha1(serialise(market, a, frame.timestamp, ladder[a], min_order_size[a], tick[a], neg_risk[a], ltp[a]))
        ok = (mine == change.hash) or any(sha1(serialise(... candidate)) == change.hash
                                          for candidate in ltp_candidates(a))   # rule 3
        if ok:
            streak_since[a] = none; trusted[a] = true
        else if streak_since[a] is none:
            streak_since[a] = frame.timestamp                              # rule 1: start the clock
        else if frame.timestamp - streak_since[a] > 200 ms:
            trusted[a] = false; streak_since[a] = none
            request_resync(a)                                              # rule 5: unsubscribe now, subscribe through the limiter
        log(a, frame.timestamp, change.hash, mine, ok, streak_since[a])

on last_trade_price(frame):   ltp[frame.asset_id] = frame.price            # record it; do not clear trust with it
on tick_size_change(frame):   tick[frame.asset_id] = frame.new_tick_size   # rule 4

request_resync(a):
    send unsubscribe(a) immediately
    enqueue subscribe(a) behind a limiter of 50/s, retried with jitter until sent, one entry per asset

What to log with every comparison: the asset, the venue timestamp, the venue's hash, yours, the outcome, and the streak start. When something goes wrong you will want the preimage too, but only for the frames that tripped; at full universe the preimages for every frame are gigabytes an hour.

The checklist

  • Keys in the venue's order, hash present and empty, no whitespace.
  • Prices and sizes as the venue's strings; never parsed and reprinted.
  • Bids ascending, asks descending, best of book last.
  • Size "0" removes a level.
  • min_order_size and neg_risk from metadata; missing orderMinSize means "0".
  • One mismatch starts a clock in venue time; act past a threshold, 200 ms or more.
  • Before calling it drift, re-hash under plausible last-trade values; a heal needs the full hash to match.
  • Apply tick_size_change and expect it to arrive late.
  • Trade prints and tick changes never restore trust on their own.
  • After a resubscribe, discard frames older than the snapshot.
  • Unsubscribe at once, subscribe through a rate limiter, never drop a queued subscribe.
  • Decimal arithmetic, invariant culture, no thousands separators.

The loop, run

The same tiny book, a stream of eight frames, and the loop above. One frame in the middle never reaches us; watch what the hashes do, when the streak clock starts, and what clears it. The hashes are computed in your browser with the recipe on this page.

fig 1 · seed, apply, hash, compare, resync
#timeframevenue hashour hashverdict
0+0 msbook snapshot on subscribe9658449f6aec…9658449f6aec…match
1+40 msSELL 0.44 size 6 (was 8)81919d7fae72…81919d7fae72…match
2+95 msBUY 0.42 size 9 (was 5)ee9fde317b20…ee9fde317b20…match
3+165 msSELL 0.43 size 0, level removedf6b1eb8226e4…f6b1eb8226e4…match
4+255 msBUY 0.41 size 14, this frame never reached us831abbb64ab7…(not received)no frame, no check
5+350 msBUY 0.40 size 30 (was 25)400d8f50710f…5d680baa7cb1…mismatch, streak 0 ms
6+470 msSELL 0.45 size 15 (was 12)44d6de42802f…61d562f2896e…mismatch, streak 120 ms
7+593 msSELL 0.44 size 4 (was 6)6f18483e40df…6833817ca496…mismatch, streak 243 ms, past 200 ms: resync
8+653 msbook snapshot after unsubscribe and subscribe631879d7e2a8…631879d7e2a8…match
Frames 1 to 3 apply cleanly. Frame 4 is lost on the wire, so from frame 5 every hash disagrees although each delta is applied correctly. The streak clock starts at frame 5 and passes 200 ms at frame 7, which requests a resync; frame 8 is the snapshot that answers it. With JavaScript on, the panel above steps through the same frames and computes each hash live.

Where this came from

Everything above was learned while building and running a collector that recorded the whole Polymarket market channel, frame for frame, between June and September 2026. The archiver page describes it, including the placement mathematics that kept two copies of the feed from failing together. If you are building something on this feed and want to compare notes, write to archiver@gabbatron.com.au.