Connecting
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: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:
= 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: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:Subscribing to a Market
Send aSubscribeRequest to start receiving data for a contract:
Venues and products
One gateway carries every product. Theprovider 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 underhyperliquid; a perpetual is addressed by its canonical instrument id underhyperliquid_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:
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.[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:
topics: ["orderbook"] selects the legacy v1 orderbook
family and is not the perpetual beta contract.
Crypto oracle prices
Subscribe withprovider: "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.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.SubscribedResponse (0x14):
Unsubscribing
Send anUnsubscribeRequest 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 anotherSubscribeRequest for the same contract_id with the additional topics. Subscriptions are additive — the new topics are added on top of any existing ones.
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:- Unsubscribe from the contract entirely
- Re-subscribe with only the topics you want
Changing Candle Timeframes
To add new candle timeframes, send anotherSubscribeRequest 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 withcontract_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 aPingRequest (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 standardPing/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.
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’sprice_scale(or 10,000 when it is0)size_scaled: divide by the snapshot’ssize_scalewhen it is non-zero
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,000size_scaled: new quantity at this price level.0means the level was removed.
DeltaLevel entries:
- If
size_scaled == 0: remove the level atpricefrom your local book - Otherwise: insert or replace the level at
pricewithsize_scaled
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_namesprice: scaled by 10,000size_scaled: trade sizeside:TRADE_SIDE_BUY/TRADE_SIDE_SELL(TRADE_SIDE_UNSPECIFIED= 0)taker_side: venue-supplied taker side as a stringtimestamp_sec: Unix secondsoutcome_0_price,outcome_1_price: outcome prices at trade time (scaled by 10,000)block_number,log_index: on-chain ordering keytx_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 unsolicitedMarketResolvedNotice (0x1D) to every subscriber of that contract:
- 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
UnsubscribeRequestafterwards is harmless — the server tolerates it. - Re-subscribes are rejected. A
SubscribeRequestfor a recently-resolved market returns the standardErrorResponse(0x17) withaction: "subscribe"andmessage: "market resolved — subscription rejected", and no data is sent. The rejection persists for 7 days after resolution.
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.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,CandleOHLC, andPriceUpdate.mid_price/best_bid/best_askare scaled by 10,000.PriceUpdate.raw_price_centsforprovider: "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
Trackexpected_seq per contract. When a delta arrives:
- If
delta.seq < expected_seq: stale, drop it - If
delta.seq == expected_seq: apply it, setexpected_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.
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.
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 fullOrderbookSnapshot 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.ErrorResponse the gateway emits:
An empty binary frame is dropped silently, with no error.
Server-initiated close codes
Recommended reconnect / resync flow
- On any close, wait at least 500 ms before reconnecting; on repeated failures use exponential backoff up to 30 s with jitter.
- On close code
4001, refresh the JWT before reconnecting. On4002/4003(anonymous tier), reconnect as-is with backoff. - After reconnecting, re-send all
SubscribeRequests. The server has no memory of prior subscriptions. - On reconnect, reset all per-contract
expected_seq/snapshot_seqstate and wait for the freshOrderbookSnapshotthat arrives in response to each subscribe — applying deltas across a reconnect with stale baselines will corrupt your local book. - 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 ownws.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.
FetchRequest) has its own quota: 20/s per connection
for authenticated clients, 1/s per connection for anonymous ones.
