Streaming (WebSocket)

Subscribe to real-time liquidity, arbitrage, quote, exposure, game-state and order events over a single persistent WebSocket. Lower latency than polling; the server only sends you what you've subscribed to.

Endpoint

text
wss://api.openmarkets.ai/flow/v1/stream?api_key=om_data_live_...

The key is passed as api_key because browsers don't reliably allow custom headers on WebSocket handshakes. In server-side code, either form works, but the query parameter keeps you compatible everywhere.

Query-parameter keys may appear in proxy access logs. Use distinct keys for WS traffic if this is a concern, and rotate on the same schedule as your REST keys.

Connection lifecycle

Once the WebSocket opens, the server sends a greeting:

Server → client
{
  "type": "connected",
  "organization_id": "org_...",
  "api_key_id": "key_...",
  "server_time": "2026-04-17T22:00:00Z"
}

You then send a subscribe message for each channel+filter combination you want. The connection stays open indefinitely; re-subscribing replaces the filter list.

Channels

Streaming requires the stream:realtime entitlement. It is included from the Pro plan up; Trial keys can use the REST API but cannot open a subscription. A subscribe without it is rejected with entitlement_required and no subscription is created — you will receive nothing on that channel rather than a silent empty stream.
  • liquidity — price/size changes per contest. Public market data.
  • arbitrage — live cross-venue arbitrage. Requires arbitrage:read + stream:realtime. See below.
  • contest_state — live score, clock and status.
  • quote / exposure — your own quoting activity and filled exposure (market-making).
  • orders — private order lifecycle. See below.

Client messages

Subscribe to one or more contests
{
  "action": "subscribe",
  "channel": "liquidity",
  "contest_ids": ["ct_abc123", "ct_def456"]
}
Unsubscribe
{
  "action": "unsubscribe",
  "channel": "liquidity",
  "contest_ids": ["ct_def456"]
}
Heartbeat
{ "action": "ping" }

A connection with no contest_ids subscribed receives nothing; subscribing an empty array is effectively a no-op. The maximum number of subscribed contests per connection is 200.

Server messages

After subscribe, the server sends a confirmation, then streams liquidity.update events whenever any partner's price or size changes for a subscribed contest.

json
{
  "type": "subscribed",
  "channel": "liquidity",
  "contest_ids": ["ct_abc123"]
}

{
  "type": "liquidity.update",
  "contest_id": "ct_abc123",
  "changed_entries": [
    {
      "liquidity_hash": "kalshi:ct_abc123:mk_ml:side_home:var_0:p_kc:tf_full",
      "partner_id": "kalshi",
      "price": 0.49,
      "available": 5400
    }
  ],
  "refresh_required": false,
  "timestamp": "2026-04-17T22:00:01.234Z"
}

{ "type": "pong", "server_time": "..." }

When the server can't fit all changes into a single event (e.g. a partner just reconnected and re-sent the full book), you'll receive refresh_required: true with an empty changed_entries. In that case, re-fetch the full liquidity via REST.

Errors

json
{ "type": "error", "code": "invalid_channel", "message": "..." }

Possible codes: invalid_message (non-JSON), invalid_channel, entitlement_required (your plan doesn't include this channel — the message names the missing entitlements), account_forbidden (orders channel, unowned account). Key auth errors happen at upgrade time as a 401 close, not as an in-band message.

Limits

  • Concurrent connections — 3 per API key (default; configurable).
  • Contests per connection — 200.
  • Heartbeat — the server pings every 25 seconds. If your client doesn't pong back within the next tick it will be terminated. Browser WebSocket APIs respond to protocol-level pings automatically; Node clients may need to handle the ping event.

Reconnection pattern

Browser with exponential backoff
function connect() {
  const ws = new WebSocket(
    `wss://api.openmarkets.ai/flow/v1/stream?api_key=${KEY}`
  );
  let retry = 0;

  ws.onopen = () => {
    retry = 0;
    ws.send(JSON.stringify({
      action: 'subscribe',
      channel: 'liquidity',
      contest_ids: activeContestIds,
    }));
  };

  ws.onmessage = (e) => {
    const msg = JSON.parse(e.data);
    handle(msg);
  };

  ws.onclose = () => {
    const delay = Math.min(30_000, 1000 * 2 ** retry++);
    setTimeout(connect, delay);
  };
}

Orders channel — private order push

The orders channel pushes your own order lifecycle in real time — the moment a resting limit fills, partially fills, cancels, or settles — so you don't poll. It is private: you only ever receive orders for accounts you're authorized on.

Like every channel it needs stream:realtime; the scopes below layer connect:trade on top for reaching accounts other than your own.

Subscribe (three scopes)
// your own account (default — retail / owner_self key or player JWT)
{ "action": "subscribe", "channel": "orders" }

// every user in your workspace (Connect org — requires connect:trade)
{ "action": "subscribe", "channel": "orders", "all": true }

// specific users (Connect org — requires connect:trade). Each id is an
// external_user_id or om_account_id and is ownership-checked against your org.
{ "action": "subscribe", "channel": "orders", "account_ids": ["user-123", "user-456"] }
  • A key/JWT scoped to one account gets only that account's orders — no account_ids/all needed.
  • all and account_ids require the connect:trade entitlement; an unrecognized or other-org account returns account_forbidden.
Server events
{ "type": "order.update", "router_order_id": "ro_...", "partner_id": "kalshi",
  "status": "resting", "fill_status": "partially_filled",
  "filled_amt": 1.2, "unfilled_amt": 0.6, "avg_price": 0.60, "timestamp": "..." }

{ "type": "order.settled", "router_order_id": "ro_...", "partner_id": "kalshi",
  "result": "win", "winnings": 3.0, "net_winnings": 2.7, "timestamp": "..." }
Changes only. The channel does not replay an order's initial state — the /orders/buy 200 is that. It streams every change after. On reconnect, re-fetch GET /orders/resting/me for a fresh snapshot, then resubscribe.

Arbitrage channel — live cross-venue edges

The arbitrage channel pushes cross-venue arbitrage opportunities the moment they appear, disappear, or reprice. Requires both arbitrage:read and stream:realtime; subscribing without them returns an entitlement_required error and no subscription is created.

Arbs close in seconds, so this is the intended surface — polling GET /flow/v1/arbitrages is for backfill and one-off queries.

Subscribe with server-side thresholds
// everything, unfiltered
{ "action": "subscribe", "channel": "arbitrage" }

// scope by league or contest
{ "action": "subscribe", "channel": "arbitrage", "league_ids": ["lg_nba"] }
{ "action": "subscribe", "channel": "arbitrage", "contest_ids": ["ct_abc123"] }

// only edges that survive fees and are worth the round trip
{
  "action": "subscribe",
  "channel": "arbitrage",
  "league_ids": ["lg_nba"],
  "after_fee_only": true,
  "min_after_fee_roi_pct": 0.5,
  "min_investment": 250
}

Thresholds are applied server-side, per arb, against the selected leg pair — so you only pay bandwidth for edges you'd actually trade. Available: min_roi_pct (gross), min_after_fee_roi_pct, after_fee_only, min_investment. Setting min_after_fee_roi_pct implies after_fee_only.

Server events
{
  "type": "arbitrage.update",
  "contest_id": "ct_abc123",
  "league_id": "lg_nba",
  "arbitrages": [
    {
      "arbitrage_id": "3f2a91c4-8b17-4d02-9e55-7ac1b0d3e884",
      "contest": { "id": "ct_abc123", "label": "Lakers @ Rockets", "league_id": "lg_nba" },
      "market": { "id": "mk_ml", "variable_id": "var_0" },
      "combined_price": 0.968,
      "roi_pct": 3.31,
      "after_fee_roi_pct": 1.18,
      "profitable_after_fees": true,
      "max_investment": 412.5,
      "sides": [ /* both legs, every venue quoting them */ ],
      "detected_at": "2026-04-17T22:00:00.000Z"
    }
  ],
  "timestamp": "2026-04-17T22:00:00.100Z"
}
Snapshot per contest, not a delta. Each event carries the contest's current arb set — replace whatever you hold for that contest_id rather than merging. An empty arbitrages array means the contest's arbs have cleared; drop them. arbitrage_id is stable for the same venue pair on the same position across repricings, so you can diff “new” against “moved”.

Events are emitted only when a contest's arb set actually changes, so an idle contest costs you nothing. Because thresholds are per-subscriber, two clients watching the same contest can legitimately see different sets — and a client whose thresholds exclude everything in an update receives nothing at all, rather than an empty array it would misread as a clear.

Not yet supported

  • Orderbook depth streaming (subscribe by position_hash).

This is on the roadmap and will be added without breaking existing subscriptions.