A single WebSocket endpoint streams live market data and trading-day 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 or per trading account (owner only) |
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 trading day:order_update(status transitions),financials_update(cash, buying power, realized P&L),ended, andsettlement_failed. Every envelope carries top-levelsession_idandaccount_idso clients route to per-day state. Per-tick continuous values (P&L, delta) aren't pushed here — compute them fromlive_option_chain.Subscribe by account, or by session. For live trading, subscribe to the account:
{ "action": "subscribe", "channel": "session_events", "account_id": "<account-uuid>" }You then receive every trading day of that account: the day open now, and any day that opens later — including the one your own first order creates. That matters because your trading day is opened for you by your first order or dry-run, so at the moment you subscribe there may be no day to name yet. Subscribe once, when you pick the account, and leave it: there is nothing to re-subscribe when the day opens.
For a bot session, subscribe by session id instead — bots run several sessions per date, so an account id doesn't identify one of them, and a bot account id is declined here:
{ "action": "subscribe", "channel": "session_events", "session_id": "<session-uuid>" }An account you don't own is declined silently, and a subscribe naming both
session_idandaccount_idis ignored whole — an unsubscribe naming both is honored and removes both scopes. Unsubscribe with the same key you subscribed with; an unsubscribe naming neither clears everysession_eventssubscription on the connection. At most 64 sessions and 8 accounts per connection.order_updateis the order's lifecycle, event by event. Itspayloadis the same order JSON the REST reads return —status,cancel_requested,replaces_order_id, 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.
The frames are usually a fraction of a second apart, but they don't have to be: a queued order — one the
POSTanswered202— sits at admission until the exchange processes it, so its next frame can be minutes later. A long gap is not a lost frame.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.An order a strategy placed carries
trade_idandtrade_labelfrom its first frame onward — the trade it belongs to, matching the rows the session's trades read returns — so a client can file the order under its trade the moment it learns of it. Both keys are absent on an order you placed yourself.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.A replace adds a whole order's worth of frames, because the revision is its own order. Its admission frame —
status: "pending", withreplaces_order_idnaming the order being revised — is written before thePUTresponse, ahead of the original'scanceledframe and the revision's own acceptance:{ "channel": "session_events", "id": 44, "session_id": "<uuid>", "account_id": "<uuid>", "type": "order_update", "payload": { "id": "<revision-uuid>", "status": "pending", "accepted_seq": null, "replaces_order_id": "<order-uuid>", "price": "10.40", "…": "…" } } { "channel": "session_events", "id": 45, "session_id": "<uuid>", "account_id": "<uuid>", "type": "order_update", "payload": { "id": "<order-uuid>", "status": "canceled", "accepted_seq": 7, "…": "…" } } { "channel": "session_events", "id": 46, "session_id": "<uuid>", "account_id": "<uuid>", "type": "order_update", "payload": { "id": "<revision-uuid>", "status": "live", "accepted_seq": 8, "replaces_order_id": "<order-uuid>", "price": "10.40", "…": "…" } }If the replace is never applied, the revision's next frame is
rejectedinstead, and the original usually has no terminal frame at all — it simply keeps working. Usually, because the original can also be what ended the replace: if it filled or was canceled while the revision was in flight, it emits that terminal frame of its own. Either way it is the frames, not thePUT's status code, that tell a client which order is live.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 re-submitted an entry the exchange had temporarily refused, 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 — which, for a strategy whose entry the platform works across its window, also covers the ordinary case of that window closing on an entry that never filled, the platform withdrawing it there; a strategy saved before platform-managed entry windows reports that ending asentry_window_closedalone.entry_retried'sreasonsays which loop you are reading:platform_entry_windowis the whole-window one, and itsmax_attemptsis that window's allowance rather than the smaller post-refusal recovery budget), the platform's own entry notes (entry_place_failed— a platform attempt was refused, by the exchange or by a platform check such as a trade budget or the order guardrail, reported once per kind of refusal;entry_rebuild_yielded_none—attempts, the trade is armed and inside its window but the strategy has offered nothing to place on any attempt so far;entry_window_wrong_day— the strategy asked to open a trade on a weekday its own window excludes, so nothing was attempted; rare live, since such a session is refused at the start, and mostly met in a strategy's computed sessions), the liquidation handoff (strategy_liquidation_started—reason, the strategy handed the session to the platform to close; and the rarestrategy_liquidation_unclaimed/liquidation_start_failed, the platform standing down without touching the book so the positions ride to the close),guardrail_tripped(an order-rate guardrail trip with its counts — the decision-log companion to the session'shalt_reason: "order_guardrail_tripped"),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— a strategy halted itself: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 strategy that closes itself out reaches the same terminal frame without any stop from you: astrategy_liquidation_starteddecision_loggedentry, thensession_completedwithstatus: "stopped"andhalt_reason: "strategy_liquidated"once the platform has flattened the book. 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(an assistant message;interim: true— carried alongsidemessage_id, the transcript row id — marks a mid-turn progress message the assistant keeps working after, or the partial text of a turn that ended early, so one turn may carry severalmessageevents, the last one withoutinterimbeing the closing reply when the turn completes with one — a turn that closes by asking a question emits itsask_usercard instead of an unflaggedmessage),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}/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}/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 and 8 accounts, assistant_draft_events 16 drafts, backtest_events 256 strategies, market_data 256 symbols, account_financials 64 accounts. One per-frame cap sits beside them: a session_events subscribe may carry at most 128 last_event_by_sim cursors, on either subscribe form — a frame over that is answered with the envelope below and then ignored whole, so nothing in it takes effect. 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. Forsession_eventsthelimitfield tells you which bound you hit: 64 (sessions), 8 (accounts), or 128 (cursors in one frame).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 trading-day state after a disconnect, refetch positions, orders, and transactions over REST to be safe, and re-send your subscriptions — including the session_events account_id subscribe, carrying whatever cursors you still hold for that account's days.
A last_event_by_sim map (session id → highest seen event id) replays the gap on either subscribe form: with session_id the map's entry for that session is the cursor, and with account_id each open day of the account uses its own entry when the map has one, or starts at that day's current head when it doesn't — so a page that just loaded its state over REST is never re-sent the day's history. A day the server enrolls on its own, one that opened after you subscribed, always arrives from its first event.
If a cursor names a day 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 that day. It fires on both forms — a session_id resubscribe into such a session, and every last_event_by_sim entry of an account_id subscribe naming a day of yours that has ended. The envelope has no account_id; route it by session_id alone:
{ "channel": "session_events", "type": "stale_cursor", "session_id": "<uuid>" }
Refetch that day's authoritative state over REST; drop the stale cursor and resubscribe if you still want to follow it.
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.