# Connecting

Three channels share one connection model:

| Channel | Carries |
| --- | --- |
| [`/ws/market`](https://docs.troncharts.xyz/docs/realtime/market/) | Depth, order book, trade tape, volume at price, inside quote, candles. |
| [`/ws/risk`](https://docs.troncharts.xyz/docs/realtime/risk/) | Account state, positions, risk, order lifecycle, fills. |
| [`/ws/quotes`](https://docs.troncharts.xyz/docs/realtime/quotes/) | The canonical mark per venue and symbol. |

Endpoint: `wss://api.troncharts.xyz`.

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

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`:
   ```json
   { "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](https://docs.troncharts.xyz/docs/sdks/typescript/) — drive the socket with any WebSocket client
if you are integrating from outside the monorepo.

```ts
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

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

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

```json
{ "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

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

The credential [tier](https://docs.troncharts.xyz/docs/auth/scopes/) 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

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

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.