WebSocket API Reference¶
Status: Normative. This chapter is the contract for the four WebSocket streams of
pm-api-gwy(api_gateway/routers/ws.py). It is versioned with the REST API under/api/v1and follows the same compatibility policy. For a tutorial with worked client code see API Gateway (REST/WebSocket). The key words MUST, MUST NOT, SHOULD and MAY are used per RFC 2119.tests/test_ws_spec_matches_code.pyholds the catalogues below to the code.
Streams¶
Every socket lives under /api/v1/ws/, so a reverse proxy can route upgrades
by that one prefix.
| Path | Access | Purpose | Queue |
|---|---|---|---|
/api/v1/ws/private |
Trading key | The caller gateway's order, quote and risk events | 256 |
/api/v1/ws/market-data |
Any key | Public book, depth, trades, auctions, session, halts and news, by subscription | 4096 |
/api/v1/ws/admin/monitor |
ADMIN | Every engine event across all gateways except public market data and engine.trace, plus admin.action |
512 |
/api/v1/ws/admin/sim |
ADMIN | pm-market-sim state and the model's true values |
64 |
Queue is the number of events the gateway holds for one socket that has not yet read them. See Flow control.
Connecting¶
- The client opens the socket. The server accepts the upgrade.
- The client MUST send, as its first frame and within 5 seconds, a JSON
object
{"api_key": "<key>"}. - On success the server sends
{"type": "authenticated", ...}and then the socket's opening snapshot, if it has one, before any live event.
The checks, in order, and what a failure looks like:
| Check | Sockets | On failure |
|---|---|---|
| A first frame arrives within 5 s and is JSON | all | close 1008 |
api_key is a configured key |
all | close 1008 |
The key is a trading key (has a gateway_id) |
private, admin/monitor, admin/sim |
error frame (admin sockets), then close 1008 |
| The engine accepts the gateway's identity | private, admin/monitor |
error frame with the engine's reason, then close 1008 |
| The gateway holds the ADMIN role | admin/monitor, admin/sim |
error frame "ADMIN role required", then close 1008 |
Close codes¶
The gateway itself closes a socket with exactly one code:
| Code | Meaning |
|---|---|
1008 |
Policy violation: authentication or authorisation failed (see above). The client MUST NOT reconnect with the same key without fixing the cause |
Any other closure (a normal close, the server shutting down, or a dropped connection with no close frame) is not a verdict on the client: it SHOULD reconnect with backoff and recover as described for each socket.
The event envelope¶
Every engine event on every socket arrives in one envelope:
{
"type": "order.fill",
"topic": "order.fill.TRADER01",
"seq": 4127,
"stream_seq": 88,
"ts": "2026-06-24T10:15:03.221Z",
"gateway_id": "TRADER01",
"data": { "order_id": "ORD-...", "fill_qty": 50, "fill_price": 150.5 }
}
| Field | Present | Meaning |
|---|---|---|
type |
always | The stable event name; see Event types |
topic |
always | The engine topic the event came from |
seq |
engine events | Per-topic counter, assigned once per event by this gateway process |
stream_seq |
private only |
Per-gateway counter across the whole private stream |
ts |
always | When this gateway sent the event, UTC, millisecond ISO-8601 |
gateway_id |
gateway-scoped events | The gateway the event belongs to |
data |
always | The engine message body; its schema is the linked message reference |
Server frames that are not engine events (authenticated, snapshots, the
market-data control replies, error) carry type and data and only the other
fields their row below names.
Sequencing¶
seqcounts pertopic, within one gateway process. A client checking for gaps MUST key ontopic, nottype: one type (depth) spans many topics (depth.AAPL,depth.MSFT), each numbered independently.trade.executedis one topic for every symbol, so itsseqis contiguous only on a*trades subscription. A subscription to some symbols' trades sees jumps where other symbols traded; it cannot detect a drop fromseqand SHOULD subscribe to*when it needs to.stream_seqcounts every event on one gateway's private stream, so on/ws/privateit is contiguous: a gap of any size means events were lost.- An event is numbered before it is queued, so an event dropped for a slow socket still consumes its number. A gap is therefore always detectable.
- Counters restart when the gateway process restarts. A client SHOULD treat a
seqlower than the last one seen on a topic as a restart, not a gap.
Event types¶
type is derived from topic: the first two dot-separated segments
(order.fill.TRADER01 gives order.fill), with these exceptions:
| Topic | type |
|---|---|
trade.executed |
trade |
book.{symbol} |
book |
depth.{symbol} |
depth |
session.state |
session |
circuit_breaker.* |
circuit_breaker |
auction.result.{symbol} |
auction |
auction.indicative.{symbol} |
auction.indicative |
risk.kill_switch_ack.{gateway_id} |
mass_cancel.ack |
admin.action.{gateway_id} |
admin.action |
A client MUST ignore an event whose type it does not recognise. New types
may be added within v1.
Flow control¶
Each socket has a bounded queue (see Streams). The gateway never blocks the engine feed for a slow client: when a socket's queue is full, the event is dropped for that socket and the socket stays open.
- Every drop is counted per stream (
private,market_data,admin) inGET /api/v1/healthzasdropped_eventsand in the gateway's metrics asapi.dropped_events. - The client detects a drop from a
seqorstream_seqgap and recovers as its socket describes below. /ws/admin/simandnewsevents are not numbered. Eachsimframe supersedes the previous one, so a dropped one needs no recovery.
/api/v1/ws/private¶
The caller gateway's own events, unfiltered.
Opening frames¶
type |
Fields | Meaning |
|---|---|---|
authenticated |
gateway_id, stream_seq |
Authenticated; stream_seq is the stream position now |
orders.snapshot |
gateway_id, stream_seq, ts, data: {orders, positions, quote_legs} |
The gateway's view of the caller's orders, positions and quote legs, accurate as of stream_seq |
The event sink is registered before the snapshot is taken: the worst case is an event both in the snapshot and delivered live, never one in neither. Order state is idempotent, so a client MAY apply such a duplicate twice.
Private event catalogue¶
type |
Engine topic | Payload |
|---|---|---|
order.ack |
order.ack.{gateway_id} |
order.ack |
order.fill |
order.fill.{gateway_id} |
order.fill |
order.amended |
order.amended.{gateway_id} |
order.amended |
order.cancelled |
order.cancelled.{gateway_id} |
order.cancelled |
order.expired |
order.expired.{gateway_id} |
order.expired |
order.triggered |
order.triggered.{gateway_id} |
order.triggered |
order.orders |
order.orders.{gateway_id} |
order.orders |
combo.ack |
combo.ack.{gateway_id} |
combo.ack |
combo.status |
combo.status.{gateway_id} |
combo.status |
oco.ack |
oco.ack.{gateway_id} |
oco.ack |
oco.cancelled |
oco.cancelled.{gateway_id} |
oco.cancelled |
quote.ack |
quote.ack.{gateway_id} |
quote.ack |
quote.status |
quote.status.{gateway_id} |
quote.status |
oco.cancel_ack |
oco.cancel_ack.{gateway_id} |
oco.cancel_ack |
combo.cancel_ack |
combo.cancel_ack.{gateway_id} |
combo.cancel_ack |
quote.cancel_ack |
quote.cancel_ack.{gateway_id} |
quote.cancel_ack |
mass_cancel.ack |
risk.kill_switch_ack.{gateway_id} |
risk.kill_switch_ack |
system.gateway_auth |
system.gateway_auth.{gateway_id} |
system.gateway_auth |
system.symbols |
system.symbols.{gateway_id} |
system.symbols |
system.quote_bootstrap |
system.quote_bootstrap.{gateway_id} |
system.quote_bootstrap |
system.quote_legs |
system.quote_legs.{gateway_id} |
system.quote_legs |
system.session_status |
system.session_status.{gateway_id} |
system.session_status |
Each OCO, combo or quote cancel is answered by its own *.cancel_ack, accepted
or refused, echoing the cancel's request_tag; the submit acks (oco.ack,
combo.ack, quote.ack) report submissions only.
The order.* events carry the order's client_tag, and cancels and amends
their request_tag (see the REST spec's
order correlation).
Recovery¶
On reconnect the server sends a fresh orders.snapshot. A client MUST rebuild
its order state from it, then apply live events with a higher stream_seq.
There is no replay of missed private events: the snapshot is the recovery. For
history, use GET /api/v1/history/orders and GET /api/v1/history/fills.
/api/v1/ws/market-data¶
Public events, filtered by the socket's subscription. Any configured key may connect, including a read-only one.
Channels¶
A client subscribes to (symbol, channel) pairs. Three channels are always
on: their events reach every market-data socket without a subscription,
because a halt or a phase change alters the meaning of every other channel.
| Channel | Subscribable | Event types |
Engine topics |
|---|---|---|---|
book |
yes | book |
book.{symbol} |
depth |
yes | depth |
depth.{symbol} |
trades |
yes | trade |
trade.executed |
auction |
yes | auction, auction.indicative |
auction.result.{symbol}, auction.indicative.{symbol} |
session |
always on | session |
session.state |
circuit_breaker |
always on | circuit_breaker |
circuit_breaker.halt.{symbol}, .extend.{symbol}, .resume.{symbol} |
news |
always on | news |
news.event (from pm-market-sim; not numbered) |
Control frames¶
The client sends control frames as JSON objects at any time after
authenticated.
action |
Fields | Effect | Reply |
|---|---|---|---|
subscribe |
symbols, channels (flat form) and/or items |
Add the pairs; then send the current snapshot of each | subscription, then snapshot events |
unsubscribe |
as subscribe |
Remove the pairs | subscription |
snapshot |
as subscribe |
Re-send the current snapshot, subscription unchanged | snapshot events |
resume |
topic, from_seq, optional symbols |
Replay one topic after from_seq |
replayed events, or resume.rejected |
- Flat form:
{"action": "subscribe", "symbols": ["AAPL"], "channels": ["book", "trades"]}is the cross product of its two lists. - Items form:
"items": [{"symbols": ["*"], "channels": ["book"]}, {"symbols": ["AAPL"], "channels": ["depth"]}]is a list of independent rules: book for every symbol, depth for AAPL only. - An empty
symbolslist, or["*"], means every symbol. - An item may carry
resume_from: {"trades": <seq>}; onsubscribeandsnapshotthetradeschannel then replays buffered prints after thatseqinstead of sending the whole tail. - A malformed frame, or one that fails validation (an unknown
actionor channel, an unknown field), is answered with anerrorframe and otherwise ignored; the socket stays open.
Server control frames¶
type |
data |
Sent |
|---|---|---|
authenticated |
none | After authentication |
subscription |
items (effective rules, one per symbol), always (the always-on channels), rejected |
After every subscribe/unsubscribe |
resume.rejected |
topic, from_seq, reason |
A resume (or resume_from) that cannot be served |
trades.reset |
symbol |
Before a fresh trade tail, when a symbol's trades could not be replayed |
error |
message |
A control frame could not be parsed or validated |
subscription.data.rejected lists the items that contributed nothing:
reason |
Meaning |
|---|---|
no_channels |
The item named no channel |
wildcard_still_subscribed |
An unsubscribe of a named symbol that a * rule still covers |
resume.rejected.data.reason:
reason |
Meaning |
|---|---|
unknown_topic |
The topic is not a market-data topic, or there is nothing cached for it |
too_old |
from_seq is older than the replay window; a fresh tail follows |
Recovery¶
- Snapshot channels (
book,depth,auction) are self-healing: the current snapshot replaces whatever was missed. After a gap or a reconnect, sendsnapshot(orsubscribe) for the pairs concerned. Thebooksnapshot is also available without a socket asGET /api/v1/book/{symbol}, whoseseqis that of thebookframe for the same snapshot. - Trades are replayable for the gateway's replay window
(
market_data_cache_sec, 60 seconds by default):resumewithtopic: "trade.executed", the last processedseqand the symbol. Past the window the server sendstrades.resetandresume.rejected(too_old), then the current tail. A client SHOULD de-duplicate trades by theiridacross a replay. sessionandcircuit_breakerstate after a reconnect is available fromGET /api/v1/sessionandGET /api/v1/halts.
/api/v1/ws/admin/monitor¶
Every engine event this gateway receives, across all gateways, in the
standard envelope, plus admin.action. ADMIN only.
Two families are left out: public market data (book, depth, trade,
auction, auction.indicative), which an ADMIN reads on
/api/v1/ws/market-data, and the audit-only
engine.trace.{symbol}. Under load these were about half of the feed, and no
monitor renders them. Whole topics are omitted, so the per-topic seq of
everything that is sent stays contiguous.
Opening frames¶
type |
Fields | Meaning |
|---|---|---|
authenticated |
none | Authenticated |
monitor.snapshot |
ts, data: {orders, halts, gateways, last_seq, incomplete} |
Current state: the gateway's cross-gateway order view, the engine's halts and gateway roster, each active gateway's stream_seq; names of any part that timed out in incomplete |
Events¶
All private event types above, for every gateway, session and
circuit_breaker, and:
type |
Engine topic | Payload |
|---|---|---|
admin.action |
admin.action.{gateway_id} |
admin.action: one admin-gated command, who ran it and what it did |
Other engine topics (system replies addressed to any gateway, index and
reference events) also arrive here, with type derived as in
Event types. A monitor MUST ignore types it does not render.
Recovery¶
None by replay: on reconnect the snapshot is current state. History is the
audit trail (GET /api/v1/admin/orders/{order_id}/audit, pm-audit-cli).
A seq gap on a live connection means the gateway dropped events because this
client's queue was full. A dropped fill or cancel leaves an order looking
working. The monitor SHOULD repair its working-order view from
GET /api/v1/admin/orders?status=… (one call per working status) rather than
by reconnecting: the opening snapshot also carries every cached terminal order,
which under the load that caused the gap is far larger than the working set.
/api/v1/ws/admin/sim¶
pm-market-sim's state and the model's true (hidden) fair values. ADMIN only.
Frames carry type and data only, and are not numbered.
type |
data |
Sent |
|---|---|---|
authenticated |
none | After authentication |
sim.snapshot |
{available, state, values}, as GET /api/v1/admin/sim |
Opening state |
sim.state |
sim.state | Each simulator heartbeat |
sim.value |
sim.value | Each step's true values |
Each frame supersedes the previous one; a slow viewer simply misses steps.