Everything that touches the filesystem or decides who you are is served over HTTP under /api, on the same port as the WebSocket. Only live game state stays on the socket (WebSocket protocol).
Rule of thumb: touches the filesystem or decides who you are → HTTP. Touches the in-memory registry of a running game → WebSocket.
This API is internal and not version-stabilized. It can change between releases without a deprecation period.
The server never sends prose. Every error body is:
{ "error": "errors:quizz.notFound" }The value is an i18n translation key (the web UI feeds it straight to t()), so treat it as a symbolic error code. Validation failures return the key of the first failing rule, e.g. errors:quizz.subjectEmpty.
This holds for every response the server can produce, including the ones no route handles:
| Situation | Status | Key |
|---|---|---|
| Unknown path or method | 404 |
errors:api.notFound |
| Unparseable or missing request body | 400 |
errors:invalidRequest |
| Unexpected server-side failure | 500 |
errors:api.serverError |
The one bodyless response is 204 No Content, returned by the DELETE routes — that is what the status means.
Identity is a JWT signed by the server (see JWT_SECRET). Two roles exist: player (30-day tokens) and manager (12-hour tokens).
Authenticated routes expect the token as a bearer:
Authorization: Bearer <token>
Body { "token"?: string } (an empty or absent body is fine). Always answers 200 with:
{ "token": "…", "clientId": "…", "role": "player", "expiresAt": 1789017512 }| Input | Behaviour |
|---|---|
| none / unparseable | mints a fresh clientId, role player |
| valid, unexpired | returned as-is — idempotent, keeps an active manager role |
| valid signature, expired | re-minted keeping clientId, role forced to player |
| bad signature | mints a fresh session (the secret was rotated) |
Keeping clientId on expiry is what preserves a player's seat across a refresh; forcing player is what downgrades a stale manager.
Body { "password": string }. Upgrades the caller's session to manager, keeping the same clientId when a bearer is sent. Returns the same shape as /api/auth/session.
401 errors:manager.invalidPassword— wrong password403 errors:manager.passwordNotConfigured—MANAGER_PASSWORDis not set
There is no rate limit, matching the previous socket behaviour.
Bearer required. Mints a fresh player token for the same clientId and returns it.
Logout is stateless: the old manager token stays cryptographically valid until it expires. The 12-hour manager TTL is the mitigation.
All of these require a bearer whose role is manager, and answer 401 errors:auth.unauthorized otherwise.
| Route | Success | Errors |
|---|---|---|
GET /api/quizz |
200 { quizz: [{ id, subject }] } |
— |
GET /api/quizz/:id |
200 the full quiz |
404 errors:quizz.notFound |
POST /api/quizz |
201 { id } |
400 validation key |
PATCH /api/quizz/:id |
200 { id } |
400 validation key, 404 |
DELETE /api/quizz/:id |
204 |
404 errors:quizz.notFound |
GET /api/results |
200 { results: [meta] } |
— |
GET /api/results/:id |
200 the full result |
404 errors:result.notFound |
DELETE /api/results/:id |
204 |
404 errors:result.notFound |
POST /api/games |
201 { gameId, inviteCode } |
404 errors:quizz.notFound |
GET /api/media |
200 { media: [media] } |
— |
POST /api/media |
201 the stored media |
400, 413, 415 |
DELETE /api/media/:name |
204 |
400, 404 |
The quiz body is validated against the same schema as the files in config/quizz — see Quiz.
POST /api/media takes a multipart/form-data body with a single file field, up to 100 MB (413 errors:media.tooLarge beyond). The type is detected from the file's content, not its name or declared MIME: PNG, JPEG, WebP and GIF images, MP3, OGG and WAV audio, MP4 and WebM video. Anything else is refused with 415 errors:media.invalidType.
Files are stored in config/media and served at /media/<name>. A media object is { name, url, type }: name is the stored file name (the uploaded name plus a random suffix) and type either "image", "audio" or "video"; put its url in a question's media.url. The list is sorted newest first. Deleting a file does not update the quizzes that use it.
POST /api/games creates the game but binds no socket to it: the manager enters the room by emitting manager:reconnect { gameId }. A game nobody connects to expires after ~5 minutes, like an abandoned one.
Changes the settings of a game you created, before it starts. The body is a partial settings object — send only the keys you are changing:
{
generatedUsernames?: boolean // the server names the players instead of letting them type
answersOnly?: boolean // players only see the answer buttons during SELECT_ANSWER
autoAdvance?: {
enable: boolean
responsesDelay: number // seconds on the answer reveal, 3-600
leaderboardDelay: number // seconds on the leaderboard, 3-600
}
}Answers 200 { settings } with the full settings after the change.
404 errors:game.notFound— unknown game, or you are not its manager409 errors:game.alreadyStarted— settings are locked once the game is running400— validation key, e.g.errors:game.invalidAutoAdvanceDelay
The autoAdvance object is nested, so sending it replaces all three of its fields at once.
| Route | Success | Notes |
|---|---|---|
GET /api/health |
200 { status: "ok" } |
Liveness probe |
POST /api/games/check |
200 { generatedUsernames: boolean } |
Checks a 6-digit invite code before joining |
Body { "inviteCode": string }. It is a POST rather than a GET on purpose: the invite code is a room key, and a body keeps it out of access logs, browser history and Referer headers — and out of proxy caches, which matters because the answer changes as games start and end.
generatedUsernames tells a client what the join form should ask for: when it is true there is no username to type.
404 errors:game.notFound— unknown invite code403 errors:game.locked— the host has locked the room (manager:setLock, see WebSocket protocol). The game still exists, so a player who already holds a seat can reconnect to it.
Any signed session (bearer required, no role). Body { "inviteCode": string, "username"?: string }, answers 200 { gameId, ticket, username }.
usernameis 1-24 characters (validators/auth.ts). It is required unless the game hasgeneratedUsernamesenabled, in which case anything you send is ignored and the server picks the name.- The response
usernameis the name you asked for, ornullwhen the server will generate one — you then learn it fromgame:successJoin.
The ticket is a 5-minute JWT with claims { sub, gameId, username }, signed with the same secret. It carries the right to a seat; the seat itself is only created when the socket presents it through player:login { ticket } — which is what keeps player.id === socket.id true at all times server-side.
When the generator is on, the ticket carries no username and the name is drawn at join time, so a ticket minted before the setting was turned on cannot smuggle a chosen name past it.
ticket is null when this client already holds a seat: reconnect with player:reconnect { gameId } instead.
404 errors:game.notFound— unknown invite code403 errors:game.managerCannotJoin— you are this game's manager403 errors:game.locked— the host has locked the room; a client that already holds a seat still gets itsticket: nullanswer400— invalid or missing username, with the validation key
POST /api/games/checkmakes the 6-digit PIN space (10⁶) cheaply enumerable, as it already was over the socket. Keeping the code out of the URL does not change that — it only stops it leaking passively into logs and caches. Worth knowing if you expose Razzia to the open internet.
/api must be forwarded to the container like / and /ws — see Reverse Proxy.