Connecting
Three channels share a connection model — and not a subscribe vocabulary:
| Channel | Carries | How you subscribe |
|---|---|---|
/ws/market |
Depth, order book, trade tape, volume at price, inside quote, candles. | Subscribe-Depth, Subscribe-Tape, Subscribe-BBO, Subscribe-Candles, … one frame type per feed |
/ws/risk |
Account state, positions, risk, order lifecycle, fills. | Subscribe { topics: [...] } |
/ws/quotes |
The canonical mark per venue and symbol. | Subscribe-Quote, Subscribe-Quotes-Bulk, Get-Quote |
Endpoint: wss://api.troncharts.xyz. POST /api/auth/bootstrap also hands you
a fully-qualified endpoint per channel and it may name a different host
(today wss://app.troncharts.xyz/ws/risk); both resolve to the same service.
Prefer the endpoint from the response over hard-coding either host.
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>" } -
Wait for the reply before sending anything else. Authentication resolves asynchronously, and the socket keeps processing frames while it does — so a client that pipelines
PingorSubscribeimmediately afterAuthenticategets them answerednot_authenticatedfirst, and seesAuthenticatedarrive after the rejections. Nothing is broken when that happens; the frames simply have to be sent again. -
The reply is
Authenticated, carrying the scope the socket ended up with:{"type": "Authenticated", "ok": true, "seq": 1,"scope": {"sessionId": "apiclient:<id>","accountId": "<the account this socket is bound to>","walletAddress": null,"isAdmin": false}}scope.accountIdis worth reading rather than assuming — it is the account whose frames you will receive.On failure it is
Result { ok: false }with one of three codes:token_requiredwhen the frame carries no token,token_invalidwhen the token is unknown, already consumed, or expired, andnot_authenticatedon any other frame that arrives before authentication has completed.
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; any WebSocket client drives the socket just as well.
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. |
Account-Suspension-Changed |
Account-Suspension-Update — sent the moment the account is blocked, before its positions are flattened. |
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.