Skip to content

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
    }
  }
}
  • category is one of account, order, position, transaction.
  • event_type is <category>.updated throughout v1. It exists so finer types can be added later without a new frame.
  • resource_id is what the event is about; account_id is the account it belongs to, and is null for an account-level event, where resource_id already is the account.
  • data is 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

  1. Remember the highest seq you have fully processed.
  2. On reconnect, subscribe to events with after_seq: <that seq>.
  3. 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_seq resume. 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.