API reference

WebSocket streams

Real-time market data and per-session event channels.

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" }
Connection lifecycle: connect → auth (≤5 s) → subscribe → frames client wss://api.0dtespx.com/__ws {"token": "…"} — within 5 s of connecting {"action":"subscribe","channels":["live_aggregate_data","live_option_chain"]} {"channel":"live_aggregate_data","payload":{…}} every second {"channel":"live_option_chain","payload":…} every second {"channel":"session_events","id":42,"type":"order_update",…} on change

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 endpoint GET /market-data/option-chain-snapshots/{timestamp} returns for a single timestamp: bid, ask, and signed delta per 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 in GET /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_data is 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, and settlement_failed. Subscribe per session with {"action":"subscribe","channel":"session_events","session_id":"<uuid>"}; each envelope carries top-level session_id and account_id so clients route to per-session state. Per-tick continuous values (P&L, delta) aren't pushed here — compute them from live_option_chain. At most 64 sessions per connection.

    order_update is the order's lifecycle, event by event. Its payload is the same order JSON the REST reads return — status, cancel_requested, the prices, the status-conditional stamps — plus accepted_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", "…": "…" } }
    1. Admissionstatus: "pending", accepted_seq: null: we accepted the order, nothing downstream has confirmed it. This event is written before the POST response is.
    2. Acceptancestatus: "live", accepted_seq stamped: the order is resting on the book. (The ladder's routed rung is reserved for a future venue and produces no event today.)
    3. Terminalfilled / canceled / expired / rejected, with that state's stamps.

    A filled frame carries fill_quotes among 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, never null.

    A cancel request adds a frame of its own: the same order, unchanged status, now carrying cancel_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.

  • 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}, where id is a monotonically increasing row id. data carries type, account_id (the bot id — a bot is a trading account, so the two are the same value), and session_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_id is the same as 0, 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 from GET /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_logged is mirrored into the decision log, and the session_started / session_completed pair is reflected in the session's own record. The other seven — signal, halted, registering, waiting_for_data, session_resumed, missed_ticks and error — have no REST equivalent: this channel is the only place they are published, and error is 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 on data.log_id for a decision_logged event you may also have fetched over REST. Pass last_event_id whenever you have one — the highest id you have already processed on a reconnect — and pass "last_event_id": 1 when 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_progress heartbeats 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 (unlike session_events, there is no stale_cursor here). What an expired heartbeat takes with it is its point-in-time tick_index, open_positions and open_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.type is one of eleven:

    • session_started — the session was created. Payload session_date, opening_balance, starting_capital. Whether you started it or the platform's automatic start did rides the paired decision_logged entry (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), and log_id. Your strategy's ctx.log() output, platform re-pricing activity (order_repriced — its payload's legs_changed marks a step that also re-selected the entry's legs — and reprice_failed), the pricing rows (order_start_priced and order_repriced both carry quotes_at — each leg's option symbol mapped to the timestamp of the quote used for it — plus net_bid and net_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, carrying rejection_reason, order_created: false (the exchange never accepted it, so the order reads rejected and never reached the book), and an optional message), 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 new order_id for that re-submission (which may itself be refused, so it becomes a working order only once one is accepted) plus attempt, max_attempts and reason — and entry_retry_abandoned, at most one per session, carrying attempts and reason when 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 — a ctx.signal() call: name plus its fields.
    • session_resumed — a mid-session bot was picked back up: after a platform restart, or after you restarted a stopped session. Payload status, tick_index. At most one per resume.
    • missed_ticks — the session entered a stale-market-data episode (or a load-shedding one): tick_index plus age_ms, or overload: true. One per episode, not one per skipped tick.
    • halted — the strategy called ctx.halt(): reason, source. A halt request, not a terminal state by itself.
    • session_completedterminal, 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). status is the terminal session status — completed, halted, stopped, or failed.
    • error — a strategy callback raised: callback, tick_index, message (truncated to 500 characters with a trailing ellipsis). on_tick / on_order_filled errors 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 a decision_logged entry with event: "strategy_stopped" while the session stays running and tick_progress keeps flowing over its held positions. session_completed comes later — status: "stopped" once you flatten it with a liquidation, or status: "completed" if you hold to the 16:00 ET close. A restart in between is a decision_logged entry with event: "strategy_restarted" (source: "api") followed by session_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 still scheduled or registering, or one whose strategy had already exited everything — and so does a flat book with no stop at all: expect a final decision_logged frame (the session_ended entry) and then session_completed with status: "stopped" as the session's last event, whenever that happens during the day. And there is no stopped event type: a terminal stop is that session_completed payload.

  • backtest_eventsstrategy results progress for one strategy's backtest. Subscribe per strategy with its source_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 under data.snapshot (the same shape GET /strategies/preview/{source_hash}/results returns), folded for you — render straight from it, no per-tick refetch. data.type is one of started, 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.type is one of delta (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 your backtest_events subscription to it. params is omitted on an older draft that hasn't been edited since the field was introduced), turn_complete, error. A reconnect may supply last_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 explicit symbols list: {"action":"subscribe","channel":"market_data","symbols":["SPX","VIX","SPXW 260717C05900000"]}. Two symbol kinds:

    • Underlyings "SPX" and "VIX" — public (anonymous connections may subscribe). Each emits a type:"trade" frame carrying the index price (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 a type:"quote" frame carrying bid, ask, and signed delta: {"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). datetime is offset-less UTC. Contract quotes come off the same chain tick as live_option_chain and share its availability bound: no type:"quote" frame is emitted before the session's data-start-time. The SPX/VIX type:"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 symbols list to drop those symbols, or with no symbols to clear all of them.

  • account_financials — per-account live financials at a 1-second cadence, token-authenticated only. Subscribe with an account_ids list (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") and timestamp is RFC 3339 with a trailing Z — the same field encoding GET /accounts/{id}/sessions/{sid}/history returns, 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 from GET /accounts/{id}/sessions/{sid}/history. At most 64 account ids per subscribe frame and 64 per connection. Unsubscribe with an account_ids list to drop those accounts, or with no account_ids to 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, and backtest_events refuse the over-cap subscribe and the server sends a subscription_rejected envelope naming the channel and its limit:

    { "channel": "subscription_rejected", "payload": { "target": "session_events", "reason": "limit_exceeded", "limit": 64 } }

    target is one of session_events, assistant_draft_events, or backtest_events.

  • market_data and account_financials instead silently truncate: keys past the cap are dropped and the rest are subscribed, with no subscription_rejected envelope.

(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 to live_aggregate_data and to market_data for the SPX/VIX underlyings, but not to live_option_chain, market_data option symbols, or account_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.