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
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.
Connection lifecycle
Once the WebSocket opens, the server sends a greeting:
{
"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
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. Requiresarbitrage: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
{
"action": "subscribe",
"channel": "liquidity",
"contest_ids": ["ct_abc123", "ct_def456"]
}{
"action": "unsubscribe",
"channel": "liquidity",
"contest_ids": ["ct_def456"]
}{ "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.
{
"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
{ "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
pingevent.
Reconnection pattern
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.
// 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/allneeded. allandaccount_idsrequire theconnect:tradeentitlement; an unrecognized or other-org account returnsaccount_forbidden.
{ "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": "..." }/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.
// 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.
{
"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"
}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.