Skip to main content

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​

ChannelPurposeRequired credential scope
accountBalances, holds, releases, positions and the nonce window (nonceFloor, nonceWindow, nonceUsedInWindow, nonceHoles)read
userEventsAccount lifecycle events (settlement, funding credits, admin actions)read
orderUpdatesDurable order outcomes, amendments, cancels, fills and terminal states; admission ACKs remain on the order-entry transportread
userFills (alias fills)Executions with price, quantity, fee (feeMicro, fee ppm) and liquidity roleread
dropCopyExecution reports from the same durable source as FIX/BSL, in a JSON envelopedrop_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.

Candidate availability

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.

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​

FrameMeaningClient action
snapshot_endReplay for that channel is completeSwitch to live processing
checkpointA contiguous delivered range contains no fillsValidate predecessor, persist progress after earlier callbacks; no strategy fill callback
resume_required / gap_fillCursor outside retention; data carries oldestAvailableSeq and newestAvailableSeqRebuild from REST (/api/v1/accounts/{account}/orders, /fills, /api/v1/bsl/accounts/{account}/executions?cursor=), then resubscribe from oldestAvailableSeq or later
rotatePlanned disconnect ahead; includes the resubscribe journalReconnect and replay the journal
warning with reason: slow_consumerYour consumer is behindDrain 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 ttlMs to 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.