Connecting
Three channels share one connection model:
| Channel | Carries |
|---|---|
/ws/market |
Depth, order book, trade tape, volume at price, inside quote, candles. |
/ws/risk |
Account state, positions, risk, order lifecycle, fills. |
/ws/quotes |
The canonical mark per venue and symbol. |
Endpoint: wss://api.troncharts.xyz.
Authenticate
Section titled “Authenticate”Only /ws/risk needs authentication. Market data is public, so /ws/market
and /ws/quotes accept subscribe frames with no Authenticate at all — send
one only if you want a scope attached to the connection.
- Mint a handshake token —
POST /api/auth/bootstrapreturns{ success, data }, wheredatacarriesriskEngineWssandquoteServiceWss, each{ endpoint, token, expiresAt }. There is no separate/ws/markettoken, because that channel needs none. - Open the socket.
- Send
Authenticateas the first message, usingdata.riskEngineWss.token:{ "type": "Authenticate", "token": "<ws-token>" } - You get back
Authenticated, orResult { ok: false }—token_requiredwhen the frame carries no token,token_invalidwhen the token is unknown, already consumed, or expired.
Each token is single-use and lives 60 seconds; the socket that consumes it burns it. Bootstrap again for every reconnect — the mint is rate-limited to 10 requests per minute per API key, which is ample for reconnect backoff but not for a hot loop.
Browser sessions rooted in a cookie authenticate automatically; sending the frame anyway is harmless and keeps client code uniform.
The sample below uses the TypeScript SDK, which is not yet on the public npm registry — drive the socket with any WebSocket client if you are integrating from outside the monorepo.
import { RiskEngineClient } from '@tronchartsxyz/api-client'
const ws = new RiskEngineClient({ url: 'wss://api.troncharts.xyz/ws/risk', token: wsToken, onFrame: (frame) => apply(frame),})await ws.connect()ws.send({ type: 'Subscribe', topics: ['Position-Changed', 'Order-Intent-Changed'] })Heartbeat
Section titled “Heartbeat”Send Ping every 30 seconds; the server replies Pong { serverTime }. The
socket’s idle timeout closes it after 60 seconds of inbound silence, and the
Ping resets it. (Alive resets it too, but acks with Result { ok: true }
instead of a Pong — legacy; new code should use Ping/Pong.) Both are exempt
from the per-frame rate limit, so a heartbeat is never dropped.
Subscribe to topics
Section titled “Subscribe to topics”/ws/risk pushes nothing until you subscribe. A fresh socket carries an
empty topic set, and every push frame is gated on its topic — authenticate,
subscribe to nothing, and the socket stays silent for its whole life.
{ "type": "Subscribe", "topics": ["Position-Changed", "Order-Intent-Changed"] }topics is required and must be non-empty; a Subscribe without it comes back
as Result { ok: false, error: "invalid_frame" }. The explicit Get-*
requests answer regardless of what you subscribed to — topics gate pushes, not
replies.
| Topic | Unlocks |
|---|---|
Account-State-Changed |
Account-State-Update — plus an immediate snapshot on subscribe. |
Position-Changed |
Position-Update, Position-Removed. |
Order-Intent-Changed |
Order-Intent, Order-Intent-Update. |
Order-Changed |
Order-Update. |
Open-Orders |
Open-Orders-Update. |
Fill |
Fill — one frame per execution, never coalesced. |
Trade-Changed |
Trade-Update (closed round trips). |
Balance-Changed |
Balance-Update. |
Margin-Changed |
Margin-Update. |
Funding-Changed |
Funding-Update. |
Risk-Changed |
Risk-Update. |
Blocking-Changed |
Blocking-Update. |
Account-Event-Changed |
Account-Event. |
Prop-Account-Changed |
Prop-Account-Update and the Prop-Account-* transition frames. |
Companion-Event-Changed |
Companion-Event. |
IP-Changed |
IP-Update. |
Unsubscribe { topics } removes them again.
What a socket receives
Section titled “What a socket receives”Beyond topics, a socket only receives frames for the accounts in its scope:
| Credential | Scope |
|---|---|
| API credential (bootstrap token) | The one account it is bound to. Subscribe { topics, accountId } re-scopes to another account the credential owns — an unowned id is rejected account_forbidden; one active account at a time. |
| Cookie session | Every account the signed-in user owns. |
Scope is enforced at the socket, not filtered client-side. You cannot receive another tenant’s frames, and if your access is revoked mid-connection the socket is torn down rather than left alive until the next reconnect.
Tier enforcement, per frame
Section titled “Tier enforcement, per frame”The credential tier is checked on every inbound frame, but the socket draws a single line — read versus write:
| Frame | Tiers allowed |
|---|---|
Authenticate, Ping, Alive, Resume, Subscribe, Unsubscribe, Get-*, Request-Trade-History |
readonly, liquidation, fullTrading |
Order-Sign-Result, Cancel-Order-Intent, Replace-Order-Intent, Bracket-Insert, Bracket-Modify, Bracket-Cancel, Position-Close |
liquidation, fullTrading |
A readonly credential is rejected with
Result { ok: false, error: "tier_readonly" } — the socket stays open. Admin
read-only impersonation sessions are barred from the write frames the same way.
The socket gate is coarser than the REST one: /api/v1/oms/* splits
liquidation (cancel, cancel-all, flatten) from fullTrading (everything that
opens or re-prices exposure), while /ws/risk lets any non-readonly
credential send every write frame. If you are issuing a liquidation
credential to a third-party risk system, that difference is the thing to
account for.
Frames rejected for other reasons carry their own code — invalid_json,
invalid_frame, rate_limited, account_forbidden, intent_not_found,
no_account, partial_close_unsupported, and so on. Always branch on
error, never on a message string.
Binary encoding
Section titled “Binary encoding”Append ?encoding=msgpack to the upgrade URL to receive server→client frames
as MessagePack instead of JSON. The decoded object is identical either way —
msgpack is purely a smaller, faster transport. JSON is the default, so existing
clients and the TypeScript SDK are unaffected. Client→server frames stay JSON.
Reconnecting
Section titled “Reconnecting”On a drop: mint a fresh bootstrap token, reconnect, re-authenticate,
re-Subscribe, then re-request state with the Get-* frames rather than
assuming your cached view survived. Topics live on the connection, so a new
socket starts silent again. If the server decides your view is stale it sends
Resync-Required — treat it as an instruction to drop local state and
re-snapshot.
Frames are coalesced server-side under load: you may receive one merged update instead of several. Always apply a frame as the current truth for its key, never as a delta on top of what you had.