Streaming
Subscribe to real-time liquidity, low-latency ticks, 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; a Free plan key 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.ticks— every venue quote the moment it reaches us, before aggregation. Lowest latency; directly executable. Requiresstream:ticks+stream:realtime. See below.arbitrage— deprecated: subscribe to thesignalchannel withtypes: ["arbitrage"]instead. Still works witharbitrage:read+stream:realtime. See below.signal— named, explained observations (arbitrage, price moves). Requiressignals:read+stream:realtime; filters and shapes on the Signals page.trades— venue prints on the contests you name (rolling out venue by venue). Requirestrades:read+stream:realtime; see Trades.contest_state— live score, clock and status.quote/exposure— your own quoting activity and filled exposure (organization market-making; not covered here).orders— private order lifecycle. See below.balance— private per-venue balance changes. See below.
Client messages
{
"action": "subscribe",
"channel": "liquidity",
"contest_ids": ["ct_abc123", "ct_def456"]
}
{
"action": "unsubscribe",
"channel": "liquidity",
"contest_ids": ["ct_def456"]
}
{ "action": "ping" }
ticks and trades must name contest_ids — a subscribe without them is rejected with contest_ids_required. On every other channel the filters are optional, and a subscription with none receives that channel for every contest (for liquidity that is a lot of traffic — filter unless you mean it). 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": "..." }
changed_entries always carries every change. A very large update (e.g. a partner just reconnected and re-sent the full book) may arrive split across several consecutive events for the same contest — apply each as it comes.
refresh_required: true means the server knows the contest changed but has no entries to send you (for example, straight after a fill). Re-fetch the full liquidity via REST. Apply any changed_entries on the same event first.
Low-latency ticks
liquidity events are published after we aggregate a contest across venues, which batches changes for up to a few seconds during busy markets. The ticks channel skips that step: each venue quote is pushed the moment its change reaches us.
stream:ticks and stream:realtime — stream:ticks is on the Scale plan only. A ticks subscription must name contest_ids — up to 200 per connection; a subscribe without them is rejected with contest_ids_required.{ "action": "subscribe", "channel": "ticks", "contest_ids": ["ct_abc123"] }
{
"type": "ticks",
"contest_id": "ct_abc123",
"seq": 42,
"ticks": [
{
"liquidity_hash": "pt_9f2c:ct_abc123:mk_spread:side_home:var_m20_5:p_lar:tf_full",
"position_hash": "ct_abc123:mk_spread:side_home:var_m20_5:p_lar:tf_full",
"partner_id": "pt_9f2c",
"partner_name": "Kalshi",
"price": 0.91,
"american_odds": "-1011",
"available": 3051.3,
"actionable": true,
"fee_packet": { "...": "same as REST" },
"buy_id": "KXNFLSPREAD-26SEP21NYGLAR-LAR21",
"source": "streaming",
"outlier": false,
"median": 0.9,
"as_of": "2026-09-22T03:01:46.120Z"
}
],
"timestamp": "2026-09-22T03:01:46.180Z"
}
Each tick has the same shape as one partner_liquidities entry from GET /flow/v1/contests/:id/liquidity (without edges, see below), plus position_hash, source (streaming from the venue's own feed, polling from our REST poll), as_of and the outlier fields. partner_id is the venue's OpenMarkets id (the one in GET /flow/v1/partners), which is also the first segment of liquidity_hash. You can keep a local copy current:
- Fetch the contest's liquidity once over REST.
- For each tick, find the position by
position_hashand replace the entry whoseliquidity_hashmatches (add it if the venue is new to that position). Recompute best price yourself. - A
position_hashyou don't hold is a newly quoted position — refetch REST for its title and market fields. seqincreases by one perticksevent on your connection. After a gap or a reconnect, refetch REST and carry on.
To place an order, pass the tick's liquidity_hash to POST /flow/v1/auth/orders/buy — the venue is encoded in it. available is the USD you can buy at price (the size at the best level, not the whole book).
What ticks don't carry. consensus_price, consensus_line, distribution_prices and edges come from our cross-venue pricing models; they keep arriving on the liquidity channel and over REST.
Outliers are flagged, not removed. outlier: true means the quote is more than 0.15 from the median of the venues quoting the same position (at least three with a real book; otherwise median is null). In a live game the venue that reprices first is briefly the outlier, so treat the flag as information. A quote that stays an outlier is more likely a bad listing.
Mislinked quotes are withheld. When a venue lists a participant on the opposite side from every other venue, its quotes for that market are not sent. The check runs with our aggregation, so a newly broken listing can appear for a few seconds before it is withheld.
When ticks are sent. as_of is when the venue update entered our pipeline: the moment a streamed frame arrived, or the start of the poll that fetched it. A quote that isn't in our cache yet (a newly listed line, for example) starts streaming once its first poll lands. From then on, a tick is always sent when the quote's price changes. When only the size moves, a tick is sent if the quote empties, refills from empty, or shrinks to $1,000 or less. Other size-only moves (growth, or a move that leaves more than $1,000 behind the price) reach you with the quote's next price change, or from 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), contest_ids_required (ticks channel without contests). 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.
- Keys — any key that can call the Flow API can open the stream, including a marketplace app's install key. The key is checked when the connection opens and again about once a minute while it stays open. If the key has been revoked or has expired, the server sends
{ "type": "error", "code": "key_invalid" }and closes the socket with close code4001. Install keys expire after 7 days and refresh, so on a 4001 (or whenever your app refreshes its key) reconnect with the new key. Do not retry a 4001 with the same key. - 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.
With the app SDK
Marketplace apps can skip the loop below: openStream() in @openmarketsai/app-sdk (0.5+) fetches a fresh install key on every connect, subscribes after connected and re-subscribes after every reconnect, pings every 25 seconds, backs off on drops, reconnects at once when the server closes a socket whose key has expired, and stops when a subscribe is refused with entitlement_required.
import { openStream } from '@openmarketsai/app-sdk'
const stream = openStream(auth, { // auth = createOpenMarketsAuth({ ... })
subscriptions: [{ action: 'subscribe', channel: 'signal', include_beta: true }],
onMessage(msg) { if (msg.type === 'signal.update') render(msg) },
onStatus(status, detail) { /* connecting | live | reconnecting | denied | signed_out | closed */ },
onReconnected() { backfillOverRest() }, // events during the gap are only in REST
})
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.Balance channel — private balance push
The balance channel pushes a venue balance the moment it moves: when an order commits funds, when a cancel returns them, when a settlement pays out, and when a scheduled refresh re-reads the venue. It saves you polling /account/balances to keep a header figure honest.
It is private and scoped exactly like orders: same stream:realtime requirement, same connect:trade requirement for reaching accounts other than your own, same account_forbidden on an account outside your workspace.
// your own account (default)
{ "action": "subscribe", "channel": "balance" }
// every user in your workspace (Connect org — requires connect:trade)
{ "action": "subscribe", "channel": "balance", "all": true }
// specific users (Connect org — requires connect:trade)
{ "action": "subscribe", "channel": "balance", "account_ids": ["user-123"] }
{ "type": "balance.update",
"account_partner_id": "ap_...",
"partner_id": "p_kalshi",
"currency": "USD",
"balance": 842.10, // EXPECTED — spendable now. Render this.
"venue_balance": 892.10, // the venue's last snapshot
"pending": -50.00, // negative = held against open orders
"balance_version": 42,
"balance_as_of": "2026-08-24T16:04:11Z",
"cause": "order_hold", // order_hold | release | settlement | refresh | wallet
"router_order_id": "ro_...",
"timestamp": "..." }
balance_version is not greater than the last one you saw for that account_partner_id. The version is monotonic per venue connection; delivery order is not guaranteed, so a late frame would otherwise overwrite a newer balance with a stale one.Note the field naming mirrors the REST shape's meaning, not its keys: balance on this event is the expected figure (what to show), and the venue snapshot is venue_balance. The event carries the number you render first.
GET /flow/v1/auth/account/balances for your opening figures, then apply events. Do the same after a reconnect — a gap in the stream means a gap in your totals.balance:read for a venue produces no events for it — the same venue is omitted from the REST response. Revoking mid-stream stops delivery within about 30 seconds, with no reconnect required.Arbitrage channel — live cross-venue edges (deprecated)
{ "action": "subscribe", "channel": "signal", "types": ["arbitrage"] } (needs signals:read). Every leg and venue is in the signal's evidence.sides, and the venues are in its envelope venues; see Signals. This channel keeps working unchanged for existing clients.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 a channel
- Consensus prices ride on
liquidityasconsensus_price; there is no separate channel or REST endpoint. - Orderbook depth streaming (subscribe by
position_hash) is planned, not available. PollGET /depth/:position_hashfor now.