WebSocket Protocol
Stream live orderbooks, trades, arbitrage returns, catalog changes, heartbeats, and user events over WebSocket
WebSocket Protocol
Connect to the sufwan-local-test WebSocket API to receive live aggregated orderbook updates, public trades, cross-venue arbitrage-return updates, catalog changes, JSON heartbeat events, and authenticated user events across connected venues. Browsers connect with your app ID; backends connect with an API key and can also stream every user's events.
wss://ws.t.sufwankhalid.com/ws?appId=YOUR_APP_IDOrderbooks can also stream from a dedicated orderbook socket at
wss://ob.agg.market/ws, which adds an opt-in realtime tier and the
orderbook firehose.
There is no midpoint channel. A midpoint field on an orderbook frame is not a subscription.
The JSON frames on this page are the protocol.
Getting connected
Open a connection
Pass your appId as a query parameter.
const ws = new WebSocket(`wss://ws.t.sufwankhalid.com/ws?appId=${appId}`);How the gateway admits the connection depends on where it runs:
- Browser: the handshake's
Originheader must match one of your app's allowed origins. Browsers send it automatically. - Server: send your API key in the
x-app-api-keyhandshake header. NoOriginis needed. See Server connections.
WebSocket API-key enforcement is currently deferred for all apps, including apps with
Require API key turned on. Without a key, an allowed Origin is still required.
Any supplied key must be valid; an invalid key closes with 4003 even if the Origin is
allowed. This does not change REST API-key requirements. The app-events channel still
requires an API key issued for your app.
To sign a user in, send their token in the authenticate message after
connected. A token query parameter still works but is deprecated, because URLs end up in
proxy and load-balancer logs.
Wait for the connected message
The server acknowledges the connection with app and optional user context:
{
"type": "connected",
"appId": "app_demo123",
"protocolVersion": 2,
"auth": "browser"
}auth is browser (admitted by Origin) or key (admitted by x-app-api-key).
protocolVersion 2 adds error codes, request ids, event ids and resume. userId appears
only when a token was passed in the URL. Send subscriptions after this message arrives.
Subscribe to outcome streams
Send one or more outcome IDs with a channel to start receiving orderbook or trade updates. Each outcome has its own orderbook — there is no complement derivation.
{
"action": "subscribe",
"channel": "orderbook",
"outcomeIds": ["vmo_1", "vmo_2"]
}outcomeId is an outcome's id (outcomes[].id on REST market responses). Each outcome
belongs to one venue.
Keep orderbook and trade subscriptions together at or under 500 per connection, and arb
market subscriptions at or under 500. Ids beyond the limit are rejected with a
subscription_limit error. The other per-connection limits are in Limits.
Server connections
Backends authenticate the handshake with an API key from the admin dashboard
(Develop → API). The key replaces the Origin check, and the connection is metered per key
rather than per IP, so many sockets from one server are fine.
import { createAggClient } from "@agg-build/sdk";
const client = createAggClient({
baseUrl: "https://api.t.sufwankhalid.com",
wsUrl: "wss://ws.t.sufwankhalid.com/ws",
appId: process.env.AGG_APP_ID,
apiKey: process.env.AGG_API_KEY, // sent as x-app-api-key, never from a browser
});
const ws = client.createWebSocket({ onError: (e) => console.error(e.code, e.message) });
ws.subscribe("vmo_1");On Node versions without a global WebSocket, pass the ws package to the SDK:
client.createWebSocket(callbacks, { webSocketImpl: WebSocket }).
An API key is a secret. Never put it in browser or mobile code. If the key is revoked or
expires, open connections that used it are closed with 4003 within a minute.
Orderbook socket
The orderbook channel is also served on a socket of its own:
wss://ob.agg.market/ws?appId=YOUR_APP_IDConnect to it the same way as above. Browsers are admitted by an allowed Origin, servers by
x-app-api-key on the handshake, and you wait for connected before subscribing. An API key is
not required for the per-outcome orderbook channel.
orderbook_snapshot and orderbook_delta are the same objects on both sockets. Everything in
Orderbook stream applies. The orderbook socket adds two things:
wss://ws.t.sufwankhalid.com/ws | wss://ob.agg.market/ws | |
|---|---|---|
orderbook channel | 100 ms conflated tier | 100 ms conflated tier by default. Send "latency": "realtime" on subscribe to opt in. |
firehose channel | Not available | Every non-empty book for selected venues. Server-only, requires an app API key, and stays off until Snag enables it. |
| Trades, arbitrage, catalog, user events | Available | Not available. Other channels and authenticate are refused with invalid_message. |
{
"action": "subscribe",
"channel": "orderbook",
"outcomeIds": ["vmo_1"],
"latency": "realtime"
}seq is counted separately on each tier. Do not compare a conflated seq with a realtime seq
for the same outcome. Sending latency on wss://ws.t.sufwankhalid.com/ws does not turn on realtime.
In the SDK, set orderbookUrl on createAggClient to move only the orderbook channel to this
socket, and orderbookLatency to pick the tier. See Orderbook streams
for both, and Orderbook firehose for the whole-venue feed.
1. Orderbook stream
Subscribe to an outcome's orderbook to receive a full snapshot followed by incremental deltas. Each outcome has its own orderbook — there is no complement derivation. Levels carry venue attribution for the outcome's venue, and the venue's own book is included alongside.
The snapshot and delta objects below do not change on the orderbook socket. That page is where orderbookUrl moves this channel onto a second socket, and where the optional latency field selects realtime. The default remains the 100 ms conflated tier. A whole-venue feed is the Orderbook firehose.
Subscribe
{
"action": "subscribe",
"channel": "orderbook",
"outcomeIds": ["vmo_1"]
}You receive a confirmation before the initial snapshot:
{
"type": "subscribed",
"outcomeIds": ["vmo_1"],
"channel": "orderbook"
}Initial snapshot
{
"type": "orderbook_snapshot",
"outcomeId": "vmo_1",
"seq": 1710000001,
"checksum": 2918476531,
"bids": [
[0.55, 800, { "predict": 800 }],
[0.54, 400, { "predict": 400 }]
],
"asks": [
[0.56, 600, { "predict": 600 }],
[0.57, 300, { "predict": 300 }]
],
"venueOrderbooks": {
"predict": {
"bids": [[0.55, 800], [0.54, 400]],
"asks": [[0.56, 600], [0.57, 300]]
}
},
"venues": {
"predict": { "bestBid": 0.55, "bestAsk": 0.56 }
},
"midpoint": 0.555,
"spread": 0.01,
"timestamp": 1710000000000
}Orderbook timestamp is Unix milliseconds on the wire, and the SDK reports it as seconds.
Aggregated levels use the wire format [price, totalSize, { venue: sizeAtPrice }]. Per-venue
levels use [price, size]. Bids are sorted descending by price, asks ascending. A change with
size 0 removes that level. All timestamp values on the wire are Unix milliseconds.
If no orderbook is available for an id, you get an error whose message starts with
Snapshot unavailable for <outcomeId> instead of a snapshot. The subscription stays active, so
deltas may still arrive: ignore deltas for an outcome you have no book for. Check that the id is
an outcome id (outcomes[].id), not a market id, then retry with resnapshot after a delay
rather than in a loop.
Incremental deltas
{
"type": "orderbook_delta",
"outcomeId": "vmo_1",
"seq": 1710000002,
"checksum": 3847261950,
"bidChanges": [
[0.55, 900, { "predict": 900 }]
],
"askChanges": [],
"venueDeltaBooks": {
"predict": {
"bidChanges": [[0.55, 900]],
"askChanges": []
}
},
"venues": {
"predict": { "bestBid": 0.55, "bestAsk": 0.56 }
},
"midpoint": 0.555,
"spread": 0.01,
"timestamp": 1710000001000
}Market resolved
After you subscribe to orderbook for an outcome, a resolution or close arrives once as market_resolved. The outcome's orderbook and trade subscriptions end with it: nothing further arrives, and you don't need to unsubscribe. Trade-only subscribers are not sent this message. result is the label of the first outcome with winner: true, or null when none is marked winner. outcomes[] are the sibling outcomes for that market.
{
"type": "market_resolved",
"outcomeId": "vmo_1",
"status": "resolved",
"result": "Yes",
"outcomes": [
{
"label": "Yes",
"winner": true,
"externalIdentifier": "yes-token",
"payoutPerShare": 1,
"refundAtCost": false
},
{
"label": "No",
"winner": false,
"externalIdentifier": "no-token",
"payoutPerShare": 0,
"refundAtCost": false
}
],
"timestamp": 1710000000000
}status is resolved or closed. outcomes can be empty, and winner is omitted when it isn't known. payoutPerShare and refundAtCost are omitted when the venue did not report them. winnerSource is venue or matched and is omitted when winner is or when the winner predates this field; a matched winner is provisional (see Inferred winners). Settlement meaning is on Market resolution.
Sequencing and resync
Every orderbook message includes a seq value for that outcome. seq is monotonic within one
stream of the book. Your client is responsible for maintaining local book state and detecting
integrity issues.
Apply deltas when delta.seq === book.seq + 1. After applying, recompute the checksum
(XOR of per-level CRC32 values) and compare it to checksum in the message. If they match, the
book is consistent. The exact level encoding is in the
market-data guide.
Drop stale deltas where delta.seq <= book.seq — these can arrive after a subscribe when
deltas predate the initial snapshot. Silently discard them.
Request a resnapshot if you detect a forward gap (delta.seq > book.seq + 1) or a checksum
mismatch:
{
"action": "resnapshot",
"channel": "orderbook",
"outcomeIds": ["vmo_1"]
}Always replace your book with an orderbook_snapshot, even when its seq is lower than
yours. If sufwan-local-test rebuilds an outcome's book, that book starts a new seq stream. When the first
update from the rebuilt book arrives, the gateway sends you a fresh orderbook_snapshot without
being asked. Apply the following deltas against the snapshot's seq.
Unsubscribe
{
"action": "unsubscribe",
"channel": "orderbook",
"outcomeIds": ["vmo_1"]
}Staleness checks
Heartbeats prove the connection is alive, not that each outcome is updating. A quiet market can legitimately go several minutes without a delta, so silence alone is not an error, and silence alone can't tell you a stream has stopped reaching you.
We recommend a 5-minute REST watchdog for every client that keeps books open. The gateway can only resend a snapshot when it sees a new update, so a quiet book that went wrong stays wrong. The watchdog confirms each book against the server instead of trusting that the stream is still flowing.
- Once a minute, find subscribed outcomes whose book has not changed for 5 minutes.
- Fetch up to 100 of them with
GET /orderbooks/outcomes?venueMarketOutcomeIds=id1,id2,…&depth=1(see Market-data API). Results come back in request order, withnullfor an id that can't be served. - Compare each returned
seqwith your book'sseq:- Same: the market is quiet and your book is current. Reset its 5-minute timer.
- Different: you missed updates. Send
resnapshotfor that outcome. - Still different on the next check:
unsubscribeandsubscribethe outcome. That resets all per-subscription state on the gateway.
- Ignore an outcome whose book changed while the request was in flight. Its
seqmoved on for a normal reason.
Each outcome in a batch costs one market-data quota unit, so check only silent outcomes. On a
429, wait for Retry-After before the next check.
// `books` and `ws` come from your client; see the raw example below.
const SILENCE_MS = 5 * 60_000;
const lastChange = new Map(); // outcomeId -> ms; set on every snapshot and applied delta
const resnapshotted = new Set();
let inFlight = false;
let pausedUntil = 0;
setInterval(async () => {
const now = Date.now();
if (inFlight || now < pausedUntil) return;
const silent = [...books.keys()]
.filter((id) => now - (lastChange.get(id) ?? now) >= SILENCE_MS)
.slice(0, 100);
if (silent.length === 0) return;
const asked = new Map(silent.map((id) => [id, lastChange.get(id)]));
const url = new URL("https://api.t.sufwankhalid.com/orderbooks/outcomes");
url.searchParams.set("venueMarketOutcomeIds", silent.join(","));
url.searchParams.set("depth", "1");
inFlight = true;
try {
const res = await fetch(url, { headers: { "x-app-id": appId } });
if (res.status === 429) {
pausedUntil = Date.now() + Number(res.headers.get("retry-after") ?? 60) * 1000;
return;
}
if (!res.ok) return;
const { data } = await res.json();
silent.forEach((id, i) => {
const server = data[i];
const book = books.get(id);
// Skip ids the server can't serve, and books that changed while we asked.
if (!server || !book || lastChange.get(id) !== asked.get(id)) return;
if (server.seq === book.seq) {
lastChange.set(id, Date.now());
resnapshotted.delete(id);
} else if (!resnapshotted.has(id)) {
resnapshotted.add(id);
lastChange.set(id, Date.now()); // give the snapshot a full window
ws.send(JSON.stringify({ action: "resnapshot", channel: "orderbook", outcomeIds: [id] }));
} else {
resnapshotted.delete(id);
ws.send(JSON.stringify({ action: "unsubscribe", channel: "orderbook", outcomeIds: [id] }));
ws.send(JSON.stringify({ action: "subscribe", channel: "orderbook", outcomeIds: [id] }));
}
});
} finally {
inFlight = false;
}
}, 60_000);Best practices
Connection
- Wait for
connectedbefore you subscribe. Only the first 20 messages sent before it are kept; the rest are dropped. - Answer protocol pings. Browsers and most WebSocket libraries do this automatically; a connection that misses two pings in a row is closed.
- Treat 45 seconds without any frame, heartbeats included, as a dead connection and reconnect. Heartbeats arrive every 30 seconds.
- Reconnect with exponential backoff and jitter. After reconnecting, discard your books, subscribe again to everything, and rebuild from the new snapshots. Subscriptions don't survive a new connection.
- Don't keep reconnecting on
4001or4003. Those mean a missing app id, an inactive or unknown app, a disallowed origin, a missing or revoked API key, or a non-user token, and retrying won't help until the configuration is fixed.
Orderbooks
- Apply a delta only when its
seqis exactly one more than your book's, and verify itschecksum. - On a forward
seqgap or a checksum mismatch, sendresnapshot. Never keep patching a book you can't verify. - Replace your book on every
orderbook_snapshot, whatever itsseq. - Run the 5-minute REST watchdog.
- On
market_resolved, drop that outcome's book.
Throughput
- Subscribe only to outcomes you are displaying or trading, and unsubscribe when you stop.
- Keep your message handler fast and move heavy work off it. If your client falls more than
about 1 MB behind, the gateway skips frames for it. On orderbooks this shows up as a
seqgap, which a resnapshot repairs. If it stays behind for 10 seconds, the connection is closed with4008. Reconnect and resume to recover user events. Skipped trades, heartbeats, arbitrage updates andmarket_resolvedmessages are not resent.
Authentication
- Use the
authenticatemessage. ThetokenURL parameter is deprecated. - On
auth_expiring, refresh the token and sendauthenticateagain. After any reconnect, authenticate withresumeFromset to your lasteventId. - If
resumedarrives withcomplete: false, reload orders, balances and withdrawals over REST. - Backends: authenticate the handshake with
x-app-api-keyinstead of setting anOrigin. Send the key only from your backend, never from a browser.
The @agg-build/sdk handles the orderbook practices for you: seq tracking, stale delta
filtering, checksum validation, resnapshots, server-sent snapshots and the 5-minute watchdog. It
also reconnects with jittered backoff after 45 seconds of silence, sends your token by message,
resumes user events from the last eventId, and refreshes the token on auth_expiring. Use
client.createWebSocket() (the hooks use it) or the useLiveMarket hook. See
Real-Time Orderbook for the watchdog settings.
SDK, hooks, and UI orderbooks are on Real-Time Orderbook. That page does not repeat these frames.
2. Trade stream
Subscribe to the public trade feed for an outcome. No user auth is required.
Subscribe
{
"action": "subscribe",
"channel": "trades",
"outcomeIds": ["vmo_1"]
}Confirmation:
{
"type": "subscribed",
"outcomeIds": ["vmo_1"],
"channel": "trades"
}Trade event
{
"type": "trade",
"outcomeId": "vmo_1",
"venue": "hyperliquid",
"side": "buy",
"price": 0.55,
"size": 100,
"timestamp": 1710000000000,
"tradeId": "915328169553830"
}tradeId is the venue's own id for the fill, present when the venue supplies one (Hyperliquid
today). Use it to deduplicate. Two fills can share side, price, size and millisecond timestamp, so
those fields alone cannot identify a trade.
Unsubscribe
{
"action": "unsubscribe",
"channel": "trades",
"outcomeIds": ["vmo_1"]
}For chart-building patterns on top of orderbook and trade data, see Real-Time Charts.
3. Authenticated user events
Receive user-specific events, such as order confirmations and balance updates, after the connection has user-level auth.
Authenticate
Send the user's access token after connected:
{
"action": "authenticate",
"token": "eyJhbGciOiJIUzI1NiIs..."
}{
"type": "authenticated",
"userId": "usr_xyz789",
"expiresAt": 1710000900000
}expiresAt is when the token expires (Unix ms). Send authenticate again with a refreshed token
at any time. That re-authenticates without opening a second socket, and it also switches users.
Suspended users are refused. A user can hold at most 20 authenticated sockets at once.
Session lifetime
A user session on the socket lasts only as long as its token:
- About a minute before expiry you receive
auth_expiring. Refresh the token and sendauthenticateagain. - At expiry, or within a minute of the user signing out elsewhere or being suspended, you receive
auth_expired. The connection stays open at app level: market data keeps flowing, but user events stop until you authenticate again.
{ "type": "auth_expiring", "userId": "usr_xyz789", "expiresAt": 1710000900000 }{ "type": "auth_expired", "userId": "usr_xyz789", "reason": "expired" }reason is expired, revoked (signed out or token version bumped) or suspended. Clients
created with AggClient.createWebSocket() refresh and re-authenticate for you.
Event ids and resume
Every user event carries an eventId, along with stream: "user" and the userId. Ids increase
within the stream. Keep the last one you processed. When you reconnect, pass it as resumeFrom:
{
"action": "authenticate",
"token": "eyJhbGciOiJIUzI1NiIs...",
"resumeFrom": "1710000000123-0"
}The gateway replays the events after that id, marked "replayed": true, before live events
continue. Then it sends:
{
"type": "resumed",
"stream": "user",
"fromEventId": "1710000000123-0",
"replayed": 3,
"complete": true
}- The replay starts slightly before your cursor and may resend events you already have.
De-duplicate by
eventId. complete: falsemeans some events may be missing. This happens when the cursor is older than 24 hours, more than the last 500 events have passed, or the event log was unavailable. In that case, reload orders, balances and withdrawals over REST.- An event with no
eventIdwas delivered while the event log was unavailable, and a resume can't replay it.
The SDK tracks the cursor, resumes and de-duplicates automatically. Resume covers reconnects. Webhooks remain the durable record for your backend.
Order submitted
{
"type": "order_submitted",
"userId": "usr_xyz789",
"venue": "predict",
"orderId": "ord_abc123",
"side": "buy",
"price": 0.55,
"size": 100,
"outcomeId": "vmo_1",
"timestamp": 1710000000000,
"eventId": "1710000000123-0",
"stream": "user"
}Balance update
{
"type": "balance_update",
"venue": "predict",
"tradingBalanceCents": 94500,
"walletBalanceCents": 100000,
"timestamp": 1710000000000
}Order event
Venue-agnostic stream of execution-plan lifecycle and terminal order status events. Emitted for every active venue (Polymarket, Kalshi, Predict, …) automatically — no per-venue handling on the client. Discriminate on the event field.
Step lifecycle (progress UI)
{
"type": "order_event",
"event": "step_started",
"userId": "usr_xyz789",
"orderId": "ord_abc123",
"dagRunId": "dag_mnp...",
"stepId": "execute-...",
"stepType": "submit-order",
"sequence": 3,
"totalSteps": 7,
"venue": "polymarket",
"timestamp": 1710000000000
}event values: step_started | step_completed | step_waiting | step_failed | step_retrying.
sequence is the 1-based declaration index of the step within the execution plan, and totalSteps is the total step count. Pair them to render progress like "Step 3 of 7". dag_started also carries totalSteps so the UI can show an empty progress bar before the first step runs. For branching execution plans, sequence reflects declaration order, not strict execution order — parallel branches may emit non-monotonic sequence values.
Terminal fill (success toast + position refresh)
{
"type": "order_event",
"event": "filled",
"userId": "usr_xyz789",
"orderId": "ord_abc123",
"venue": "polymarket",
"filledAmountRaw": "1000000",
"remainingAmountRaw": "0",
"timestamp": 1710000000000
}event values: filled | partial_fill. remainingAmountRaw is present only on partial_fill.
Failure (error toast)
{
"type": "order_event",
"event": "failed",
"userId": "usr_xyz789",
"orderId": "ord_abc123",
"venue": "polymarket",
"errorReason": "Polymarket CLOB error (400): ...",
"timestamp": 1710000000000
}event values: failed (order-level) | dag_failed (execution-plan-level).
Execution plan lifecycle (optional)
{
"type": "order_event",
"event": "dag_started",
"userId": "usr_xyz789",
"orderId": "ord_abc123",
"dagRunId": "dag_mnp...",
"templateName": "route-execution",
"totalSteps": 7,
"timestamp": 1710000000000
}event values: dag_started | dag_completed | dag_failed | dag_cancelled.
All order_event messages are broadcast verbatim — the gateway does not filter by event type. Switch on event and ignore types your UI doesn't care about.
redeem_event reports EVM redemption completion. See Market resolution
for how to track claims and update positions.
For a notification-focused recipe, see User Notifications. That page does not repeat these frames.
4. Arbitrage streams
Stream the live cross-venue arbitrage return (arbReturn) for matched markets — the estimated
return, as a decimal fraction (e.g. 0.012 = 1.2%), from the best prices across the venues in a
matched cluster. This is the same value returned by the
List Venue Events API; the stream keeps it live.
There are two ways to consume it: a per-market channel for a focused view (one or a few markets), and a single coalesced feed channel for a whole listing.
Arbitrage streams are delta-only — there is no snapshot or sequence number. Load the initial
arbReturn from a REST listing, then apply stream updates on top. Updates are last-value-wins
and loss-tolerant: a dropped message self-heals on the next update. Subscribing requires only an
app-level connection (your appId) — arbReturn is not user-scoped. For REST request and
response details, use the API Reference.
Per-market channel
Subscribe with one or more marketIds (a market's id) to receive an update whenever that
market's arbitrage return moves.
{
"action": "subscribe",
"channel": "arb",
"marketIds": ["vm_1", "vm_2"]
}Confirmation. The ack field outcomeIds contains the marketIds you subscribed:
{
"type": "subscribed",
"outcomeIds": ["vm_1", "vm_2"],
"channel": "arb"
}Update message — published per market as the value changes:
{
"type": "arb_market_update",
"marketId": "vm_1",
"activeVenuesOnly": true,
"venueEventId": "ve_1",
"arbReturn": 0.012,
"ts": 1710000000000
}activeVenuesOnly is always true: the value only reflects active venues.
venueEventId is the parent event of the market (or null), so you can roll updates up to an
event-level value (e.g. the max across an event's markets).
Unsubscribe:
{
"action": "unsubscribe",
"channel": "arb",
"marketIds": ["vm_1"]
}Feed channel
For a listing of many markets, subscribe once to the coalesced feed instead of managing a subscription per visible market. No ids are sent.
{
"action": "subscribe",
"channel": "arb-feed"
}Confirmation:
{
"type": "subscribed",
"channel": "arb-feed"
}Batch message — emitted on a short coalescing interval, carrying only the markets whose
arbReturn changed since the last flush (deduped to the latest value per market):
{
"type": "arb_feed_batch",
"feed": "arb",
"activeVenuesOnly": true,
"entries": [
{ "marketId": "vm_1", "venueEventId": "ve_1", "arbReturn": 0.012, "ts": 1710000000000 },
{ "marketId": "vm_2", "venueEventId": "ve_1", "arbReturn": 0.004, "ts": 1710000000000 }
],
"flushTs": 1710000000300,
"chunk": 0,
"chunkCount": 1
}A single flush is split into multiple messages when it exceeds the per-batch entry cap — chunk
is the 0-based index and chunkCount the total for that flush. Merge entries into your local map
by marketId regardless of chunk; the feed is a stream of deltas, not a full snapshot each flush.
Unsubscribe:
{
"action": "unsubscribe",
"channel": "arb-feed"
}The @agg-build/sdk exposes subscribeArb(marketId, cb) and subscribeArbFeed(cb), and the
@agg-build/hooks package adds useMarketArb and useArbFeed (which maintains a live
byMarket map plus an event-level byEvent max rollup). See
Real-Time Arbitrage.
5. App events (server-to-server)
A backend connected with an API key can receive every user event of its app on one socket.
These are the same frames as in section 3: order submitted,
balance update, order, redeem and withdrawal events. Each one carries its userId.
{ "action": "subscribe", "channel": "app-events" }{ "type": "subscribed", "channel": "app-events" }{
"type": "order_event",
"event": "filled",
"userId": "usr_xyz789",
"orderId": "ord_abc123",
"venue": "polymarket",
"filledAmountRaw": "1000000",
"timestamp": 1710000000000,
"eventId": "1710000000456-0",
"stream": "app"
}- The channel needs an
x-app-api-keyissued for this app on the handshake. Browser connections, and keys from another app, getapp_events_forbidden. eventIdhere is the app stream's id, not the same as the user stream's id for the same event. Resume with{ "action": "subscribe", "channel": "app-events", "resumeFrom": "<eventId>" }. The app stream keeps the last 10,000 events or 24 hours, whichever ends first. Theresumedframe works as in Event ids and resume.- Unsubscribe with
{ "action": "unsubscribe", "channel": "app-events" }.
const ws = client.createWebSocket(); // client created with apiKey, server-side
const stop = ws.subscribeAppEvents((event) => {
// Raw frames: timestamps are milliseconds. Resumes across reconnects automatically.
if (event.type === "order_event" && event.event === "filled") onFill(event.userId, event);
});App events are a fast path, not a ledger. Use webhooks for
delivery you must not miss, and reconcile over REST when a resumed frame says
complete: false.
6. Catalog feed
Stream new, changed and deleted events and markets, so you can keep your own copy of the catalog
without polling. The channel is live only: frames sent while you were disconnected are not
replayed. Pair it with the REST catch-up
GET /catalog/changes, which serves the same items
for the last 24 hours. The whole pattern, and the SDK's watchCatalog, is in
Catalog Sync.
The channel works on browser and API-key connections. No user token is needed.
Subscribe, optionally narrowed by venue and entity kind:
{
"action": "subscribe",
"channel": "catalog",
"venues": ["polymarket", "kalshi"],
"entities": ["market"],
"id": "cat-1"
}| Field | Notes |
|---|---|
venues | Up to 64 venue names. Omit for every venue your app allows. Deletes are sent regardless. |
entities | event, market, or both (the default). |
Confirmation:
{ "type": "subscribed", "channel": "catalog", "id": "cat-1" }Subscribing again on the same connection replaces the filter. An invalid filter gets an
invalid_message error and leaves the previous subscription in place.
Each change is one frame carrying the entity's current state:
{
"type": "catalog_change",
"entity": "market",
"id": "vm_8f2c",
"op": "updated",
"version": 4,
"changedAt": "2026-10-21T12:00:41.512Z",
"venue": "kalshi",
"market": {
"id": "vm_8f2c",
"venueEventId": "ve_51aa",
"venue": "kalshi",
"externalIdentifier": "KXFEDDECISION-26DEC-H0",
"question": "Will the Fed hold rates in December 2026?",
"shortTitle": "Hold",
"status": "open",
"startDate": "2026-10-20T14:00:00.000Z",
"endDate": "2026-12-16T19:00:00.000Z",
"createdAt": "2026-10-20T14:02:11.000Z",
"outcomes": [
{ "id": "vmo_31d0", "label": "Yes", "title": null },
{ "id": "vmo_31d1", "label": "No", "title": null }
],
"categories": [{ "id": "cat_econ", "name": "Economics" }],
"matched": true,
"clusterAnchorId": "vm_77a1",
"clusterVenues": ["polymarket", "kalshi"]
}
}An event change carries an event snapshot instead (id, venue, externalIdentifier,
title, slug, status, startDate, endDate, createdAt, categories and the same three
matching fields). A delete carries only the id:
{
"type": "catalog_change",
"entity": "event",
"id": "ve_51aa",
"op": "deleted",
"version": 9,
"changedAt": "2026-10-21T12:03:02.004Z",
"venue": null
}- Apply a frame only when its
(version, changedAt)is newer than what you hold for that(entity, id). Frames can repeat, and two quick edits can arrive as one frame. opiscreated,updatedordeleted. On this channelcreatedis best effort and the same entity can be announced ascreatedtwice. Treatcreatedandupdatedas an upsert.clusterAnchorIdcan change as clusters gain and lose members. Key storage onid. See Matching fields.outcomes[].idis the outcome id the orderbook stream takes.- Venues and categories disabled for your app are left out, except deletes.
On a stream_interrupted that lists catalog, or after any reconnect, frames may be missing:
catch up over GET /catalog/changes from your last applied changedAt minus about a minute.
Unsubscribe:
{ "action": "unsubscribe", "channel": "catalog" }Connection management
Heartbeat
The service emits a JSON heartbeat event that clients can observe directly:
{
"type": "heartbeat",
"ts": 1710000000000
}The gateway sends this event every 30 seconds, along with a protocol-level ping. Browsers don't
expose pings to JavaScript, so use the JSON heartbeat (or any frame) as your liveness signal:
if nothing arrives for 45 seconds, reconnect.
A connection that doesn't answer two pings in a row is closed by the server.
Reconnection
If the connection closes, reconnect with exponential backoff and jitter. Once the new
connection sends connected, discard your books, subscribe again to everything, and
authenticate with resumeFrom set to your last eventId. A catalog subscriber also re-runs its
catch-up once the new subscription is confirmed:
function connect(attempt = 0) {
const ws = new WebSocket(`wss://ws.t.sufwankhalid.com/ws?appId=${appId}`);
ws.onclose = (event) => {
if (event.code === 4001 || event.code === 4003) return; // configuration error: don't retry
// 4008 (slow consumer) and 4029 (rate limited) are retryable with backoff.
const delay = Math.min(1000 * 2 ** attempt + Math.random() * 1000, 30000);
setTimeout(() => connect(attempt + 1), delay);
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "connected") {
attempt = 0;
clearLocalBooks();
resubscribeAll(ws);
ws.send(JSON.stringify({ action: "authenticate", token, resumeFrom: lastEventId }));
}
// ...handle other messages
};
}Error handling
Protocol or validation errors arrive as JSON messages:
{
"type": "error",
"code": "subscription_limit",
"message": "Rejected outcomes: vmo_9. Subscription limit reached (max 500)",
"outcomeIds": ["vmo_9"],
"id": "req-42"
}Branch on code, not on message. outcomeIds (or marketIds for arb) lists the ids the error
applies to. id echoes the id you put on the request. Any client frame can carry an id
(string or number), and acks such as subscribed and authenticated echo it too.
code | Meaning |
|---|---|
missing_app_id, invalid_app | No appId, or an unknown or inactive app |
origin_not_allowed | Browser Origin not in the app's allowed origins |
api_key_required | Reserved; not currently emitted while WebSocket API-key enforcement is deferred |
invalid_api_key | The key is malformed, unknown, revoked or expired |
invalid_token, token_app_mismatch, admin_token_not_allowed | The user token was refused. The connection stays at its previous level |
too_many_connections | A per-IP, per-key or per-user connection limit was reached. On a firehose subscribe: the server's firehose capacity is full; the connection stays open, so reconnect with backoff |
rate_limited | A message or snapshot rate limit was reached. The frame or ids were dropped; retry shortly |
invalid_message, too_many_ids | Malformed frame, or more than 1,000 ids in one frame |
subscription_limit | Ids beyond the per-connection subscription limit |
outcomes_blocked | Outcomes or markets disabled for your app |
not_subscribed | resnapshot for an outcome this connection isn't subscribed to |
snapshot_unavailable | No orderbook for that id. Orderbooks take outcome ids, not market ids |
app_events_forbidden | app-events without an API key for this app |
internal_error | Server-side failure admitting the connection or processing a frame |
An error about a request, such as a rejected subscription, leaves the connection open. An invalid token also leaves it open: the connection continues at app level.
Connection-level failures send an error and then close with one of these codes:
| Code | Meaning | Reconnect? |
|---|---|---|
4001 | Missing appId query parameter | No. Fix the URL |
4003 | Inactive or unknown app, Origin not allowed, a supplied API key invalid or revoked, or a non-user (admin) token in the URL. Also sent to open connections when the app is deactivated, its origins change, or the key is revoked | No. Fix the configuration |
4008 | Slow consumer: the client stayed more than 1 MB behind for 10 seconds | Yes, with backoff. Process frames faster or subscribe to less |
4029 | Too many connection attempts or connections, or too many pending messages | Yes, with backoff |
1009 | A client frame larger than 64 KB | Fix the client |
1001 | Server restarting | Yes, with backoff |
1006 | Connection dropped, including after missed pings | Yes, with backoff |
1013 | Temporary connection-admission failure, such as an unavailable app lookup | Yes, with backoff |
Stream interruptions
If the gateway loses its upstream feed briefly, every affected connection receives:
{ "type": "stream_interrupted", "channels": ["orderbook", "trades"], "timestamp": 1710000000000 }Orderbooks are resnapshotted automatically, so expect fresh orderbook_snapshot frames to follow.
Trades and arb updates published during the gap are not resent. When channels includes
catalog, re-run the catalog catch-up from your last applied changedAt. User events are not affected,
because they don't use this feed.
Limits
Limits apply per connection unless noted otherwise.
| Limit | Value | When exceeded |
|---|---|---|
| Client frame size | 64 KB | Connection closed with 1009 |
| Ids per frame | 1,000 | too_many_ids; the frame is ignored |
| Orderbook + trade subscriptions | 500 | subscription_limit |
| Arb market subscriptions | 500 | subscription_limit |
| Client frames | 20 per second, bursts of 200 | rate_limited; extra frames are dropped |
Orderbook snapshots (subscribe + resnapshot) | 100 per second, bursts of 1,000 | rate_limited for the ids over the budget |
| Browser connections per IP | 250 | too_many_connections, close 4029 |
| Connections per API key | 200 | too_many_connections, close 4029 |
| New connections per IP | 300 per minute | Close 4029 |
| Authenticated sockets per user | 20 | too_many_connections; the socket stays at app level |
| Send backlog | 1 MB | Frames are skipped. After 10 seconds the connection is closed with 4008 |
resnapshot only works for outcomes the connection is subscribed to, and a resnapshot already in
flight for an outcome absorbs repeat requests for it.
Related
Setup Guide
Provider wiring, auth setup, and SDK client initialization.
Real-Time Orderbook
SDK, hooks, and UI orderbook implementations.
Orderbook streams
Optional second socket, the 100 ms default, and the realtime subscribe field.
Orderbook firehose
Every book on selected venues. Server-only, and off until Snag enables it.
Real-Time Charts
Build live candles from REST history plus WebSocket events.
Real-Time Arbitrage
Live cross-venue arbitrage returns via per-market and feed channels.
User Notifications
Authenticated order and balance events with refresh-aware reconnect logic.
Catalog Sync
Keep your copy of events and markets current with the catalog channel and REST catch-up.