Real-time

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_ID

Orderbooks 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 Origin header must match one of your app's allowed origins. Browsers send it automatically.
  • Server: send your API key in the x-app-api-key handshake header. No Origin is 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.

SDK (Node 22+)
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_ID

Connect 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/wswss://ob.agg.market/ws
orderbook channel100 ms conflated tier100 ms conflated tier by default. Send "latency": "realtime" on subscribe to opt in.
firehose channelNot availableEvery non-empty book for selected venues. Server-only, requires an app API key, and stays off until Snag enables it.
Trades, arbitrage, catalog, user eventsAvailableNot 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.

  1. Once a minute, find subscribed outcomes whose book has not changed for 5 minutes.
  2. 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, with null for an id that can't be served.
  3. Compare each returned seq with your book's seq:
    • Same: the market is quiet and your book is current. Reset its 5-minute timer.
    • Different: you missed updates. Send resnapshot for that outcome.
    • Still different on the next check: unsubscribe and subscribe the outcome. That resets all per-subscription state on the gateway.
  4. Ignore an outcome whose book changed while the request was in flight. Its seq moved 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 connected before 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 4001 or 4003. 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 seq is exactly one more than your book's, and verify its checksum.
  • On a forward seq gap or a checksum mismatch, send resnapshot. Never keep patching a book you can't verify.
  • Replace your book on every orderbook_snapshot, whatever its seq.
  • 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 seq gap, which a resnapshot repairs. If it stays behind for 10 seconds, the connection is closed with 4008. Reconnect and resume to recover user events. Skipped trades, heartbeats, arbitrage updates and market_resolved messages are not resent.

Authentication

  • Use the authenticate message. The token URL parameter is deprecated.
  • On auth_expiring, refresh the token and send authenticate again. After any reconnect, authenticate with resumeFrom set to your last eventId.
  • If resumed arrives with complete: false, reload orders, balances and withdrawals over REST.
  • Backends: authenticate the handshake with x-app-api-key instead of setting an Origin. 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 send authenticate again.
  • 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: false means 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 eventId was 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-key issued for this app on the handshake. Browser connections, and keys from another app, get app_events_forbidden.
  • eventId here 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. The resumed frame works as in Event ids and resume.
  • Unsubscribe with { "action": "unsubscribe", "channel": "app-events" }.
SDK
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"
}
FieldNotes
venuesUp to 64 venue names. Omit for every venue your app allows. Deletes are sent regardless.
entitiesevent, 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.
  • op is created, updated or deleted. On this channel created is best effort and the same entity can be announced as created twice. Treat created and updated as an upsert.
  • clusterAnchorId can change as clusters gain and lose members. Key storage on id. See Matching fields.
  • outcomes[].id is 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.

codeMeaning
missing_app_id, invalid_appNo appId, or an unknown or inactive app
origin_not_allowedBrowser Origin not in the app's allowed origins
api_key_requiredReserved; not currently emitted while WebSocket API-key enforcement is deferred
invalid_api_keyThe key is malformed, unknown, revoked or expired
invalid_token, token_app_mismatch, admin_token_not_allowedThe user token was refused. The connection stays at its previous level
too_many_connectionsA 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_limitedA message or snapshot rate limit was reached. The frame or ids were dropped; retry shortly
invalid_message, too_many_idsMalformed frame, or more than 1,000 ids in one frame
subscription_limitIds beyond the per-connection subscription limit
outcomes_blockedOutcomes or markets disabled for your app
not_subscribedresnapshot for an outcome this connection isn't subscribed to
snapshot_unavailableNo orderbook for that id. Orderbooks take outcome ids, not market ids
app_events_forbiddenapp-events without an API key for this app
internal_errorServer-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:

CodeMeaningReconnect?
4001Missing appId query parameterNo. Fix the URL
4003Inactive 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 revokedNo. Fix the configuration
4008Slow consumer: the client stayed more than 1 MB behind for 10 secondsYes, with backoff. Process frames faster or subscribe to less
4029Too many connection attempts or connections, or too many pending messagesYes, with backoff
1009A client frame larger than 64 KBFix the client
1001Server restartingYes, with backoff
1006Connection dropped, including after missed pingsYes, with backoff
1013Temporary connection-admission failure, such as an unavailable app lookupYes, 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.

LimitValueWhen exceeded
Client frame size64 KBConnection closed with 1009
Ids per frame1,000too_many_ids; the frame is ignored
Orderbook + trade subscriptions500subscription_limit
Arb market subscriptions500subscription_limit
Client frames20 per second, bursts of 200rate_limited; extra frames are dropped
Orderbook snapshots (subscribe + resnapshot)100 per second, bursts of 1,000rate_limited for the ids over the budget
Browser connections per IP250too_many_connections, close 4029
Connections per API key200too_many_connections, close 4029
New connections per IP300 per minuteClose 4029
Authenticated sockets per user20too_many_connections; the socket stays at app level
Send backlog1 MBFrames 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.

On this page