A single WebSocket endpoint streams live market data and per-session events. It's what keeps the live trading screen and the strategy results progress current in real time.
Endpoint
wss://api.0dtespx.com/__ws
Handshake
After connecting, you have 5 seconds to send an auth message — either anonymous or token-authenticated:
{"auth": "public"}
{"token": "8a4f3e21d6c94b0e9f2a1c5b7d8e4f60"}
A freshly authenticated connection has no subscriptions; you must explicitly subscribe to each channel you want. channels (array) and channel (single) are both accepted on subscribe and unsubscribe:
{"action": "subscribe", "channels": ["live_aggregate_data", "live_option_chain"]}
{"action": "unsubscribe", "channel": "live_option_chain"}
A live_option_chain subscribe may add "encoding": "columnar" to receive the compact structure-of-arrays frame instead of the default array-of-structs one (both are described under Channels, and both are supported indefinitely):
{ "action": "subscribe", "channels": ["live_option_chain"], "encoding": "columnar" }
Wire format
Every server-sent message is a JSON object with a top-level channel field — dispatch on that, never on payload shape. Market-data channels nest their data under payload; event channels put id, type/data as siblings of channel:
{"channel": "live_aggregate_data", "payload": { … }}
{"channel": "live_option_chain", "payload": [{"strike": 5950, "call": {"bid": 4.2, "ask": 4.4, "delta": 0.523}, "put": {"bid": 3.1, "ask": 3.3, "delta": 0.477}}, … ]}
{"channel": "market_data", "payload": {"symbol": "SPX", "type": "trade", "datetime": "2026-07-17T14:30:00", "price": 5951.2}}
{"channel": "account_financials", "payload": {"account_id": "<uuid>", "session_id": "<uuid>", "timestamp": "2026-07-17T14:30:05Z", "net_liquidation_value": "101234.5", "…": "…"}}
{"channel": "session_events", "id": 42, "session_id": "<uuid>", "account_id": "<uuid>", "type": "order_update", "payload": { … }}
{"channel": "end_of_data"}
With "encoding": "columnar", the live_option_chain payload is a structure-of-arrays object instead — seven equal-length, index-aligned integer arrays. It carries exactly the same information, positionally encoded and scaled to integers:
{ "channel": "live_option_chain", "payload": { "k": [5950, 5975], "cb": [420, 210], "ca": [440, 230], "cd": [5230, 3110], "pb": [310, 505], "pa": [330, 525], "pd": [6890, 4770] } }
k— strike, whole dollars.cb/ca,pb/pa— call/put bid/ask in integer cents; divide by 100 for dollars.cd/pd— call/put delta in signed ten-thousandths; divide by 10000 (the sign is preserved — a put delta may be negative).
All seven arrays are the same length and share an index: element i of every array describes strike k[i].
Channels
| Channel | Cadence | Access |
|---|---|---|
live_aggregate_data |
1-second | Public + authenticated |
live_option_chain |
1-second | Token-authenticated only |
market_data |
1-second | Per symbol; SPX/VIX public, option symbols token-only |
account_financials |
1-second | Token-authenticated, per account (owner only) |
session_events |
Event-driven | Token-authenticated, per session |
live_bot_events |
Event-driven | Token-authenticated, per user — all of your bots |
backtest_events |
Event-driven | Token-authenticated, per strategy (source hash) |
assistant_draft_events |
Event-driven | Token-authenticated, per draft (owner only) |
subscription_rejected |
Event-driven | Server-emitted when a subscribe exceeds a per-connection limit (see Subscription limits) |
end_of_data |
Terminal | — |
live_aggregate_data— the 1-second aggregate market feed: SPX, VIX, the expected move, and the two chain-wide premium totals (summed OTM bids and total extrinsic value) — the same series the historical endpoint serves for past sessions.live_option_chain— the latest SPX option-chain snapshot, semantically equivalent to what the REST snapshot endpointGET /market-data/option-chain-snapshots/{timestamp}returns for a single timestamp:bid,ask, and signeddeltaper side, per strike. By default it arrives as an array of{strike, call, put}objects. Add"encoding": "columnar"to the subscribe to receive the compact structure-of-arrays frame shown under Wire format instead — the same values, positionally encoded, with prices scaled to integer cents (÷100) and deltas to signed ten-thousandths (÷10000), and every column the same length. Both encodings are supported indefinitely; the columnar frame is far smaller on the wire. (Second-order Greeks, IV, and theoretical price aren't available anywhere in the API — delta is the only Greek in the data model.)Frames start at the session's
data-start-time, not at the open. The chain is not published before that instant — the per-date value inGET /market-data/sessions, usually a minute past the open but not always. A connection that subscribes earlier stays subscribed and simply receives nothing on this channel until the bound passes, at which point the 1-second cadence begins; there is no error, no rejection, and nothing to re-subscribe. The REST snapshot endpoint applies the same bound, which is why the equivalence above holds in both directions.live_aggregate_datais unaffected and streams from the open, so a chart has data during the wait.session_events— discrete state changes for a live session:order_update(status transitions),financials_update(cash, buying power, realized P&L),ended, andsettlement_failed. Subscribe per session with{"action":"subscribe","channel":"session_events","session_id":"<uuid>"}; each envelope carries top-levelsession_idandaccount_idso clients route to per-session state. Per-tick continuous values (P&L, delta) aren't pushed here — compute them fromlive_option_chain. At most 64 sessions per connection.order_updateis the order's lifecycle, event by event. Itspayloadis the same order JSON the REST reads return —status,cancel_requested, the prices, the status-conditional stamps — plusaccepted_seq, which the REST reads omit. One event is written for every change to the order, in the same transaction as the change, so the sequence is the order's history with nothing lost in between:{ "channel": "session_events", "id": 42, "session_id": "<uuid>", "account_id": "<uuid>", "type": "order_update", "payload": { "id": "<order-uuid>", "status": "pending", "accepted_seq": null, "price": "10.50", "…": "…" } } { "channel": "session_events", "id": 43, "session_id": "<uuid>", "account_id": "<uuid>", "type": "order_update", "payload": { "id": "<order-uuid>", "status": "live", "accepted_seq": 7, "price": "10.50", "…": "…" } }- Admission —
status: "pending",accepted_seq: null: we accepted the order, nothing downstream has confirmed it. This event is written before thePOSTresponse is. - Acceptance —
status: "live",accepted_seqstamped: the order is resting on the book. (The ladder'sroutedrung is reserved for a future venue and produces no event today.) - Terminal —
filled/canceled/expired/rejected, with that state's stamps.
A
filledframe carriesfill_quotesamong those stamps — the market quotes at the time of the fill, per leg, plus the order's net bid and ask at that moment. No other frame carries it: the key is simply absent, nevernull.A cancel request adds a frame of its own: the same order, unchanged
status, now carryingcancel_requested: true. The flag then rides every later frame for that order, the terminal one included.Because the ladder only ever moves forward, a frame reporting an earlier state than one you have already applied is stale — drop it. See Event ordering and recovery.
- Admission —
live_bot_events— the durable lifecycle stream for your bots. It's the one event channel with no per-key scoping: subscribe to the channel itself and you receive events for every bot on your account, so a dashboard needs a single subscription. Delivery is scoped to the authenticated user — an anonymous connection that subscribes to it receives nothing.{ "action": "subscribe", "channel": "live_bot_events" }Envelopes use the outbox shape
{channel, id, data}, whereidis a monotonically increasing row id.datacarriestype,account_id(the bot id — a bot is a trading account, so the two are the same value), andsession_id, so clients route straight to per-session state:{ "channel": "live_bot_events", "id": 8241, "data": { "type": "tick_progress", "account_id": "<bot-uuid>", "session_id": "<uuid>", "tick_index": 812, "tick_at": "2026-07-29T14:30:05Z", "nlv": "101234.5", "open_positions": 2, "open_orders": 0 } }The queue is durable: envelopes are never dropped for a slow client, so a reconnect can replay the gap exactly. Send
"last_event_id": <the highest id you saw>on the subscribe — or"last_event_ids": {"live_bot_events": <id>}when one envelope subscribes to several event channels at once — and every row above that id is delivered before live events resume.Subscribing without a cursor replays the newest 500 events. An omitted
last_event_idis the same as0, and that is not "everything": the server delivers the newest 500 events for your account, then live traffic. Those 500 are a deliberate short overlap, sized so that a page which just loaded its state over REST cannot miss an event written between that read and the moment the subscription took effect. They are not a history feed — the socket carries live traffic plus that overlap, and much of what you would look back at is a REST read instead: a session's decision log comes fromGET /bots/{id}/sessions/{session_id}/decision-log, its orders, positions, transactions and financials from the sibling endpoints.REST does not cover every event type, though.
decision_loggedis mirrored into the decision log, and thesession_started/session_completedpair is reflected in the session's own record. The other seven —signal,halted,registering,waiting_for_data,session_resumed,missed_ticksanderror— have no REST equivalent: this channel is the only place they are published, anderroris the sharpest case, since it carries the strategy traceback nothing else exposes. If you care about those beyond the 500-event overlap, hold a cursor and pass it on every resubscribe, or ask for the backfill with"last_event_id": 1. All seven are retained indefinitely, so they are still there when you do.Because the overlap is deliberate, expect to receive events you already have, and make your handling idempotent: de-duplicate on the envelope's
id, and ondata.log_idfor adecision_loggedevent you may also have fetched over REST. Passlast_event_idwhenever you have one — the highest id you have already processed on a reconnect — and pass"last_event_id": 1when you genuinely want the full backfill. It means what it says — every row above id 1 — so for practical purposes that is your entire retained history, minus only a row whose id is exactly 1.Retention is per event type.
tick_progressheartbeats are kept for 7 days; every other type is kept indefinitely. So a cursor older than that horizon replays only what remains — the non-heartbeat events above it, with expired heartbeats simply absent, and no signal that they were (unlikesession_events, there is nostale_cursorhere). What an expired heartbeat takes with it is its point-in-timetick_index,open_positionsandopen_orders, which nothing else stores; what it does not take is anything you act on — the current values come from the session's REST reads, the P&L curve from…/history, and the next heartbeat restates the rest.data.typeis one of eleven:session_started— the session was created. Payloadsession_date,opening_balance,starting_capital. Whether you started it or the platform's automatic start did rides the paireddecision_loggedentry (started_by: "user" | "auto").registering— the session is being registered for live trading. Emitted only when it actually waits for the acknowledgement.waiting_for_data— the session is holding for fresh market data before its first tick. Emitted only when it actually blocks; a normal start inside regular hours skips it.tick_progress— a ~10-second heartbeat:tick_index,tick_at,nlv,open_positions,open_orders. It keeps flowing while a stopped session sits holding positions.decision_logged— one decision-log entry, mirrored live:datetime,level,event,payload,source("strategy","runner", or"api"for an entry recorded against a request you made), andlog_id. Your strategy'sctx.log()output, platform re-pricing activity (order_repriced— its payload'slegs_changedmarks a step that also re-selected the entry's legs — andreprice_failed), the pricing rows (order_start_pricedandorder_repricedboth carryquotes_at— each leg's option symbol mapped to the timestamp of the quote used for it — plusnet_bidandnet_ask, the net bid and ask their price was computed from), the order timeline (order_filled,order_canceled,order_rejected— which also reports a placement the exchange refused outright, carryingrejection_reason,order_created: false(the exchange never accepted it, so the order readsrejectedand never reached the book), and an optionalmessage), the entry-recovery pair (entry_retried— the platform rebuilt an entry the exchange had temporarily refused and re-submitted it inside the window, carrying a neworder_idfor that re-submission (which may itself be refused, so it becomes a working order only once one is accepted) plusattempt,max_attemptsandreason— andentry_retry_abandoned, at most one per session, carryingattemptsandreasonwhen the entry could not be recovered and the day ends without a trade),entry_window_closed(the strategy's entry window ended with an entry order still unfilled), and the lifecycle entries (session_started,strategy_stopped,strategy_restarted,session_ended) all arrive here.signal— actx.signal()call:nameplus itsfields.session_resumed— a mid-session bot was picked back up: after a platform restart, or after you restarted a stopped session. Payloadstatus,tick_index. At most one per resume.missed_ticks— the session entered a stale-market-data episode (or a load-shedding one):tick_indexplusage_ms, oroverload: true. One per episode, not one per skipped tick.halted— the strategy calledctx.halt():reason,source. A halt request, not a terminal state by itself.session_completed— terminal, written in the same transaction that finalizes the session:session_id,status,halt_reason,final_nlv,total_pl,realized_pl,fees_total(money fields are JSON strings).statusis the terminal session status —completed,halted,stopped, orfailed.error— a strategy callback raised:callback,tick_index,message(truncated to 500 characters with a trailing ellipsis).on_tick/on_order_fillederrors are throttled to one envelope per 15 seconds; the once-per-session callbacks always emit.
Two things worth knowing. Stopping a bot session emits no lifecycle event of its own:
DELETE /bots/{id}/sessions/{sid}stops the strategy, not the session, so what you observe is adecision_loggedentry withevent: "strategy_stopped"while the session staysrunningandtick_progresskeeps flowing over its held positions.session_completedcomes later —status: "stopped"once you flatten it with a liquidation, orstatus: "completed"if you hold to the 16:00 ET close. A restart in between is adecision_loggedentry withevent: "strategy_restarted"(source: "api") followed bysession_resumed, and the stream carries on as if the stop had never happened. But a stop with nothing left in the market ends the session instead of holding it — a session stillscheduledorregistering, or one whose strategy had already exited everything — and so does a flat book with no stop at all: expect a finaldecision_loggedframe (thesession_endedentry) and thensession_completedwithstatus: "stopped"as the session's last event, whenever that happens during the day. And there is nostoppedevent type: a terminal stop is thatsession_completedpayload.backtest_events— strategy results progress for one strategy's backtest. Subscribe per strategy with itssource_hash:{"action":"subscribe","channel":"backtest_events","source_hash":"<hex>"}(authorized against a saved strategy of yours with that source, or your live builder preview). Each frame carries the full fee-overlaid results snapshot underdata.snapshot(the same shapeGET /strategies/preview/{source_hash}/resultsreturns), folded for you — render straight from it, no per-tick refetch.data.typeis one ofstarted,day_completed,completed,stopped,failed. The HTTP results endpoint stays the source of truth for the initial load and reconnect. A dropped socket never stops a run; reconnect and re-subscribe. At most 256 strategy subscriptions per connection.assistant_draft_events— the AI assistant chat stream for one of your drafts. Subscribe with the draft id:{"action":"subscribe","channel":"assistant_draft_events","draft_id":"<uuid>"}(authorized against your ownership of the draft).data.typeis one ofdelta(assistant text chunk),thinking(a chunk of the model's streamed reasoning while it works — display-only, not part of the final message),tool_status,ask_user,message(the finalized assistant message),preview({source_hash, description, risks, params}— the live preview moved to a new hash and the strategy description, risk scenarios, and standardized{legs, entry, exit}summary were updated with it; re-key yourbacktest_eventssubscription to it.paramsis omitted on an older draft that hasn't been edited since the field was introduced),turn_complete,error. A reconnect may supplylast_event_by_draft(draft id → highest seen id) to replay the gap. At most 16 drafts per connection.market_data— per-symbol live market data at a 1-second cadence, so you subscribe to exactly the symbols you care about instead of the whole aggregate feed or the whole option chain. Subscribe with an explicitsymbolslist:{"action":"subscribe","channel":"market_data","symbols":["SPX","VIX","SPXW 260717C05900000"]}. Two symbol kinds:- Underlyings
"SPX"and"VIX"— public (anonymous connections may subscribe). Each emits atype:"trade"frame carrying the indexprice(a number):{"channel":"market_data","payload":{"symbol":"SPX","type":"trade","datetime":"2026-07-17T14:30:00","price":5951.2}}. - Option contracts — a 21-character OSI symbol for one of today's SPX 0DTE contracts (e.g.
"SPXW 260717C05900000"), token-authenticated only. Each emits atype:"quote"frame carryingbid,ask, and signeddelta:{"channel":"market_data","payload":{"symbol":"SPXW 260717C05900000","type":"quote","datetime":"2026-07-17T14:30:00","bid":4.2,"ask":4.4,"delta":0.523}}. The OSI's embedded expiration must be today's session; a wrong-day or unknown symbol simply produces no frames (nothing is rejected at subscribe time).datetimeis offset-less UTC. Contract quotes come off the same chain tick aslive_option_chainand share its availability bound: notype:"quote"frame is emitted before the session'sdata-start-time. TheSPX/VIXtype:"trade"frames above are unaffected and stream from the open.
Delivery is lossy latest-wins: each tick supersedes the last. At most 64 symbols per subscribe frame and 256 per connection. Unsubscribe with a
symbolslist to drop those symbols, or with nosymbolsto clear all of them.- Underlyings
account_financials— per-account live financials at a 1-second cadence, token-authenticated only. Subscribe with anaccount_idslist (both your manual trading accounts and your bots are supported), each id authorized against your ownership:{"action":"subscribe","channel":"account_financials","account_ids":["<uuid>","<bot-account-uuid>"]}. On subscribe you receive one initial snapshot frame for each newly subscribed account that already has session history — an account with none yet enrolls without one — then a live frame on every per-second change. The payload is that account's live financials — net liquidation value, the realized/unrealized P&L split, credits/debits/fees, buying power, and the equity-options and equities breakdowns:{ "channel": "account_financials", "payload": { "account_id": "<uuid>", "session_id": "<uuid>", "timestamp": "2026-07-17T14:30:05Z", "net_liquidation_value": "101234.5", "unrealized_profit_loss": "800", "...": "...all financials fields..." } }Money fields are JSON strings with trailing zeros stripped (
"101234.5","800") andtimestampis RFC 3339 with a trailingZ— the same field encodingGET /accounts/{id}/sessions/{sid}/historyreturns, so the same parser handles both. Delivery is lossy latest-wins and the channel is not authoritative on its own: after a disconnect or a gap, reseed the current state fromGET /accounts/{id}/sessions/{sid}/history. At most 64 account ids per subscribe frame and 64 per connection. Unsubscribe with anaccount_idslist to drop those accounts, or with noaccount_idsto clear all of them.
Subscription limits
Each per-key channel caps how many keys one connection may hold: session_events 64 sessions, assistant_draft_events 16 drafts, backtest_events 256 strategies, market_data 256 symbols, account_financials 64 accounts. In every case the connection's existing subscriptions keep streaming, but the two families handle an over-cap subscribe differently:
session_events,assistant_draft_events, andbacktest_eventsrefuse the over-cap subscribe and the server sends asubscription_rejectedenvelope naming the channel and its limit:{ "channel": "subscription_rejected", "payload": { "target": "session_events", "reason": "limit_exceeded", "limit": 64 } }targetis one ofsession_events,assistant_draft_events, orbacktest_events.market_dataandaccount_financialsinstead silently truncate: keys past the cap are dropped and the rest are subscribed, with nosubscription_rejectedenvelope.
(Authorization failures are also declined silently, not with an envelope.)
Event ordering and recovery
Event-channel envelopes carry a monotonically increasing id. live_bot_events is the durable one: nothing is dropped for a slow client, and resubscribing with last_event_id (or a last_event_ids map) replays every row above your cursor before live events resume. Subscribing without a cursor replays the newest 500 events only — the deliberate overlap described above, not your whole history — and "last_event_id": 1 is how you ask for the full backfill. tick_progress heartbeats are retained 7 days and every other type indefinitely, so a cursor older than that replays what remains, with no signal that the expired heartbeats were skipped. backtest_events deliberately has no replay — but it needs none: each frame carries the full current snapshot, so a missed frame self-heals on the next one, and the HTTP results endpoint backs the initial load and reconnect. For live-session state after a disconnect, refetch positions, orders, and transactions over REST to be safe.
If you resubscribe to session_events with a cursor (a last_event_by_sim map) for a session that has already ended and whose events have been cleaned up, the server replies with a one-shot stale_cursor signal instead of a silently empty replay, and does not enroll the subscription:
{ "channel": "session_events", "type": "stale_cursor", "session_id": "<uuid>" }
Refetch the session's authoritative state over REST; if you still want to follow it, resubscribe without a cursor.
Close codes
Some server-initiated closes carry a WebSocket close code:
- 1008 (policy violation) — the connection exceeded its inbound frame-rate budget, or the token it authenticated with was revoked or expired (logout, password reset, session expiry). Reconnect and re-authenticate; a dead token is refused.
- 1013 (try again later) — a subscribe/unsubscribe frame could not be admitted because the connection's control-frame queue was saturated. Reconnect and resubscribe.
Ordinary disconnects (and the 5-minute anonymous cap, which is preceded by an end_of_data frame) are code-less.
Connection limits
- Anonymous (
{"auth":"public"}) connections stream for at most 5 minutes, after which the server sends{"channel":"end_of_data"}and closes. They may subscribe tolive_aggregate_dataand tomarket_datafor theSPX/VIXunderlyings, but not tolive_option_chain,market_dataoption symbols, oraccount_financials. - Token-authenticated accounts have no concurrency limit and no time cap.
Quick test
The upgrade needs an allowed Origin header — a non-browser client must send https://www.0dtespx.com, or the handshake is refused with HTTP 403:
( echo '{"auth":"public"}'; cat ) | websocat --origin 'https://www.0dtespx.com' 'wss://api.0dtespx.com/__ws'
Send the auth message first — a subscribe frame that arrives before auth completes is read as a failed auth and the server closes the connection. Unknown channel names on a subscribe are silently dropped.