WebSocket streaming¶
One socket carries every live stream a broker or a trader session needs. It is multiplexed: you subscribe to channels on a connection that is already open, rather than opening a socket per thing you want.
GET /api/v1/ws
This is the part of the API that the endpoint reference cannot describe — OpenAPI has no way to express a WebSocket — so this page is the contract for it.
Connecting¶
The credential rides the Sec-WebSocket-Protocol header. Not Authorization,
and never a query string.
GET /api/v1/ws HTTP/1.1
Sec-WebSocket-Protocol: broker-api.v1, bearer.<token>
<token> is either a server token or a delegated token — see
Authentication. Both authenticate through the same
pipeline the REST endpoints use.
The server accepts and echoes back only broker-api.v1. The bearer. entry is
consumed and never echoed, so your credential does not appear in the response
headers.
const ws = new WebSocket(
'wss://platform-api.example.com/api/v1/ws',
['broker-api.v1', `bearer.${token}`]
);
Why the subprotocol
A browser cannot set headers on a WebSocket, and a token in the query
string ends up in access logs, proxy logs and Referer. The subprotocol is
the only place it goes. This is also why a browser session can stream
directly even while browser-side REST calls are not yet available.
A rejected upgrade is a plain HTTP response, not an accepted-then-closed socket — much easier to diagnose:
| Condition | Response |
|---|---|
broker-api.v1 was not offered |
400 invalid_request |
No bearer.<token> was offered |
401 unauthorized |
| Token invalid, expired, revoked, or from a disallowed address | 401 unauthorized |
Every one of those carries an X-Request-Id.
The three channels¶
| Channel | Carries | Scope | Delivery |
|---|---|---|---|
quotes |
Raw price ticks | stream:quotes |
Conflated |
account |
Live balance, equity, margin and open profit for one account | stream:account |
Conflated |
events |
Account, order, position and transaction lifecycle events | stream:account |
Durable |
Two delivery classes¶
Conflated (quotes, account) buffers latest-wins per key and flushes
about three times a second. A slow consumer sees fewer frames, never a
growing backlog, and never a stale value — the right trade for prices and live
figures, where only the current number matters. These frames carry no seq.
Durable (events) is at-least-once and ordered. Every frame carries seq,
and a reconnecting client resumes from it. Because it is at-least-once, you may
see an event you already processed after a reconnect: key your side effects on
the envelope's id, which is stable for a given event.
Client frames¶
All frames are JSON text. Fields the server does not recognize are ignored, so
a new optional argument is never a breaking change. An unknown type or
channel produces an error frame and leaves the socket open. A binary frame
is refused the same way.
subscribe¶
{"type": "subscribe", "channel": "quotes"}
{"type": "subscribe", "channel": "account", "resource_id": 4210001}
{"type": "subscribe", "channel": "events", "after_seq": 918}
| Field | Required | Meaning |
|---|---|---|
channel |
yes | quotes, account or events |
resource_id |
for account |
The customer account id. Optional on events, where it narrows the feed to one account. |
after_seq |
no | events only: resume after this sequence number. |
Account subscriptions accumulate: send one subscribe per account and the
socket carries all of them.
A fresh subscription tails live
Omit after_seq and an events subscription delivers only what happens
after you subscribe — the live tail, which is what a fresh connection
almost always wants. To replay past events instead, send after_seq: pass
the last seq you processed to resume from there, or 0 to receive the
whole retained window from the beginning. The window is 7 days; see
Reconnecting and resuming.
unsubscribe¶
{"type": "unsubscribe", "channel": "events"}
Unsubscribing a channel drops all of it — unsubscribe on account stops
every account you had subscribed, not one of them. Re-subscribe the ones you
still want.
ping¶
{"type": "ping"}
Answered with a pong. Use it when your client library hides WebSocket control
frames from application code.
Server frames¶
subscribe_ack¶
{"type": "subscribe_ack", "channel": "events", "resource_id": null, "seq": 918}
Confirms a subscribe, and also a unsubscribe. seq echoes the after_seq
you asked to resume from, and is null for the conflated channels and for a
fresh events subscription. The cursor you should actually remember is the
seq on each event frame.
ticks — channel quotes¶
{"type": "ticks", "ts_ms": 1753634000123, "ticks": [[11119, "1.0842", "1.0843"]]}
A coalesced batch, each entry positionally [instrument_id, bid, ask]. Prices
are raw: no markup, and not specific to any trading terms. The instrument
roster is derived on the server from your firm's instrument packs, so there is
no list of ids to send and none to keep in sync.
account_pnl — channel account¶
{
"type": "account_pnl",
"pnl": {
"tenant": 4210001,
"revision": 771,
"ts_ms": 1753634000123,
"accounts": [
{
"account_id": 4210044,
"currency": "USD",
"balance": "10000.00",
"credit": "0",
"equity": "9874.60",
"margin": "2000.00",
"free_margin": "7874.60",
"open_pnl": "-125.40",
"open_pnl_available": true
}
]
}
}
The customer account's latest figures, with one entry per trading account that
currently has open positions. tenant is the customer account id; account_id
inside each entry is the trading account.
Money is a decimal string, never a floating-point number. Watch
open_pnl_available: when it is false, at least one position could not be
priced and equity has fallen back to balance only — do not treat that number
as tradeable equity.
event — channel events¶
{
"type": "event",
"seq": 919,
"event": {
"id": "evt_4210001_771_88231",
"seq": 919,
"category": "transaction",
"event_type": "transaction.updated",
"firm_workspace_id": 30100,
"brand_id": 30110,
"resource_id": 88231,
"account_id": 4210001,
"occurred_at": 1753634000123,
"data": {
"amount": "100.50",
"currency": "USD",
"method": "Deposit",
"trading_account_id": 4210044,
"approved": true,
"processed": true
}
}
}
categoryis one ofaccount,order,position,transaction.event_typeis<category>.updatedthroughoutv1. It exists so finer types can be added later without a new frame.resource_idis what the event is about;account_idis the account it belongs to, and isnullfor an account-level event, whereresource_idalready is the account.datais a published projection, not the stored record. Adding a field internally does not widen it.
This is the same envelope a webhook delivers, with the same
seq.
error¶
{"type": "error", "code": "forbidden", "message": "this account is not visible to the credential"}
Non-fatal — the socket stays open. Branch on code:
code |
Meaning |
|---|---|
forbidden |
The credential may not see this channel, or this particular record. |
bad_request |
Malformed JSON, a binary frame, or an unknown type or channel. |
unavailable |
The price or figures service is not available right now. Retry. |
internal |
A server-side failure. Retry. |
pong¶
{"type": "pong"}
Authorization happens per subscribe¶
Holding a scope is not enough to stream a record. Every account-scoped
subscribe is authorized against the exact resource_id in the frame, at the
moment of the subscribe — a socket can outlive the access that opened it.
| Channel | Additional gate |
|---|---|
quotes |
None. The roster is server-derived, so there is no id on the wire. |
account |
The named account must be inside the credential's firm, and inside its brand when the credential is brand-bound. A delegated token may name only its own account. |
events |
The same gate when resource_id is given. |
The firm-wide events feed — a subscribe with no resource_id — is
available only to a credential whose reach is the firm. A brand-bound
credential and a delegated token must both name a resource_id; without one
they receive forbidden. The event feed is filtered by firm, so a
narrower credential has to say which account it wants and be checked against it.
Every scope is additionally clamped by what your firm currently permits: a scope granted to a credential whose firm no longer permits it grants nothing.
Reconnecting and resuming¶
- Remember the highest
seqyou have fully processed. - On reconnect,
subscribetoeventswithafter_seq: <that seq>. - You receive everything after it, in order.
The replay window is 7 days. If you were disconnected for longer than that,
resume is not possible: re-sync over the bulk reads, then
subscribe fresh with no after_seq.
Conflated channels need no resume. Re-subscribe, and the next flush carries the current value.
Two behaviors worth designing around:
- A connection that stops being read is closed after about five seconds. Read frames promptly, and do your work off the socket.
- Events you have not acknowledged by processing are redelivered on your next
after_seqresume. That is the at-least-once contract working as intended, not a fault.
Versioning¶
broker-api.v1 is frozen. A change that would break a working client mints
broker-api.v2, and both are offered and accepted side by side through the
overlap. Additive frames and additive optional fields are not breaking, so
always ignore frame types and fields you do not recognize.
Related¶
- Authentication
- Webhooks — the same events, delivered to your own endpoint
- Bulk reads — re-syncing after a long disconnect