Skip to main content
WebSocket delivery is at-least-once during normal operation. On disconnect or reconnect you must rebuild subscriptions and reconcile against REST.

sid lifecycle

sids are connection-local. They are NOT preserved across:
  • Network drops
  • Client-side WebSocket.close() + reopen
  • Server-side restarts
After every reconnect, run a fresh subscribe for everything you need. Old sids from the previous connection are meaningless.

Heartbeat

Send ping every 25 seconds (or whenever your TCP connection feels quiet):
{ "id": 999, "cmd": "ping" }
Server responds:
{ "id": 999, "type": "pong", "ts": 1776949200000 }
If you don’t get a pong within ~5 seconds, treat it as a dead connection — close and reconnect. ts is the server clock in ms. Use it as a clock-skew probe if you care about latency-sensitive timing on the client.

Reconnect strategy

let reconnectDelay = 1000;

function connect() {
  // Node / server: pass X-Api-Key as a header.
  // Browser:       use `${WSS}/ws/user?key=<encodeURIComponent(apiKey)>`
  //                because the WebSocket(…) ctor can't set custom headers.
  const ws = new WebSocket(`${WSS}/ws/user`, {
    headers: { 'X-Api-Key': apiKey },
  });

  ws.onopen = () => {
    reconnectDelay = 1000;
    // Always re-subscribe from scratch on a new connection.
    ws.send(JSON.stringify({
      id: nextId(),
      cmd: 'subscribe',
      params: { subscriptions: mySubscriptions },
    }));
  };

  ws.onclose = () => {
    // Drop sid → handler map; on next open you'll re-subscribe and
    // get fresh sids.
    sidHandlers.clear();
    setTimeout(connect, reconnectDelay);
    reconnectDelay = Math.min(reconnectDelay * 2, 30_000);
  };

  ws.onmessage = handleMessage;
}
After a successful reconnect-and-resubscribe, reconcile state from REST — don’t trust the WS to have replayed missed events from before the disconnect.

Channel-specific reconnect / resync

ChannelOn reconnect, also fetch
user_activityGET /api/orders/open + GET /api/me/balances + GET /api/me/positions
token_tradesGET /api/markets/{symbol}/trades?before=<now> to fill the visible window
token_bookre-subscribe → book_snapshot is pushed automatically
condition_statusGET /api/markets/{symbol} for current status
systemGET /api/platform/status

Orderbook resync

token_book deltas carry seq and prevSeq. Track the last seq you applied per tokenId. If the next delta arrives with prevSeq != lastSeq:
if (delta.prevSeq !== lastSeq[delta.id]) {
  // Gap — drop local book for this token and force a fresh snapshot.
  unsubscribe(sid);
  subscribe({ channel: 'token_book', ids: [delta.id] });
}
A clean unsubscribe/subscribe is the only way to force a fresh book_snapshot mid-connection — the gateway does not support an explicit “resync” command.

Auth on reconnect

Auth is validated at handshake only. If a key rotates mid-session, the open socket keeps working with the original credential until it closes. Close + reopen to pick up the new one.
ReasonClose codeWhat to do
api_key_revoked / api_key_bad_secret4401 <reason>Rotate the key on the admin panel; don’t auto-retry a revoked key.
api_key_expired4401 api_key_expiredIssue a fresh key.
api_key_suspended4401 api_key_suspendedPartner is suspended. Contact ops.
api_key_unknown_key / api_key_bad_format4401 <reason>Token on disk is stale or malformed. Refresh from your secrets manager.
api_key_ip_denied4401 api_key_ip_deniedCaller IP isn’t in the key’s allowlist.
api_key_auth_disabled / api_key_auth_unconfigured4401 <reason>Server-side misconfig (feature flag off or pepper missing). Back off and alert.
forbidden origin1008 forbidden originWebSocket Origin header not in the allowlist.
Revoking a key closes every live socket bound to that keyId immediately via a Redis apikey:invalidate pub/sub — clients see the 4401 api_key_revoked frame within milliseconds.