Private Channels
Private channels stream account-scoped events for one 20-byte engine account. Issue a short-lived session token over HTTP, then present it in the first WebSocket frame. Never send owner-wallet signatures or HMAC secrets over the socket.
Channels
| Channel | Purpose | Required credential scope |
|---|---|---|
account | Balances, holds, releases, positions and the nonce window (nonceFloor, nonceWindow, nonceUsedInWindow, nonceHoles) | read |
userEvents | Account lifecycle events (settlement, funding credits, admin actions) | read |
orderUpdates | Durable order outcomes, amendments, cancels, fills and terminal states; admission ACKs remain on the order-entry transport | read |
userFills (alias fills) | Executions with price, quantity, fee (feeMicro, fee ppm) and liquidity role | read |
dropCopy | Execution reports from the same durable source as FIX/BSL, in a JSON envelope | drop_copy |
A subscription on a channel whose scope the token's credential lacks returns
error 1002.
1. Issue a token
POST /api/v1/ws/token
Content-Type: application/json
SC-Auth-Version: 2
SC-Key: <apiKeyId>
SC-Nonce: <fresh tuple nonce>
SC-Timestamp: <unix ms>
SC-Passphrase: <apiPassphrase>
SC-Signature: <hmac>
{ "account": "0x2222222222222222222222222222222222222222", "ttlMs": 180000, "clientId": "mm-quote-engine-1", "scope": "read" }
scope is read (default) or drop_copy. The request can also be
wallet-signed (agentId, nonce, signature together) instead of using
SC-* headers. Response:
{ "ok": true, "account": "0x2222…", "scope": "private_ws:read", "tokenType": "Bearer",
"token": "spws1.…", "issuedAtMs": 1788558400818, "expiresAtMs": 1788558580818, "ttlMs": 180000, "ipBound": false, "clientId": "mm-quote-engine-1" }
Token issuance is rate-limited on its own; on 429 respect Retry-After and
reuse the token you already hold.
The issuing credential must carry read scope: an institutional_agent HMAC
credential works (verified 2026-09-13); an agent key without read is
answered with permission_denied. The URL, token claim, and current agent
mapping must resolve to the same 20-byte engine account. For a managed trading
account, the agent's 32-byte control-plane accountIdHex is first resolved to
that record's engineAccountHex; the token does not silently fall back to the
owner's main account.
The candidate routes private-token use through one authorization decision. It rechecks the current agent, every claimed scope, account/subaccount binding, credential id and API-key pairing, credential ownership, and revocation state. A token that was valid when issued can therefore fail after its agent or credential is revoked. Issue a new token only after correcting authorization; reconnecting with the same revoked credential cannot restore access.
Unauthenticated token failures return a stable, sanitized authorization error. They do not expose credential, signature, account-mapping, or revocation internals. Log the public code, socket close code, request/correlation id when present, and UTC time; never log the token itself.
Centralized claim and revocation revalidation is implemented in the candidate source, but production rejection and revocation readback has not yet been performed. Treat the behavior as available only after deployment and an authenticated revoke-then-reconnect acceptance check.
2. Authenticate on the socket
The token goes into the first frame, not into a header or query string:
{ "id": "auth-1", "method": "auth", "authorization": "Bearer spws1.…" }
{ "type": "ack", "channel": "auth", "data": { "ok": true, "account": "0x2222…", "scope": "read", "tokenType": "ws_session_token", "agentId": "agt_…", "credentialId": "…" }, "id": "auth-1" }
Authenticate within authDeadlineMs from the session frame; otherwise the
server closes with 4401. Subscribing before auth returns error 1001.
3. Subscribe with a cursor
{ "id": "sub-1", "method": "subscribe", "subscription": { "type": "orderUpdates", "account": "0x2222…", "fromSeq": "4810", "cursorNamespace": "execution-v1" } }
{ "id": "sub-2", "method": "subscribe", "subscription": { "type": "userFills", "account": "0x2222…", "fromSeq": "913" } }
account, when present, must equal the account in the URL. Send fromSeq as
a u64 decimal string. On orderUpdates it must be accompanied by
cursorNamespace: "execution-v1", otherwise the server answers error 1004
(verified 2026-09-13). With fromSeq the server replays retained events after
that sequence, then sends snapshot_end on the channel. Right after a deploy
the server can answer resume_required (1005) even to a recent cursor;
rebuild from REST and resubscribe. Without a cursor, execution channels bootstrap
with the latest durable event (if any), then continue live. This is not an
open-order inventory: use the account/orders snapshot for inventory.
Persist the last processed seq per channel and pass it on reconnect.
Sequence scope
orderUpdates, userFills and dropCopy use the account's execution stream;
account and userEvents use its account snapshot stream. Deduplicate by
(channel, seq): a replay after a process crash can legitimately redeliver
the last events you already handled. Never transfer a cursor between these
stream domains. RESULT-owned execution identifiers are sparse and exceed
JavaScript's safe integer range. Large integers are decimal strings on the
private wire; store them losslessly. Never convert them with Number().
Order-stream migration and payload
Every resumed orderUpdates subscription must include
"cursorNamespace":"execution-v1". The server rejects unversioned resume
cursors: older releases used an unrelated account-snapshot cursor on this
channel. Discard those old cursors and rebuild the order inventory; do not
merely label an old cursor with the new namespace. The subscription ACK and
rotation journal include the new namespace.
orderUpdates streams one event per execType transition, so size the
consumer from measurements of your own order lifecycle and offered load.
userFills sends fills and checkpoint frames only — never empty frames.
An order event's data.orders contains the normalized execution report,
including orderId, clientOrderId, execId, execType, ordStatus,
leavesQty, cumQty, lastQty and available pricing/reject metadata. Missing
legacy metadata remains null; it is not fabricated from a current snapshot.
Fills include the authenticated participant's role and fees. Counterparty
identity and counterparty order IDs are excluded.
data.updateType is delta, not a replacement inventory. Apply the known
fields to that order. A null quantity means the durable record does not
contain that cumulative value; preserve your previously observed value rather
than replacing it with zero. In particular, a cancel has leavesQty: "0"
and lastQty: "0", while its cumQty can be unknown. The command-local
fill count in the embedded raw result is not a lifetime cumulative fill count.
After a 30-unit partial fill, a quantity-down amend and cancel therefore retain
30 cumulative filled units in the client while closing the remaining quantity.
Filtered progress and predecessor links
userFills emits an event only for a fill. Consecutive durable records with
no fill are represented by one checkpoint, bounded by the replay page:
{"type":"checkpoint","channel":"userFills","seq":19,"prev_seq":15,"data":{"cursorMode":"durable-predecessor","reason":"filtered_non_fill","fromSeq":16,"throughSeq":19}}
Execution frames carry data.cursorMode: "durable-predecessor". Their
prev_seq links to the last event/checkpoint delivered on that subscription,
including sparse RESULT cursors. A larger numeric jump is not itself loss.
If the predecessor is ahead of the last processed cursor, request the range
after the processed cursor through that predecessor. Do not advance past an
unprocessed fill. A checkpoint certifies filtering of records actually read;
gap_fill and resume_required still mean unavailable history and require
resynchronization. Persist checkpoint progress after earlier callbacks finish;
do not send checkpoints to the trading strategy as fills.
Delivery model
Private events are delivered ring-first: reads are served from an
in-memory, per-account replay buffer before falling back to SQL. The buffer is
bounded by an event count and a retention window, so it covers the most recent
events per account. When a requested range is not fully covered, the server
backfills from SQL and resumes from the ring; sequence numbers and ordering
are identical either way: the durable authority assigns the cursor (RESULT
for its activated namespace, or the legacy database allocator). If your cursor is older than the
retention window you receive resume_required instead of a silent gap.
Recovery frames
| Frame | Meaning | Client action |
|---|---|---|
snapshot_end | Replay for that channel is complete | Switch to live processing |
checkpoint | A contiguous delivered range contains no fills | Validate predecessor, persist progress after earlier callbacks; no strategy fill callback |
resume_required / gap_fill | Cursor outside retention; data carries oldestAvailableSeq and newestAvailableSeq | Rebuild from REST (/api/v1/accounts/{account}/orders, /fills, /api/v1/bsl/accounts/{account}/executions?cursor=), then resubscribe from oldestAvailableSeq or later |
rotate | Planned disconnect ahead; includes the resubscribe journal | Reconnect and replay the journal |
warning with reason: slow_consumer | Your consumer is behind | Drain faster; the server closes with 4408 if it persists |
SDK
@sentico-labs/sdk ships ReliablePrivateStream, which implements token
refresh, auth, cursor persistence, replay dedup, rotation and reconnect:
const stream = client.ws.reliablePrivate({
account,
tokenProvider: () => client.trading.issuePrivateWsToken(account, { ttlMs: 180_000 }),
subscriptions: [{ type: "orderUpdates", cursorNamespace: "execution-v1" }, { type: "userFills" }],
onEvent: (frame, ctx) => strategy.apply(frame, ctx), // idempotent by (channel, seq)
onResyncRequired: (details) => strategy.rebuildFromRest(details),
});
await stream.start();
Security notes
- Keep tokens short-lived; the server clamps
ttlMsto its configured range. - Separate read-only stream credentials from trading credentials where possible.
- Treat a private stream disconnect as an operational alert for unattended trading.