# Sphinx Exchange — WebSocket streaming API (AsyncAPI 3.0). # # The machine-readable twin of services/websockets.md (EDGE-085), authored # from the prose doc and verified field-by-field against the emitting code # (exchange/services/edge/internal/ws/ws.go). The EXTERNAL surface only: # the operator `admin` channel and its ticket flow are deliberately absent # (api_docs_site_spec.md R5). # # Versioning tracks the REST spec (openapi.yaml info.version). asyncapi: 3.0.0 info: title: Sphinx Exchange WebSocket API version: 0.4.0 description: | All streaming rides ONE WebSocket endpoint (`GET /v1/ws`, standard HTTP/1.1 Upgrade). After the upgrade, both sides exchange small JSON frames: * client → server: `{"op": "subscribe" | "unsubscribe", "channel": "...", "params": {...}}` and `{"op": "ping"}` * server → client: `{"channel": "...", "event": "...", "data": {...}}`, acks (`subscribed` / `unsubscribed`), `ping` / `pong`, and `error` frames. **Auth**: public channels need no auth. Private channels (`fill`, `account`) unlock by presenting the bearer token on the **Upgrade request** (`Authorization: Bearer .`). Browser clients, whose WebSocket API cannot set headers, may pass the same credential as `?token=` — the header wins when both are present, and non-browser clients should prefer the header (query strings get logged by intermediaries). **Heartbeat**: the server sends `{"event":"ping"}` every 15 s; reply (any frame counts as liveness) or the connection is closed. The server-side write timeout is 5 s — slow consumers are dropped. **Reconnect**: missed frames are NOT replayed. On reconnect, resubscribe and reconcile via REST (`GET /v1/snapshot`, `GET /v1/orders`); `orderbook_delta` re-bootstraps with a full-book frame and `candles` re-sends the open bar, so those two self-heal. **Acks**: `subscribed` / `unsubscribed` acks echo `channel` (and `instrument_id` when applicable) but do NOT echo the candles `resolution` param — track it locally when correlating. license: name: Proprietary defaultContentType: application/json servers: staging: host: api.staging.sphx.io protocol: wss pathname: /v1/ws description: Staging — LIVE (always-on demo venue). security: - $ref: '#/components/securitySchemes/bearerOnUpgrade' local: host: 127.0.0.1:18201 protocol: ws pathname: /v1/ws description: Local development (`make up`). channels: session: address: null title: Session control description: | The connection-level envelope: subscriptions, unsubscriptions, heartbeats, acks, and error frames. Everything else on this API is multiplexed over this one connection. messages: subscribe: $ref: '#/components/messages/subscribe' unsubscribe: $ref: '#/components/messages/unsubscribe' clientPing: $ref: '#/components/messages/clientPing' subscribedAck: $ref: '#/components/messages/subscribedAck' unsubscribedAck: $ref: '#/components/messages/unsubscribedAck' serverPing: $ref: '#/components/messages/serverPing' pong: $ref: '#/components/messages/pong' error: $ref: '#/components/messages/errorFrame' orderbook_delta: address: null title: orderbook_delta (public) description: | Changed price levels for one instrument's book, applied client-side in `event_seq` order on top of an initial snapshot (the server sends a full-book bootstrap frame on subscribe, then changed levels only). Emitted only when a level actually moved — a quiet channel means a quiet book; gap detection is purely via `event_seq` (on a gap, re-snapshot via `GET /v1/instruments/{id}/orderbook`). Subscribe params: `instrument_id` (required). messages: bookChanged: $ref: '#/components/messages/bookChanged' trade: address: null title: trade (public) description: | The anonymized public trade tape — one event per resting-order leg consumed, no account identifiers. Liquidation fills do NOT appear here (they ride the private `fill` channel of the affected account only). Backfill via `GET /v1/instruments/{id}/trades`. Subscribe params: `instrument_id` (required). messages: trade: $ref: '#/components/messages/trade' candles: address: null title: candles (public) description: | Live OHLCV bars per (instrument, resolution). `update` replaces the in-flight bar; `closed` finalizes it. Snapshot history first via `GET /v1/instruments/{id}/candles`. Subscribe params: `instrument_id` and `resolution` (both required; resolutions: 1m, 5m, 15m, 1h, 1d). messages: update: $ref: '#/components/messages/candleUpdate' closed: $ref: '#/components/messages/candleClosed' mark: address: null title: mark (public, per-instrument gated) description: | Engine mark publications with the current admission band. Emitted per mark update, only for instruments whose `api.mark_channel_enabled` flag is set by the venue. Subscribe params: `instrument_id` (required). messages: mark: $ref: '#/components/messages/mark' market_lifecycle: address: null title: market_lifecycle (public) description: | Venue-wide account-state and instrument-lifecycle transitions (numeric states). Public and unfiltered — clients typically ignore accounts other than their own. No subscribe params. messages: accountStateChange: $ref: '#/components/messages/lifecycleAccountStateChange' instrumentLifecycle: $ref: '#/components/messages/instrumentLifecycle' fill: address: null title: fill (private) description: | The authenticated account's own fills, within ~10 ms of the match (p99). Carries the account's own-leg fee (signed µUSDC) and the fill tag (`normal` / `liquidation` / `adl`). If two of your accounts cross, each receives its own frame. Requires bearer auth on the upgrade. No subscribe params (auto-scoped). messages: fill: $ref: '#/components/messages/fill' account: address: null title: account (private) description: | Everything else that touches the authenticated account: deposits, withdrawal lifecycle, state changes, mass-cancel completions, and the margin-era set — settlement applies, margin deficit records, margin warnings, and the liquidation lifecycle. Requires bearer auth on the upgrade. No subscribe params (auto-scoped). `entitlement_change` is reserved for V1 and never emitted today. messages: depositCredited: $ref: '#/components/messages/depositCredited' withdrawalState: $ref: '#/components/messages/withdrawalState' accountStateChange: $ref: '#/components/messages/accountStateChange' massCancelComplete: $ref: '#/components/messages/massCancelComplete' settlementApplied: $ref: '#/components/messages/settlementApplied' marginDeficitFlagged: $ref: '#/components/messages/marginDeficitFlagged' marginWarning: $ref: '#/components/messages/marginWarning' liquidationStarted: $ref: '#/components/messages/liquidationStarted' liquidationComplete: $ref: '#/components/messages/liquidationComplete' operations: manageSubscriptions: action: send channel: $ref: '#/channels/session' title: Subscribe / unsubscribe / ping summary: Client-driven session control. messages: - $ref: '#/channels/session/messages/subscribe' - $ref: '#/channels/session/messages/unsubscribe' - $ref: '#/channels/session/messages/clientPing' receiveSessionFrames: action: receive channel: $ref: '#/channels/session' title: Acks, heartbeats, errors messages: - $ref: '#/channels/session/messages/subscribedAck' - $ref: '#/channels/session/messages/unsubscribedAck' - $ref: '#/channels/session/messages/serverPing' - $ref: '#/channels/session/messages/pong' - $ref: '#/channels/session/messages/error' receiveBookChanges: action: receive title: orderbook_delta — book changes (public) summary: orderbook_delta — book changes (public) channel: $ref: '#/channels/orderbook_delta' receiveTrades: action: receive title: trade — public tape (public) summary: trade — public tape (public) channel: $ref: '#/channels/trade' receiveCandles: action: receive title: candles — live OHLCV bars (public) summary: candles — live OHLCV bars (public) channel: $ref: '#/channels/candles' receiveMarks: action: receive title: mark — engine marks + admission band (public, gated) summary: mark — engine marks + admission band (public, gated) channel: $ref: '#/channels/mark' receiveMarketLifecycle: action: receive title: market_lifecycle — venue-wide transitions (public) summary: market_lifecycle — venue-wide transitions (public) channel: $ref: '#/channels/market_lifecycle' receiveFills: action: receive title: fill — your fills (private) summary: fill — your fills (private) channel: $ref: '#/channels/fill' security: - $ref: '#/components/securitySchemes/bearerOnUpgrade' receiveAccountEvents: action: receive title: account — deposits, withdrawals, margin & liquidation lifecycle (private) summary: account — deposits, withdrawals, margin & liquidation lifecycle (private) channel: $ref: '#/channels/account' security: - $ref: '#/components/securitySchemes/bearerOnUpgrade' components: securitySchemes: bearerOnUpgrade: type: http scheme: bearer description: | Bearer token (`.` — API key or session token) presented as the `Authorization` header on the HTTP Upgrade request (browsers may use `?token=`; the header wins). Optional for public channels; required to subscribe to `fill` and `account`. messages: subscribe: name: subscribe title: subscribe summary: Open a logical channel. payload: $ref: '#/components/schemas/SubscribeRequest' examples: - name: orderbook payload: op: subscribe channel: orderbook_delta params: instrument_id: 1000 - name: candles payload: op: subscribe channel: candles params: instrument_id: 1000 resolution: 5m unsubscribe: name: unsubscribe title: unsubscribe payload: $ref: '#/components/schemas/UnsubscribeRequest' clientPing: name: clientPing title: ping (client) summary: Client liveness probe; the server answers `pong`. payload: type: object required: [op] properties: op: const: ping subscribedAck: name: subscribed title: subscribed (ack) payload: type: object required: [event, channel] properties: event: const: subscribed channel: $ref: '#/components/schemas/ChannelName' data: type: object properties: channel: type: string instrument_id: type: integer format: int64 unsubscribedAck: name: unsubscribed title: unsubscribed (ack) payload: type: object required: [event, channel] properties: event: const: unsubscribed channel: $ref: '#/components/schemas/ChannelName' data: type: object properties: channel: type: string instrument_id: type: integer format: int64 serverPing: name: serverPing title: ping (server heartbeat) summary: Sent every 15 s; reply or be disconnected. payload: type: object required: [event] properties: event: const: ping pong: name: pong title: pong payload: type: object required: [event] properties: event: const: pong errorFrame: name: error title: error summary: Subscribe/protocol failure; the connection may close. payload: type: object required: [event, data] properties: event: const: error data: type: object required: [reason] properties: reason: type: string description: | e.g. `authentication required for channel 'fill'`, `unknown channel: `, `invalid params:
`. bookChanged: name: book_changed title: book_changed payload: allOf: - $ref: '#/components/schemas/EventFrame' - type: object properties: channel: const: orderbook_delta event: const: book_changed data: $ref: '#/components/schemas/BookChangedData' trade: name: trade title: trade payload: allOf: - $ref: '#/components/schemas/EventFrame' - type: object properties: channel: const: trade event: const: trade data: $ref: '#/components/schemas/TradeData' candleUpdate: name: update title: update (in-flight bar) payload: allOf: - $ref: '#/components/schemas/EventFrame' - type: object properties: channel: const: candles event: const: update data: $ref: '#/components/schemas/CandleData' candleClosed: name: closed title: closed (bar finalized) payload: allOf: - $ref: '#/components/schemas/EventFrame' - type: object properties: channel: const: candles event: const: closed data: $ref: '#/components/schemas/CandleData' mark: name: mark title: mark payload: allOf: - $ref: '#/components/schemas/EventFrame' - type: object properties: channel: const: mark event: const: mark data: $ref: '#/components/schemas/MarkData' lifecycleAccountStateChange: name: account_state_change title: account_state_change (venue-wide, numeric) payload: allOf: - $ref: '#/components/schemas/EventFrame' - type: object properties: channel: const: market_lifecycle event: const: account_state_change data: type: object required: [event_seq, account_id, new_state] properties: event_seq: $ref: '#/components/schemas/EventSeq' account_id: type: integer format: int64 new_state: type: integer description: Numeric account state (see the REST reference lookup tables). instrumentLifecycle: name: instrument_lifecycle title: instrument_lifecycle payload: allOf: - $ref: '#/components/schemas/EventFrame' - type: object properties: channel: const: market_lifecycle event: const: instrument_lifecycle data: type: object required: [event_seq, instrument_id, new_state] properties: event_seq: $ref: '#/components/schemas/EventSeq' instrument_id: type: integer format: int64 new_state: type: integer description: Numeric instrument lifecycle state. fill: name: fill title: fill payload: allOf: - $ref: '#/components/schemas/EventFrame' - type: object properties: channel: const: fill event: const: fill data: $ref: '#/components/schemas/FillData' depositCredited: name: deposit_credited title: deposit_credited payload: allOf: - $ref: '#/components/schemas/EventFrame' - type: object properties: channel: const: account event: const: deposit_credited data: type: object required: [asset_id, amount, deposit_txn, event_seq] properties: asset_id: type: integer amount: type: integer format: int64 description: Atomic units (1e-6 USDC). deposit_txn: type: string description: On-chain transaction reference. event_seq: $ref: '#/components/schemas/EventSeq' withdrawalState: name: withdrawal_state title: withdrawal_state payload: allOf: - $ref: '#/components/schemas/EventFrame' - type: object properties: channel: const: account event: const: withdrawal_state data: type: object required: [withdrawal_id, state, event_seq] properties: withdrawal_id: type: integer format: uint64 state: type: string enum: [pending, finalized] description: | V0 emits exactly these two. Intermediate custody states surface only on `GET /v1/withdrawals/{id}`. event_seq: $ref: '#/components/schemas/EventSeq' accountStateChange: name: account_state_change title: account_state_change (own account, string state) payload: allOf: - $ref: '#/components/schemas/EventFrame' - type: object properties: channel: const: account event: const: account_state_change data: type: object required: [new_state, event_seq] properties: new_state: type: string enum: [pending, active, trade_frozen, withdraw_frozen, fully_frozen, closed] event_seq: $ref: '#/components/schemas/EventSeq' massCancelComplete: name: mass_cancel_complete title: mass_cancel_complete payload: allOf: - $ref: '#/components/schemas/EventFrame' - type: object properties: channel: const: account event: const: mass_cancel_complete data: type: object required: [instrument_id, canceled_count, sequencer_correlation_id, event_seq] properties: instrument_id: type: integer format: int64 canceled_count: type: integer description: Authoritative count (the REST 202's estimate is a hint). sequencer_correlation_id: type: integer format: int64 description: Matches the triggering `POST /v1/orders/cancel_all` response. event_seq: $ref: '#/components/schemas/EventSeq' settlementApplied: name: settlement_applied title: settlement_applied (futures settle-to-market) payload: allOf: - $ref: '#/components/schemas/EventFrame' - type: object properties: channel: const: account event: const: settlement_applied data: type: object required: [instrument_id, vm_uusdc, settle_ticks, prev_settle_ticks, event_seq] properties: instrument_id: type: integer format: int64 scheduled_time_ns: type: integer format: int64 vm_uusdc: type: integer format: int64 description: Signed variation margin banked to margin cash. settle_ticks: type: integer format: int64 prev_settle_ticks: type: integer format: int64 position_rebased: type: boolean const: true event_seq: $ref: '#/components/schemas/EventSeq' marginDeficitFlagged: name: margin_deficit_flagged title: margin_deficit_flagged payload: allOf: - $ref: '#/components/schemas/EventFrame' - type: object properties: channel: const: account event: const: margin_deficit_flagged data: type: object required: [maintenance_equity_uusdc, mm_total_uusdc, suppressed, event_seq] properties: maintenance_equity_uusdc: type: integer format: int64 mm_total_uusdc: type: integer format: int64 suppressed: type: boolean description: Deficit recorded while liquidation was suspended (staleness rung). event_seq: $ref: '#/components/schemas/EventSeq' marginWarning: name: margin_warning title: margin_warning (pre-breach threshold crossing) payload: allOf: - $ref: '#/components/schemas/EventFrame' - type: object properties: channel: const: account event: const: margin_warning data: type: object required: [sub_account, instrument, equity_uusdc, mm_requirement_uusdc, mm_ratio_bps, threshold_hit, mark_event_seq] properties: sub_account: type: integer description: Always 1 (the margin sub-account) in V0. instrument: type: integer format: int64 equity_uusdc: type: integer format: int64 mm_requirement_uusdc: type: integer format: int64 mm_ratio_bps: type: integer threshold_hit: type: integer mark_event_seq: $ref: '#/components/schemas/EventSeq' liquidationStarted: name: liquidation_started title: liquidation_started payload: allOf: - $ref: '#/components/schemas/EventFrame' - type: object properties: channel: const: account event: const: liquidation_started data: type: object required: [canceled_order_count, cured, event_seq] properties: canceled_order_count: type: integer cured: type: boolean description: True when cancellation alone restored margin (no takeover follows). event_seq: $ref: '#/components/schemas/EventSeq' liquidationComplete: name: liquidation_complete title: liquidation_complete payload: allOf: - $ref: '#/components/schemas/EventFrame' - type: object properties: channel: const: account event: const: liquidation_complete data: type: object required: [filled_qty, penalty, shortfall_uusdc, remainder_uusdc, event_seq] properties: filled_qty: type: integer format: int64 penalty: type: integer format: int64 shortfall_uusdc: type: integer format: int64 remainder_uusdc: type: integer format: int64 event_seq: $ref: '#/components/schemas/EventSeq' schemas: ChannelName: type: string enum: [orderbook_delta, trade, candles, mark, market_lifecycle, fill, account] EventSeq: type: integer format: int64 description: Cluster sequence number — monotonic; drives gap detection and REST/WS joins. SubscribeRequest: type: object required: [op, channel] properties: op: const: subscribe channel: $ref: '#/components/schemas/ChannelName' params: $ref: '#/components/schemas/SubscribeParams' UnsubscribeRequest: type: object required: [op, channel] properties: op: const: unsubscribe channel: $ref: '#/components/schemas/ChannelName' params: $ref: '#/components/schemas/SubscribeParams' SubscribeParams: type: object properties: instrument_id: type: integer format: int64 description: Required for orderbook_delta / trade / candles / mark. resolution: type: string enum: [1m, 5m, 15m, 1h, 1d] description: Required for candles. EventFrame: type: object required: [channel, event, data] properties: channel: $ref: '#/components/schemas/ChannelName' event: type: string data: type: object BookChangedData: type: object required: [instrument_id, event_seq, changes] properties: instrument_id: type: integer format: int64 event_seq: $ref: '#/components/schemas/EventSeq' changes: type: array description: Only the levels that moved; `new_count` 0 removes the level. items: type: object required: [side, price_pips, new_count] properties: side: type: string enum: [yes_bid, no_bid] description: YES-collapsed book sides (margined asks project as no_bid at MAX−p). price_pips: type: integer new_count: type: integer TradeData: type: object required: [event_seq, ts_ns, instrument_id, price_pips, count, taker_side] properties: event_seq: $ref: '#/components/schemas/EventSeq' ts_ns: type: integer format: int64 description: Aeron Cluster timestamp — MILLISECONDS despite the name (historical artifact). instrument_id: type: integer format: int64 price_pips: type: integer count: type: integer taker_side: type: string enum: [buy, sell] description: The aggressor's action, YES-collapsed convention. CandleData: type: object required: [instrument_id, resolution, bar] properties: instrument_id: type: integer format: int64 resolution: type: string enum: [1m, 5m, 15m, 1h, 1d] bar: type: object required: [t, o, h, l, c, v] properties: t: type: integer format: int64 description: Bar window start, Unix seconds. o: { type: integer } h: { type: integer } l: { type: integer } c: { type: integer } v: { type: integer, format: int64 } closed: type: boolean description: Present (true) only on `closed` events. MarkData: type: object required: [instrument_id, mark, band_lo, band_hi, event_seq, ts] properties: instrument_id: type: integer format: int64 mark: type: integer format: int64 description: Engine-derived mark, ticks. band_lo: type: integer format: int64 band_hi: type: integer format: int64 description: RC-1 admission band bounds, ticks. event_seq: $ref: '#/components/schemas/EventSeq' ts: type: integer format: int64 description: Sequenced timestamp of the mark event. FillData: type: object required: [event_seq, ts_ns, instrument_id, maker_order_id, taker_order_id, account_id, side, price_pips, count, maker_side, fee_uusdc, tag] properties: event_seq: $ref: '#/components/schemas/EventSeq' ts_ns: type: integer format: int64 description: Aeron Cluster timestamp — MILLISECONDS despite the name. instrument_id: type: integer format: int64 maker_order_id: type: integer format: uint64 taker_order_id: type: integer format: uint64 account_id: type: integer format: int64 description: Always the connection's authenticated account. side: type: string enum: [maker, taker] description: Which side of the match this account was on. price_pips: type: integer count: type: integer maker_side: type: string enum: [buy, sell] fee_uusdc: type: integer format: int64 description: This account's own leg fee, signed µUSDC (negative = rebate). tag: type: string enum: [normal, liquidation, adl] description: Liquidation fills carry the tag here only — never on the public tape.