Concepts

Authentication planes

Five credential types, deliberately separated. The rule that matters: only the room-scoped session token and the directory session ever reach an end-user device.

CredentialReachesScopeClient-safe?
ak_… server key /v1/server/* One tenant + environment, scope-gated No
as_… account session /v1/account/* One customer account (billing, keys, apps) No
Operator HMAC /internal/v1/* Whole deployment Never
du_… directory session /v1/directory/* One end-user in one tenant Yes
Session token (JWT) /v1/rooms/*, both sockets, E2EE, push One principal, one room, minutes Yes

Room-scoped session tokens #

The credential your app actually holds. Signed with Ed25519, it authorises one principal, in one room, for a limited time, and carries granular permissions (read history, send, publish media, subscribe to media).

  • Default lifetime 600 s; maximum 1 h. Build re-minting into your client from day one.
  • Signing keys rotate with overlap, so a rotation never invalidates tokens already in flight.
  • device_id distinguishes the same user's devices — required if one person should be able to join a call from a phone and a laptop simultaneously.
  • Mint via POST /v1/server/session-tokens (Path A) or GET /v1/directory/conversations/{room_id}/token (Path B). Both return the same bundle: token, expiry, both socket URLs, and ICE servers.

Presenting it

REST calls use a normal bearer header. WebSockets use the subprotocol — never the query string:

Sec-WebSocket-Protocol: chatbox, bearer.<access_token>

Putting a credential in a URL leaks it into access logs, proxy logs and Referer headers. The subprotocol is the reason this API does not accept ?token=.

Customer server keys (ak_) #

Your backend's credential for /v1/server/*: provision rooms, manage members, mint tokens, send as a principal, read usage, manage webhooks.

  • Tenant-pinned. The tenant comes from the key. A tenant_id in a request body is rejected, not honoured.
  • Scope-gated. Keys carry scopes. Issue the narrowest set that works — but know what the default is (below), because a missing scope is a 403 at call time, not an error at key creation.
  • Environment-isolated. A test key cannot see live data.
  • Rotatable. POST /v1/account/api-keys/{key_id}/rotate swaps the secret atomically; revoke with DELETE.
  • Verified in constant time, and rate-limited per key.

Scopes — and the default you probably don't want

POST /v1/account/api-keys requires environment and accepts an optional scopes array. Omit scopes and you get a deliberately narrow default that is missing several things a real backend needs:

ScopeNeeded forIn the default?
rooms:read / rooms:writeProvision and read roomsYes
members:writeAdd / remove membersYes
tokens:mintMint client session tokensYes
messages:writeServer-side sendsYes
usage:readGET /v1/server/usageNo
webhooks:manageAll /v1/server/webhooks routesNo
rooms:deleteDELETE /v1/server/rooms/{id} (erasure)No
data:erasePrincipal erasureNo
Symptom to recognise: everything works until you call usage, webhooks or a delete, and you get 403 forbidden: missing scope webhooks:manage. The fix is to re-create (or rotate) the key with the scopes listed explicitly — scopes are set at creation.
If an ak_ key ever ships inside a mobile app, treat it as public. Rotate it and move token minting server-side, or switch to the directory path where clients are supposed to talk to us directly.

Account sessions (as_) #

The self-serve control surface: sign up, log in, manage API keys, provision directory apps and set their push credentials. Opaque bearer, hashed at rest, 30-day lifetime. This is a dashboard credential — it belongs in your admin tooling, not in a product build.

Directory sessions (du_) #

The end-user credential on the turnkey path, obtained by verifying a phone number. Opaque, hashed at rest, long-lived, swept when expired. It authorises the directory surface for exactly one user — profile, contacts, conversations, devices — and is the thing you exchange for room tokens.

Operator HMAC (/internal/v1/*) #

The deployment-wide control plane, used by whoever runs the infrastructure. Requests are signed over method, path, body digest, timestamp and a single-use nonce:

x-chatbox-key-id:    k1
x-chatbox-timestamp: 1757500000
x-chatbox-nonce:     3f2b…            # single-use, within the time window
x-chatbox-signature: <hex HMAC-SHA256 over method, path, body digest, timestamp, nonce>

Replays are refused inside the time window, at most two signing keys are active at once (so rotation is possible without a gap), and the plane is rate-limited per key. If you are integrating as a customer you should never need this — everything you need is on /v1/server/*.

Getting it right #

  • Clients hold only session tokens or du_ sessions.
  • Re-mint on expiry; a 4401 socket close means "token expired, get another".
  • Never move the tenant into a request body — it comes from the credential.
  • Use test keys for staging so a mistake cannot touch live rooms.
  • Cross-tenant access is refused server-side; do not rely on client-side scoping, and do test that it is refused.