Sessions & connection

One opaque token minted at login, one WebSocket for the whole session. This chapter covers the token's lifecycle and every way a connection ends — drops, second tabs, expiry, and inferred logout.

Mint a token at login

  1. 👤User logs in
  2. 🔑platform callsPOST /v1/sessions
  3. ⚙️engine mints session token (Redis + TTL)
  4. 📨hand token to that user's client
  5. 🔌SDK dials onceclient.connect()

The client's socket credential is an engine-issued session token — opaque, stored in Redis with a TTL, revocable. No JWT, no JWKS, no signing on your side: the engine verifies its own tokens.

FieldTypeRequiredDefaultDescription
userIdstringyes—platform user id the token is minted for (opaque to the engine)
http
POST /v1/sessions
X-Service-Auth: <secret>

{ "userId": "user_123" }     // userId ONLY — no rating rides along with a credential

# 201 — hand the token to that user's client; it opens one socket for the whole session
{
  "success": true,
  "response": "success",
  "responseCode": 201,
  "data": { "token": "s_9f3c...", "expiresAt": "2026-07-21 11:00:00.000" }
}

# This route FAILS CLOSED: unlike the other /v1 routes it refuses to serve when the
# service secret is unconfigured — a missed config would let anyone mint a token for
# any userId. Minting a second token does not invalidate the first; the SOCKET is what
# enforces one-per-user.

⚠ A session token is multi-use for its TTL. If it leaks, it grants account access until expiry — keep the TTL short and re-mint at login rather than issuing long-lived tokens. Do not ask for IP binding: mobile networks rotate IPs and it would evict legitimate users far more often than it would stop a thief.

Connect — one socket per session

The client opens one socket right after login — not when a game starts. The lobby, the player's own game, and anything they spectate all ride on it; every game-scoped message names its room.

ts
// The handshake — one socket per login session. The token travels in the
// subprotocol list, never as a query param (a URL lands in proxy & CDN logs), and
// it is validated BEFORE the upgrade, so an unauthenticated socket never exists:
new WebSocket(url, ["inachess.v2", token]);   // what the SDK does under the hood

import { EngineClient } from "@inachess/sdk";
const client = new EngineClient({ url: "wss://engine.example.com/ws", token });
client.connect();   // same call for the first dial AND for every reconnect
client.close();     // close + stop reconnecting

One socket per user. A second connection for the same userId closes the first with code 4002 — newest wins, so a user whose browser crashed is never locked out of their own account.

Liveness. The engine pings at the session level (and per room, as a clock resync) with a read deadline; the SDK answers automatically. A half-open connection is detected and closed rather than lingering.

Reconnect, expiry, and second tabs

ts
// Reconnect is dialling again with the SAME session token — no REST call;
// the engine finds your game by userId:
client.connect();   // the SDK does this automatically after a drop (backoff, same token)

// Restored automatically: your own game — game_state is pushed to the new
// socket, match identity included (variant, timeControl, players), so nothing
// from the original match_found needs to have survived the reload.
// NOT restored: spectate subscriptions and a queue entry — re-request those.
client.on("open", () => {
  if (wasWatching) client.subscribe(watchedRoom);
  if (wasQueued)   client.queue(lastQueueOpts);
});

// Close code 4001 — the token expired. The SDK stops retrying and emits authExpired:
client.on("authExpired", async () => {
  const fresh = await platformLogin();     // your flow → new POST /v1/sessions
  reconnectWith(fresh);
});

// Close code 4002 — the user's own NEWER socket replaced this one (second tab).
// The SDK stops retrying — do NOT redial, or the two tabs kick each other forever.

Presence is a subscription, not a live socket. The session socket stays open while the user browses elsewhere, so “connected” never implies “at the board”. Subscribing to a room means at the board; unsubscribing means left it. Everything presence-related (the ready gate, tournament grace) reads the subscription. More in Finding a game.

What can go wrong

SymptomCause & fix
Handshake refusedBad or expired token. Tokens are validated before the WebSocket upgrade, so failure surfaces as a refused handshake, never as an error message on an open socket. Re-mint via your login flow.
Socket closed with code 4001The session token expired mid-session. Stop retrying with it — ask the platform for a fresh token, then reconnect. The SDK suppresses auto-reconnect on 4001 and emits authExpired.
Socket closed with code 4002The user opened a newer connection (second tab / device) — newest wins, by design. Never auto-retry on 4002. The user's game is unaffected: the new socket re-attached to it by userId.
Dropped mid-game, clock kept runningCorrect behaviour — clocks are server-authoritative and never pause for a disconnect. The SDK auto-reconnects with the same token and the engine pushes game_state to the new socket, match identity (variant, timeControl, players) included.
User closed the app; no logout call existsLogout is inferred, not signalled. A session disconnected for more than ~5 minutes is reaped. If you need an immediate cut (ban, forced logout), delete the session server-side.