Skip to content

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.

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.

  1. Mint a handshake token — POST /api/auth/bootstrap returns { success, data }, where data carries riskEngineWss and quoteServiceWss, each { endpoint, token, expiresAt }. There is no separate /ws/market token, because that channel needs none.
  2. Open the socket.
  3. Send Authenticate as the first message, using data.riskEngineWss.token:
    { "type": "Authenticate", "token": "<ws-token>" }
  4. You get back Authenticated, or Result { ok: false }token_required when the frame carries no token, token_invalid when 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'] })

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.

/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.

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.

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.

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.

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.