Skip to main content

WebSocket Protocol

Connect to the AGG WebSocket API to receive live aggregated orderbook updates, public trades, cross-venue arbitrage-return updates, JSON heartbeat events, and authenticated user events across connected venues.
wss://ws.agg.market/ws?appId=YOUR_APP_ID

Getting connected

1

Open a connection

Pass your appId as a query parameter.
const ws = new WebSocket(`wss://ws.agg.market/ws?appId=${appId}`);
If the user is already signed in, you can also include &token=eyJ... for immediate user-level authentication.
2

Wait for the connected message

The server acknowledges the connection with app and optional user context:
{
  "type": "connected",
  "appId": "app_demo123",
  "userId": "usr_xyz789"
}
userId is present only when a valid JWT is supplied on connect.
3

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 a VenueMarketOutcome.id (venue-scoped, not cross-venue).
A single connection supports up to 100 total subscriptions across orderbook and trade channels. Requests beyond that limit return an error message.

1. Orderbook stream

Subscribe to an outcome’s aggregated orderbook to receive a full snapshot followed by incremental deltas. Each outcome has its own orderbook — there is no complement derivation. Aggregated levels include per-venue attribution. Per-venue books are also included.

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, 1500, { "kalshi": 800, "polymarket": 700 }],
    [0.54, 900, { "kalshi": 400, "polymarket": 500 }]
  ],
  "asks": [
    [0.56, 1200, { "kalshi": 600, "polymarket": 600 }],
    [0.57, 800, { "polymarket": 800 }]
  ],
  "venueOrderbooks": {
    "kalshi": {
      "bids": [[0.55, 800], [0.54, 400]],
      "asks": [[0.56, 600]]
    },
    "polymarket": {
      "bids": [[0.55, 700], [0.54, 500]],
      "asks": [[0.56, 600], [0.57, 800]]
    }
  },
  "venues": {
    "kalshi": { "bestBid": 0.55, "bestAsk": 0.56 },
    "polymarket": { "bestBid": 0.55, "bestAsk": 0.56 }
  },
  "midpoint": 0.555,
  "spread": 0.01,
  "timestamp": 1710000000000
}
Aggregated levels use the wire format [price, totalSize, { venue: sizeAtPrice }]. Per-venue levels use [price, size]. Bids are sorted descending by price, asks ascending.

Incremental deltas

{
  "type": "orderbook_delta",
  "outcomeId": "vmo_1",
  "seq": 1710000002,
  "checksum": 3847261950,
  "bidChanges": [
    [0.55, 1600, { "kalshi": 900, "polymarket": 700 }]
  ],
  "askChanges": [],
  "venueDeltaBooks": {
    "kalshi": {
      "bidChanges": [[0.55, 900]],
      "askChanges": []
    }
  },
  "venues": {
    "kalshi": { "bestBid": 0.55, "bestAsk": 0.56 },
    "polymarket": { "bestBid": 0.55, "bestAsk": 0.56 }
  },
  "midpoint": 0.555,
  "spread": 0.01,
  "timestamp": 1710000001000
}

Sequencing and resync

Every orderbook message includes a monotonic seq value for that outcome. Your client is responsible for maintaining local book state and detecting integrity issues. The gateway is a stateless fan-out — it forwards deltas from the aggregation engine without server-side seq tracking. 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. Drop stale deltas where delta.seq <= book.seq — these can arrive after a subscribe when Kafka 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",
  "outcomeIds": ["vmo_1"]
}
The @agg-build/sdk handles all of this automatically — seq tracking, stale delta filtering, checksum validation, and resnapshot requests. Use AggWebSocket or the useLiveMarket hook and these details are managed for you.

Raw WebSocket example

This keeps the protocol logic visible without pulling in SDK helpers:
const ws = new WebSocket(`wss://ws.agg.market/ws?appId=${appId}`);

ws.onopen = () => {
  ws.send(
    JSON.stringify({
      action: "subscribe",
      channel: "orderbook",
      outcomeIds: ["vmo_1"],
    }),
  );
};

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);

  switch (msg.type) {
    case "subscribed":
      console.log("Subscribed:", msg.outcomeIds, msg.channel);
      break;
    case "orderbook_snapshot":
      replaceLocalBook(msg);
      break;
    case "orderbook_delta":
      applyDeltaToLocalBook(msg);
      break;
    case "heartbeat":
      lastHeartbeatAt = msg.ts;
      break;
    case "error":
      console.error(msg.message);
      break;
  }
};
For SDK, hooks, and UI implementations, see Real-Time Orderbook.

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": "kalshi",
  "side": "buy",
  "price": 0.55,
  "size": 100,
  "timestamp": 1710000000000
}

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

Include the JWT in the connection URL:
wss://ws.agg.market/ws?appId=YOUR_APP_ID&token=eyJhbG...
Mid-session auth supports re-authentication after token refresh or user switching without opening a second socket.

Order submitted

{
  "type": "order_submitted",
  "venue": "kalshi",
  "orderId": "ord_abc123",
  "side": "buy",
  "price": 0.55,
  "size": 100,
  "outcomeId": "vmo_1",
  "timestamp": 1710000000000
}

Balance update

{
  "type": "balance_update",
  "venue": "kalshi",
  "tradingBalanceCents": 94500,
  "walletBalanceCents": 100000,
  "timestamp": 1710000000000
}

Order event

Venue-agnostic stream of DAG lifecycle and terminal order status events. Emitted for every venue (Polymarket, Kalshi, dFlow, …) 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 DAG, 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 DAGs, 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 (DAG-level).

DAG 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.
For a notification-focused recipe, see User Notifications.

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 VenueMarket.id) to receive an update whenever that market’s arbitrage return moves.
{
  "action": "subscribe",
  "channel": "arb",
  "marketIds": ["vm_1", "vm_2"]
}
Confirmation (the subscribed market ids are echoed in outcomeIds):
{
  "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",
  "venueEventId": "ve_1",
  "arbReturn": 0.012,
  "ts": 1710000000000
}
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",
  "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.

Connection management

Heartbeat

The service emits a JSON heartbeat event that clients can observe directly:
{
  "type": "heartbeat",
  "ts": 1710000000000
}
Use the JSON heartbeat event if you want an app-level liveness timer in a raw client.

Reconnection

If the connection closes, reconnect with exponential backoff and re-subscribe to your channels:
function connect(attempt = 0) {
  const ws = new WebSocket(`wss://ws.agg.market/ws?appId=${appId}`);

  ws.onclose = () => {
    const delay = Math.min(1000 * 2 ** attempt + Math.random() * 1000, 30000);
    setTimeout(() => connect(attempt + 1), delay);
  };

  ws.onopen = () => {
    attempt = 0;
    resubscribeAll(ws);
  };
}

Error handling

Protocol or validation errors arrive as JSON messages:
{
  "type": "error",
  "message": "Invalid outcomeIds: must be a non-empty array"
}
Fatal connection failures use close codes:
CodeMeaning
4001Missing appId query parameter
4003Invalid app or token scope not accepted
1001Connection closed; reconnect with backoff

Setup Guide

Provider wiring, auth setup, and SDK client initialization.

Real-Time Orderbook

SDK, hooks, and UI orderbook implementations.

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.