Skip to main content
Stream live orderbooks, trades, candles, prices, and volume over a persistent WebSocket connection using binary protobuf messages. This page is the full contract for that stream: how to connect and authenticate, what to send, what arrives, how to stay in sequence, and what every error and close code means.

Connecting

The server accepts API keys, session JWTs, and a deliberately bounded anonymous public-market-data tier.

Authenticated connections

The presence of any API-key header switches the server into API-key mode and requires the complete header set. Otherwise it expects a JWT. API key (recommended for programmatic clients): Include your API credentials as HTTP headers on the upgrade request:
If X-Client-Id is present but X-Api-Key or X-Api-Secret is missing or invalid, the upgrade is rejected with 401 Unauthorized. If API-key validation is not configured on this server, the upgrade returns 503 Service Unavailable. JWT (browser / SDK): Browsers cannot set custom headers on a WebSocket upgrade, so the JWT is passed via Sec-WebSocket-Protocol:
Encode the complete JWT as base64url without = padding. The server echoes back Sec-WebSocket-Protocol: authorization on the 101 response. If the protocol header is missing/malformed or the token fails validation (signature, expiry, revocation), the upgrade is rejected with 401 Unauthorized. JWTs are also revalidated every 60 seconds while the connection is open — see Errors & Disconnection.

Anonymous public access

Connect to the environment you are integrating with and request:
The server echoes kairos.marketdata.public.v1 in the 101 Switching Protocols response. The explicit protocol is required: a missing credential does not silently downgrade an authenticated integration into the anonymous tier. Anonymous sessions are limited across a gateway fleet to 100 concurrent connections and two connections per observed source IP. Each connection is limited to three markets, ten inbound control messages per second, and a one-hour lifetime. Redis-backed leases enforce the fleet limits across replicas and expire after a crashed task. These limits can be lowered during beta and are not a service-level commitment. An empty topic list selects the ordinary public topics. analytics and arb must be named explicitly, for every auth mode. Snapshot recovery (FetchRequest) is limited to one request per second for anonymous callers and cannot consume the recovery capacity reserved for authenticated clients. Unknown topics, invalid intervals/providers, and malformed or transport-unsafe market identifiers fail with an error. Ordinary demand-driven subscriptions can create bounded upstream presence leases. Perpetual v2 books are different: automatic catalog enrollment keeps them warm, and a perps_book-only subscription does not create a legacy presence key. A successful subscription acknowledgment validates the request; it does not by itself guarantee a fresh upstream message.

A complete connect-and-subscribe example

This Python client connects with API-key headers, subscribes to one Polymarket contract, and dispatches every frame by its 1-byte type tag. Compile the protobuf schemas first — see Protobuf Reference.

Message Format

All messages are binary WebSocket frames. Existing v1 messages have the format:
The first byte identifies the message type. The remaining bytes are a serialized protobuf message. See Protobuf Reference for schema definitions. The v2 perpetual book adds an explicit version byte:
See Perpetual streaming and recovery.

Subscribing to a Market

Send a SubscribeRequest to start receiving data for a contract:
SubscribeRequest fields:

Venues and products

One gateway carries every product. The provider slug — not the market id — decides which product you are subscribing to, so read this table before choosing a contract_id.
Hyperliquid appears twice on purpose: HIP-4 outcome markets and perpetuals are two different products on one venue. They use different id schemes, different price scales, and separate decoders, and they are never interchangeable. A HIP-4 market is addressed by its numeric outcome id under hyperliquid; a perpetual is addressed by its canonical instrument id under hyperliquid_perps. See Instruments and identity for the perpetual id rule.
These slugs identify gateway routing. A slug being accepted does not promise that a producer for that venue is enabled — see Availability.
Available topics: price_updates is accepted as an alias of price. Any other value is rejected with an ErrorResponse carrying Unknown subscription topic: <value>. Only price, volume, trades, candles, orderbook, ticks, and quote are selected by an empty topic list. orderbook_slow, analytics, arb, perps_book, synthetic, synthetic.snapshot, and synthetic.quote are never included in that default set. The orderbook topic also carries tick-grid changes (TickSizeChange, tag 0x1C, payload kairos.v1.TickSizeUpdate): a market’s valid price grid can change mid-session (Polymarket flips its flat tick at the 0.96/0.04 extremes; Kalshi grids are synthesized from the venue’s price_ranges), and the notice rides the book topic so no extra subscription is needed.

Grouped delivery (orderbook_slow)

Subscribe with topics: ["orderbook_slow"] to receive snapshots and deltas on a configurable window, defaulting to 150 ms for every exchange. The cached full OrderbookSnapshot on subscribe and explicit fetch responses remain immediate. Tick-grid changes retain their normal delivery. Deltas arrive as OrderbookDeltaBatch frames (tag 0x1E, payload kairos.v1.OrderbookDeltaBatch, a repeated OrderbookDelta deltas). Every delta retains its arrival order, seq, snapshot_seq and epoch; apply each with the rules in Orderbook Delta. Snapshots keep their normal 0x02 wire format and their ordering relative to deltas. Adjacent snapshots within a window may be replaced by the latest snapshot. Operators can change the interval and batch caps at runtime, globally or per exchange. Caps can cause an early flush or multiple frames per window, and a snapshot between deltas separates their batches. Clients must not assume a fixed cadence. Quiet books emit no batches. The Kairos web UI uses this topic for order-book widgets. Use orderbook when you need each delta as it is published. Requesting both topics delivers both streams, so clients should avoid that unless they handle duplicate data. Synthetic books use their own versioned data envelope and decoder. See the Synthetic Book Stream tutorial for the exact subscription, schema, and recovery rules. Example: Subscribe to only prices and trades for a Polymarket contract:
Example: Subscribe to 1-minute and 1-hour candles only:
Example: Subscribe to a Hyperliquid HIP-4 outcome market:
Use the numeric HIP-4 outcome id as contract_id. The initial OrderbookSnapshot.token_ids contains the side coins (for example #1010 and #1011) used as token_id during order submission.
One connection cannot carry the same contract_id from two providers. Market-data routing and cache identity are provider-qualified, and until every legacy v1 frame carries provider identity on the wire, you must use separate connections. This matters for non-expiring perp symbols such as BTC and prevents venue A from receiving venue B’s book or delta stream.
Example: Subscribe to a Hyperliquid perpetual v2 book:
A perpetual book arrives as [0x40][0x02][BookSnapshot]. Treat it as a full replacement and use its ExactDecimal prices and quantities directly. Do not apply prediction-market complement arithmetic. Polymarket Perps:
Kalshi Margin:
Always discover the canonical ID rather than deriving it from these examples. Perps use v2 only; topics: ["orderbook"] selects the legacy v1 orderbook family and is not the perpetual beta contract.

Crypto oracle prices

Subscribe with provider: "oracle" to receive live crypto resolution prices as price (PriceUpdate) messages. These feeds are always on (no warm-up needed) and carry no orderbook/trades/candles, so subscribe with topics: ["price"]. Every resolution source shares provider: "oracle". The contract id is the source identity — bare symbols are Binance spot; venue series append a suffix so they never overwrite each other.
⚠️ Reading the price: an oracle PriceUpdate is not a two-sided quote — mid_price, best_bid, and best_ask are always 0. The spot price is in the raw_price_cents field, which for oracle is scaled by 100,000 (micro-USD), not cents. Compute price_usd = raw_price_cents / 100000. Example: BTC at $97,000.50 arrives as raw_price_cents = 9700050000.
Available contract_id symbols:
For historical oracle prices (down to 1-second resolution), use the REST GET /markets/crypto/oracle-history endpoint with the matching source — see Crypto oracle history.
The server responds with a SubscribedResponse (0x14):

Unsubscribing

Send an UnsubscribeRequest to stop receiving data:
The server responds with UnsubscribedResponse (0x15). Unsubscribing removes all topics for that contract. There is no way to selectively unsubscribe from individual topics, except for the three synthetic topics: see Synthetic Book Stream.

Modifying Subscriptions

There is no dedicated “modify subscription” message. However, you can change what data you receive for a contract using the existing subscribe/unsubscribe messages.

Adding Topics

Send another SubscribeRequest for the same contract_id with the additional topics. Subscriptions are additive — the new topics are added on top of any existing ones.
After this, you will receive orderbook snapshots, trades, and candles. The server will also re-send cached snapshots (orderbook, latest prices, etc.) when it processes the new subscribe.

Removing Topics

You cannot remove individual topics from an active subscription. To narrow your subscription (e.g., stop receiving orderbook data but keep trades), you must:
  1. Unsubscribe from the contract entirely
  2. Re-subscribe with only the topics you want
There will be a brief gap in data delivery between the unsubscribe and re-subscribe.

Changing Candle Timeframes

To add new candle timeframes, send another SubscribeRequest with the additional timeframes. To remove timeframes, unsubscribe and re-subscribe with the desired set.

Arb subscriptions

Cross-venue match and spread batches are a server-filtered, identified subscription rather than a per-contract one. Subscribe with contract_id: "arb" and topics: ["arb"] — any other combination is rejected with arb subscriptions must use contract_id=arb and topics=[arb]. Set SubscribeRequest.arb_subscription to an ArbSubscription: The server acknowledges with SubscribedResponse (0x14) carrying contract_id: "arb" and the effective arb_subscription, then replays cached batches through the filter. Data arrives as ArbMatchList (0x0D) and ArbSpreadList (0x0E), each echoing your subscription_id. ArbMatchList.category_counts is a census of the catalogue by subject counted before the category filter, so a client can draw its own category controls. Send FetchRequest with arb_subscription_id to read a subscription back plus its cached batches; an unknown id returns ErrorResponse with action: "fetch" and arb subscription not found. Send UnsubscribeRequest with arb_subscription_id to delete one (or contract_id: "arb", provider: "arb" to delete all on the connection); the ack is UnsubscribedResponse echoing the removed id. Each arb subscription counts as one subscription against the per-connection and per-account subscription limits.

Keepalive

Client Ping

Send a PingRequest (0x12) with your timestamp. The server echoes it back as PongResponse (0x13). Use this to measure round-trip latency. A PingRequest that fails to parse still gets a PongResponse with timestamp_ms = 0.

Idle timeout and protocol pings

The socket has a 30-second idle timeout. Keepalive is handled at the WebSocket protocol level: the server relies on standard Ping/Pong control frames (which browsers and mainstream client libraries answer automatically) and closes a connection that produces no traffic within the timeout. On a subscribed market-data connection, inbound data resets the timer.
Gotcha: the server never sends ServerPingRequest. ServerPingRequest (0x18) and ClientPongResponse (0x19) exist in the protocol and are accepted (a 0x19 frame is consumed and ignored), but the gateway does not currently emit 0x18. Build liveness on protocol-level Ping/Pong control frames, never on receiving a 0x18.

Data Messages

After subscribing, the server pushes data as it arrives.

Orderbook Snapshot (tag 0x02)

Full orderbook state for all outcomes of a contract. Sent on initial subscribe and periodically thereafter as a refresh, independent of the incremental deltas described below. Each OutcomeOrderbook contains bids and asks, each a list of PriceLevel:
  • price: divide by the snapshot’s price_scale (or 10,000 when it is 0)
  • size_scaled: divide by the snapshot’s size_scale when it is non-zero
When you receive a snapshot, replace your local book entirely and set expected_seq = seq + 1 for delta tracking.
Which venues stamp a venue time. Polymarket, predict.fun and Hyperliquid publish an event time with their book updates, so their frames carry timestamp_source = TIMESTAMP_SOURCE_VENUE and a non-zero venue_timestamp_us. Kalshi and Opinion publish no event time on their book channels — Kalshi’s documented ts is on the ticker/trade/fill channels only — so their frames carry TIMESTAMP_SOURCE_PUBLISHER and venue_timestamp_us = 0. timestamp_us is our clock on every venue, so existing freshness checks are unaffected by any of this.
Every prediction-market provider sends price_scale = 0 and size_scale = 0, which means “use 10,000” — behaviour is unchanged from before these fields existed. Only perpetual snapshots set them. See Price and size scaling.

Orderbook Delta (tag 0x0F)

Incremental update to a previously delivered snapshot. Each delta lists only the price levels that changed since the last message. Deltas are emitted whenever the book changes (typically every few milliseconds on liquid markets). Bandwidth: deltas are typically 5–20× smaller than full snapshots — a delta carrying a single level change is ~60 bytes versus ~1,300 bytes for a full snapshot. On busy markets this can reduce orderbook bandwidth by ~70–90%. Each OutcomeDelta contains bids and asks, each a list of DeltaLevel:
  • price: scaled by 10,000
  • size_scaled: new quantity at this price level. 0 means the level was removed.
Applying a delta to your local book: For each outcome, for each side (bids/asks), iterate the DeltaLevel entries:
  • If size_scaled == 0: remove the level at price from your local book
  • Otherwise: insert or replace the level at price with size_scaled
After applying, increment expected_seq. Before applying anything, run the sequence checks in Sequencing and recovery — they are mandatory for correctness.

Trade Batch (tag 0x04)

Batch of executed trades. Each Trade contains:
  • trade_id, outcome_index, outcome, token_id, token_ids, outcome_names
  • price: scaled by 10,000
  • size_scaled: trade size
  • side: TRADE_SIDE_BUY / TRADE_SIDE_SELL (TRADE_SIDE_UNSPECIFIED = 0)
  • taker_side: venue-supplied taker side as a string
  • timestamp_sec: Unix seconds
  • outcome_0_price, outcome_1_price: outcome prices at trade time (scaled by 10,000)
  • block_number, log_index: on-chain ordering key
  • tx_hash, taker_address, maker_address

Candle (tag 0x03)

OHLC candle updated on every trade. Each OutcomeOHLC: index, name, token_id, and open, high, low, close (all scaled by 10,000). timeframe_seconds lists the timeframes the gateway subscribes when you omit candle_timeframes. You may request any value between 1 and 604800, but only timeframes an upstream producer publishes will deliver data.

Price Update (tag 0x05)

Lightweight mid/bid/ask price change.

Volume (tag 0x06)

Rolling volume statistics.

Quote (tag 0x1A)

Lightweight top-of-book for a single token, delivered on the quote topic. Payload is kairos.v1.Quote.

Tick Size Change (tag 0x1C)

Delivered to orderbook subscribers when a market’s valid price grid changes. Payload is kairos.v1.TickSizeUpdate.

Market Resolution

When a market resolves (any venue), the server pushes an unsolicited MarketResolvedNotice (0x1D) to every subscriber of that contract:
On receiving it:
  • Data stops. The server stops refreshing its upstream feeds for the market, so no further orderbook, trade, price, or candle messages will arrive. The book dies at the source within seconds.
  • Drop local subscription state. Treat the contract as unsubscribed. Sending your own UnsubscribeRequest afterwards is harmless — the server tolerates it.
  • Re-subscribes are rejected. A SubscribeRequest for a recently-resolved market returns the standard ErrorResponse (0x17) with action: "subscribe" and message: "market resolved — subscription rejected", and no data is sent. The rejection persists for 7 days after resolution.
For Polymarket-family (CTF) markets the notice is keyed on the CLOB token id (the id the book streams on); a notice is also emitted for the numeric market id. Kalshi markets are keyed on the ticker. Match the notice’s contract_id/provider against your active subscriptions exactly as you match data messages. Enforcement is soft: the server does not force-close your socket, so a non-compliant client simply ends up in a dead topic receiving nothing. Well-behaved clients surface the resolution and stop expecting live data.

Message Type Tags

Forward compatibility: Always dispatch on the 1-byte type tag and silently ignore unknown tags. New message types may be added in future versions; clients that hard-fail on unknown tags will break on protocol updates.

Price and size scaling

Prices in protobuf messages are integers. The divisor is 10,000 by default, which is what every prediction-market message uses. Formula (prediction markets): decimal_price = raw_value / 10000

Per-snapshot scales (perpetuals)

OrderbookSnapshot carries two extra fields, price_scale and size_scale. 0 means “use the default” — that is what every prediction-market provider sends, and it is why this changed nothing for existing clients. A perpetual snapshot instead sets both to the smallest power of ten that represents that book’s own levels exactly, chosen per snapshot:
size_scale = 0 does not mean “divide by one”. It means the publisher used its own long-standing, provider-specific size scale (Polymarket book sizes, for example, are shares × 100). Keep whatever handling you already have for that case and only switch to the carried divisor when the field is set.
This is the single most important integration detail for perpetuals. One fixed scale cannot carry them. At 10,000, 131 of Hyperliquid’s 177 live coins truncate — HMSTR quotes 0.000169 and would decode as 0.0001, wrong by 41% and still looking like a real quote. At a scale fine enough for HMSTR, BTC’s price overflows the int32 price field. Sizes fail the same way in the other direction: at a fixed fine size scale, HMSTR’s real 167M-token depth clamps to int64 max and advertises effectively unlimited liquidity. Both scales are chosen across both sides of one book, so a bid and an ask in the same snapshot are always directly comparable. Varying the scale between snapshots is safe only because perpetual books publish as whole baselines and are never merged with a snapshot at a different scale. A book that cannot be represented exactly within int32 prices / int64 sizes at any power of ten is refused, not rounded — the publisher drops it rather than emitting a saturated price or a clamped size.

Other scales

  • Trade.price, Candle OHLC, and PriceUpdate.mid_price / best_bid / best_ask are scaled by 10,000.
  • PriceUpdate.raw_price_cents for provider: "oracle" is scaled by 100,000 (micro-USD), not cents — see Crypto oracle prices.

Sequencing and recovery

An orderbook you maintain from deltas is only correct if every delta lands on the exact baseline it was built against. The rules below are not optional.

Sequence gap handling

Track expected_seq per contract. When a delta arrives:
  • If delta.seq < expected_seq: stale, drop it
  • If delta.seq == expected_seq: apply it, set expected_seq = delta.seq + 1
  • If delta.seq > expected_seq: gap detected — you missed messages. Request a fresh snapshot via FetchRequest and skip applying any further deltas until the new snapshot arrives.
Also verify delta.snapshot_seq matches the seq of your last snapshot. If it doesn’t, your baseline is stale and you must resync.
Gotcha: a sequence gap has no tolerance. There is no “close enough” window and no catch-up stream — applying a delta to a stale baseline produces a corrupted local book that mixes levels from two different snapshots, and nothing on the wire will tell you it happened. On any gap, stop applying deltas and resync from a full snapshot.
Independently of seq, drop any snapshot or delta whose epoch is below the highest you have seen for that contract — the epoch is the owning streamer-instance fencing token, and two sequence spaces collide during a handoff. 0 means unfenced (legacy publishers).

FetchRequest (tag 0x16)

Sent by the client to request a fresh full snapshot. The server fetches the reconstructed live snapshot from the book cache — not its own relay copy, which can be behind the sequence you already saw — and delivers it as an OrderbookSnapshot (tag 0x02), which you should use to reset your local book. Use this when you detect a sequence gap or your snapshot_seq baseline doesn’t match the incoming delta’s snapshot_seq. You do not need an active subscription on the contract to fetch its snapshot. A fetch does not always produce a snapshot. When it cannot, the server replies with an ErrorResponse (0x17) whose action is scoped to the request: The server can also send fetch_waiting:{provider}:{contract_id} with orderbook delivery unverified; waiting for baseline without being asked, when it can no longer confirm a book you are subscribed to is current. Stop trusting your local book for that contract until the next full snapshot re-anchors it; fetching one is the fastest way back. Anonymous fetches can never consume the last two upstream recovery slots, which are reserved for authenticated clients.

Slow-consumer (coalesced) delivery

When a contract’s measured delta rate crosses the gateway’s threshold (150 deltas/s by default, leaving again below 60/s), connections that are not on the fast lane stop receiving individual deltas for that contract and instead receive a full OrderbookSnapshot every 50 ms, which re-anchors expected_seq. Nothing is lost. By default only API-key connections are fast-lane eligible (browser JWT sessions are not); the mode is server-configured (api_key, all, or none).
Gotcha: “no deltas arrived” is not a stalled book. A coalesced contract stops sending deltas entirely. Keep applying snapshots as full replacements and re-derive expected_seq from each one.

Errors & Disconnection

Clients MUST be prepared for the server to reject the upgrade, push an in-band error, or close the socket at any time. Robust clients reconnect with exponential backoff and re-subscribe their topic set from a clean slate.

Upgrade-time rejections (HTTP status before 101)

The response body is JSON: {"error":"<reason>"}. A 503 on the load balancer health check is also possible while an instance is congested: it stops receiving new connections, but existing sockets are untouched.

In-band ErrorResponse (tag 0x17)

The server reports recoverable, message-scoped errors as a binary protobuf frame with type tag 0x17. The payload is an ErrorResponse: The connection stays open when an ErrorResponse is sent — the server has dropped the offending message only. The client may continue using the connection.
Gotcha: two action values embed the market. Because fetch_error:{provider}:{contract_id} and fetch_waiting:{provider}:{contract_id} are templated, match action by prefix rather than by equality if you route errors per subscription.
Every ErrorResponse the gateway emits: An empty binary frame is dropped silently, with no error.

Server-initiated close codes

  1. On any close, wait at least 500 ms before reconnecting; on repeated failures use exponential backoff up to 30 s with jitter.
  2. On close code 4001, refresh the JWT before reconnecting. On 4002 / 4003 (anonymous tier), reconnect as-is with backoff.
  3. After reconnecting, re-send all SubscribeRequests. The server has no memory of prior subscriptions.
  4. On reconnect, reset all per-contract expected_seq / snapshot_seq state and wait for the fresh OrderbookSnapshot that arrives in response to each subscribe — applying deltas across a reconnect with stale baselines will corrupt your local book.
  5. Treat unexpected 1006s during normal operation as a hint that you may be sending too many messages — review your subscribe churn and ping frequency.

Connection Limits

All values below are server defaults and may be tuned per environment. An API key can carry its own ws.maxConnections / ws.maxSubscriptions overrides, which replace the defaults for connections opened with that key. Exceeding a connection limit returns 429 Too Many Requests on upgrade. Exceeding a subscription limit returns an in-band ErrorResponse with action: "subscribe"; the connection stays open. Anonymous connections have their own, stricter quotas — see Anonymous public access.

Message Rate Limits

Inbound client frames are limited to 50 per second per connection (10/s for anonymous connections). Exceeding it returns an ErrorResponse with action: "rate_limit" and the frame is dropped.
Gotcha: the error path is itself throttled, and abuse ends the connection. At most 3 error frames per 10-second window are sent, while violations keep accumulating even during that suppression. Once accumulated violations reach 10× the per-second limit within the window, the server force-closes the connection without a close code.
Snapshot recovery (FetchRequest) has its own quota: 20/s per connection for authenticated clients, 1/s per connection for anonymous ones.