Conventions & errors
2. Conventions#
JSON everywhere#
- Request bodies are JSON with
Content-Type: application/json(otherwise 415unsupported_media_type). Routes with a JSON body need one even when every field is optional: send{}. - Every success is HTTP 200 with a JSON body. Routes that return nothing return
{}(calledAckbelow). - Every 4xx / 5xx carries the error body.
- Field names are
snake_case. Key order means nothing. - Forward compatibility: ignore fields you do not know (a newer server may add fields).
Optional fields may be absent or
null; treat both the same. Unknown enum values (a roomkind, a storagewriterule), unknown error codes and unknown push kinds must not break a client.
Headers#
| Header | Direction | Meaning |
|---|---|---|
Authorization: Bearer <access token> |
request | the caller (see Accounts and authentication) |
Content-Type: application/json |
request | on every request with a body |
x-net-backend-protocol: 1 |
both | the protocol version (optional on requests) |
x-request-id |
answer | the request's id; quote it when reporting a problem |
ETag: "3" |
answer | a storage object's version (single-object GET / PUT) |
If-Match: "3" / If-None-Match: * |
request | conditional storage writes (alternative to if_version) |
Retry-After: <seconds> |
answer | on some 429 / 503 answers |
Allow |
answer | on 405, the allowed methods |
Ids#
User, room, message and audit ids are integers (64-bit signed, plain JSON numbers). They are
assigned by the database, start at 1 and grow, so they fit JavaScript numbers in practice. Ids
from other systems travel as strings (a Steam id: "76561197960287930"). WebSocket request
ids are chosen by the client (unsigned integers; keep them below 2^53 in JavaScript).
Timestamps#
Unix time in milliseconds (UTC), a plain JSON number: 1790000000000. In JavaScript:
new Date(ms); Date.now() gives the same unit.
Pagination#
Lists use cursors:
- Request (HTTP query string, or the same fields in a WebSocket request's
data):cursor(optional; the previous page'snext_cursor) andlimit(optional; default 50, clamped to 1–100). - Answer:
{"items":[…],"next_cursor":"…"}.next_cursoris absent (ornull) on the last page. - Cursors are opaque strings of at most 512 bytes: pass them back unchanged (URL-encoded in a query string), never build or parse one.
GET /v1/chat/rooms?limit=20
GET /v1/chat/rooms?limit=20&cursor=<next_cursor of the previous page, URL-encoded>
Text rules#
The server refuses text that could impersonate or break layouts. Requests that break a rule get
422 validation_failed with the field named in details.fields.
| Field | Rule |
|---|---|
a plain local@domain (no display name, no angle brackets, comments, quoted local parts or address literals), at most 254 bytes; unique per server, case-insensitive |
|
| password | 10 characters to 128 bytes (UTF-8), no control characters, not only white space |
| display name | at most 32 characters, trimmed; no control characters and no invisible or direction-changing characters (bidi controls, zero-width characters, BOM, …) |
| storage collection / key | 1–128 bytes of A-Z a-z 0-9 _ - ., starting with a letter or digit |
| chat room key | 1–64 bytes of a-z 0-9 _ - ., starting with a letter or digit |
| chat text | at most 500 characters (server setting); line breaks (\n) and tabs allowed, \r and other control characters refused; invisible / direction-changing characters refused except the zero-width joiner and non-joiner (emoji need them); must show something visible; at most 8 combining marks in a row |
| chat nonce | 1–64 visible ASCII characters |
| role | [a-z][a-z0-9_.-]*, at most 64 bytes |
CORS (browser pages on another origin)#
CORS is off unless the server operator sets cors.allowed_origins (for example
["https://your-site.example"], or ["*"]). With it on, the server allows the methods GET,
POST, PUT, PATCH, DELETE, the request headers Authorization, Content-Type,
If-Match, If-None-Match, x-net-backend-protocol, x-request-id, and lets the page read the
answer headers x-net-backend-protocol, x-request-id, ETag and Retry-After, so a page on
another origin uses the API exactly like any other client.
A page served from the same origin as the API (for example through the same reverse proxy) needs no CORS at all. WebSocket connections are not subject to CORS.
Tokens travel only in the Authorization header or the WebSocket auth message, never in
cookies, so cross-site request forgery does not apply.
3. Errors#
Every error over HTTP is:
{"error":{"code":"validation_failed","message":"the request is invalid","details":{"fields":{"password":["is shorter than 10 characters"]}}}}
code: stable,snake_case. Branch on it.message: human-readable English; may change at any time; never contains internal details.details: optional, code-specific JSON.
Over the WebSocket the same object is the error of a refused request or of auth.failed.
Every error code#
| Code | HTTP | Meaning | Client action |
|---|---|---|---|
bad_request |
400 | malformed request: not JSON, wrong types, missing fields, invalid path parameter | fix the request |
validation_failed |
422 | well-formed but breaks a rule; details.fields maps each field to its problems |
show the field messages |
unauthorized |
401 | no valid credentials: missing, unknown, malformed or revoked token | log in again (after one refresh attempt for a revoked access token) |
token_expired |
401 | the access token expired | refresh, then retry once |
forbidden |
403 | authenticated but not allowed (missing role, server-locked object, registration closed, a server rule) | do not retry |
not_found |
404 | no such route or object (or the caller may not know it exists) | |
method_not_allowed |
405 | wrong HTTP method; Allow lists the allowed ones |
fix the request |
unsupported_media_type |
415 | a JSON route without Content-Type: application/json |
send the header |
conflict |
409 | the request conflicts with the current state | |
version_conflict |
409 | storage: the stored version is not the expected one; details: {"current_version":N} (absent when the object does not exist), plus "index" in a batch |
reload, merge, write again |
payload_too_large |
413 | body (or WebSocket answer, or stored value) too large | send less |
rate_limited |
429 | too many requests; details: {"retry_after_ms":N} |
wait that long, then retry |
quota_exceeded |
403 | a per-user quota is used up (stored objects / bytes, rooms per WebSocket) | free something up |
unknown_type |
– | WebSocket only: the request type is not known to this server |
|
unsupported_protocol |
400 | the client's protocol version is not supported; details: {"supported_min":N,"supported_max":N} |
update the client |
invalid_credentials |
401 | email + password do not match (never says which part) | |
refresh_token_reused |
401 | a refresh token was used a second time after the grace window: the whole session is revoked | log in again |
email_taken |
409 | registration: the address already has an account | offer login / password reset |
email_not_verified |
403 | the action needs a verified email address | verify first |
invalid_token |
400 | a one-time mail token (verification, reset) is unknown, used or expired | ask for a new mail |
banned |
403 | the account is banned; details: {"until":<unix ms>} for a timed ban (absent: until lifted) |
show the ban; do not retry |
reauthentication_required |
403 | the action needs a recent login (linking / unlinking a login provider) | log in again, then retry |
steam_auth_failed |
401 | Steam refused the ticket (or could not be asked) | |
room_full |
409 | the chat room is at its member cap | try later |
not_a_member |
403 | not a member of the chat room (join it first; group / DM rooms: members only) | |
hook_timeout |
503 | a server rule did not answer in time | retry later |
unavailable |
503 | overloaded, shutting down, or the request took longer than the server's limit | retry later with backoff |
internal |
500 | unexpected server error (never with details) | retry later; report with x-request-id |
Servers and their games may add their own codes: treat an unknown code like its HTTP status.
Notes:
- An unknown route answers 404
{"error":{"code":"not_found","message":"no such route or object"}}. - A request that takes longer than the server's request timeout (default 30 s) answers 503
unavailable. email_takenreveals that an address has an account: a deliberate choice for a clear sign-up answer, bounded by the registration rate limit. Login, password reset and the failed-login limits never reveal it.
4. Rate limits and sizes#
All numbers are the server's defaults; an operator may change them. Every 429 answer carries
details.retry_after_ms; answers from the request-level limiters also carry Retry-After (whole
seconds).
HTTP rate limits#
| What | Limit |
|---|---|
| logins (password and Steam), per client address and route | 10 per minute |
| registrations, per client address | 10 per hour |
| refreshes, per client address | 60 per minute |
| forgot / reset / verify / resend / password change, per client address and route | 10 per minute |
| mails per account (verification, reset) | 3 per hour (a further reset request answers the same and sends nothing; a further resend answers 429) |
| failed logins per email address and client network | 5, then one more try every 3 minutes |
| failed logins per email address, from everywhere | 50 per hour; above it only networks that logged in to the account before may try |
storage writes (PUT, DELETE, a batch counts once), per user |
60 per 60 s (a burst of 60, then one per second) |
| opening a direct-message room, per user | 20 per 600 s (a burst of 20, then one every 30 s) |
Client networks are single IPv4 addresses and IPv6 /64 blocks. The failed-login limits count whether or not the address has an account.
WebSocket limits#
| What | Limit |
|---|---|
| frames per connection | 20 per second, burst 40 (text, binary and ping frames count); over it requests are answered rate_limited; a client that keeps flooding is closed with 1008 |
| chat messages per user | a burst of 5, then one every 2 s (rate_limited with retry_after_ms) |
| message size | 1 MiB (1048576 bytes) in both directions; bigger: close 1009 |
| connections per user | 5; a 6th closes the oldest with 4009 (the same session's first) |
| connections per client address | 100 (429 at the handshake) |
| handshakes per client address | 60 per minute (429 at the handshake) |
| rooms per connection | 16 (quota_exceeded) |
| connections per public room | 200 (room_full) |
| time to authenticate | 5 s after the upgrade (else close 1008) |
Body and value sizes#
| What | Limit |
|---|---|
| JSON request bodies | 64 KiB (413 payload_too_large above) |
storage PUT body |
272 KiB (a value of up to 256 KiB plus JSON) |
storage batch PUT body |
4 MiB + 64 KiB |
| one stored value | 256 KiB of JSON (422 validation_failed above) |
| stored objects per user | 1000 (403 quota_exceeded) |
| stored bytes per user | 4 MiB of values (403 quota_exceeded; a write that does not grow an object always passes) |
| batch | 1–16 distinct objects, at most 4 MiB of values together; a batch read over 4 MiB answers 413 |
| Steam ticket | 8192 hex characters |
| ban reason | 255 characters |
Generated from API.md (repository commit e25edb9, file sha256 3db87f7bba70).