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
- 👤User logs in
- 🔑platform calls
POST /v1/sessions - ⚙️engine mints session token (Redis + TTL)
- 📨hand token to that user's client
- 🔌SDK dials once
client.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.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
userId | string | yes | — | platform user id the token is minted for (opaque to the engine) |
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.
// 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 reconnectingOne 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
// 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
| Symptom | Cause & fix |
|---|---|
| Handshake refused | Bad 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 4001 | The 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 4002 | The 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 running | Correct 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 exists | Logout 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. |