{"openapi":"3.1.0","info":{"title":"Khelau API","version":"1.0.0","summary":"HTTP API of Khelau: health, game catalog and the Call Break admin dashboard.","description":"The shared backend of **Khelau (खेलौँ)**, a platform for traditional Nepali games.\n\n## Getting started\n- **Base URL**: `https://khelau-api.vercel.app` (production), `http://localhost:8787` (`pnpm dev:api`).\n- **Format**: JSON in, JSON out, UTF-8.\n- **Authentication**: public endpoints need none. Admin endpoints need `Authorization: Bearer <ADMIN_TOKEN>`; click **Authorize** to try them here.\n- **Errors**: every `/v1` error has the same body, `{\"error\": {\"code\", \"message\"}}`. Branch on `code`; `message` is for people.\n- **Rate limits**: per client IP. Too many wrong admin tokens returns `429 rate_limited`.\n- **CORS**: public endpoints are readable from any origin. Admin endpoints and the game socket accept browsers only from the API's `ALLOWED_ORIGINS`.\n\n## Versioning\nPaths are versioned (`/v1`). Within a version, changes are additive only: new endpoints, new optional fields. Clients must ignore fields they don't know.\n\n## Real-time play\nGames are played over a WebSocket, described with AsyncAPI: see the [game socket documentation](/docs/socket).","contact":{"name":"Khelau maintainers","url":"https://github.com/yalaitsolutions/khelau"},"license":{"name":"Proprietary. All rights reserved.","identifier":"LicenseRef-Proprietary"}},"externalDocs":{"description":"Game socket (AsyncAPI)","url":"https://khelau-api.vercel.app/docs/socket"},"servers":[{"url":"/","description":"This server"},{"url":"https://khelau-api.vercel.app","description":"Production"},{"url":"http://localhost:8787","description":"Local development (pnpm dev:api)"}],"security":[],"tags":[{"name":"Platform","description":"Service health and the game catalog. Public; no authentication."},{"name":"Admin","description":"Data behind the Call Break server dashboard (`/admin` on the Call Break site). Requires the admin token."},{"name":"Legacy","description":"Routes kept for already-installed Call Break apps. Deprecated: use the `/v1` equivalents."}],"paths":{"/v1/health":{"get":{"tags":["Platform"],"operationId":"getHealth","summary":"Check service health","description":"Reports whether the instance that answered is up. Contains no configuration, versions or secrets, so it is safe to expose to uptime monitors.","security":[],"responses":{"200":{"description":"The API is up.","headers":{"Cache-Control":{"$ref":"#/components/headers/CacheControl"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"},"example":{"ok":true,"online":3,"rooms":1,"persistence":true}}}}}}},"/v1/games":{"get":{"tags":["Platform"],"operationId":"listGames","summary":"List games","description":"Returns every Khelau game and whether it can be played yet. The catalog is static and may be cached for 5 minutes.","security":[],"responses":{"200":{"description":"The game catalog.","headers":{"Cache-Control":{"$ref":"#/components/headers/CacheControl"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GamesResponse"},"example":{"games":[{"slug":"callbreak","name":"Call Break","nameNe":"कल ब्रेक","tagline":"The classic Nepali trick-taking card game. Bid, then win your tricks.","players":"4 players","status":"available","modes":["bots","offline","online","private-room"]}]}}}}}}},"/v1/admin/overview":{"get":{"tags":["Admin"],"operationId":"getAdminOverview","summary":"Get dashboard overview","description":"Returns live rooms across all server instances, key figures for today and the last 7 days, and trends.","security":[{"adminToken":[]}],"responses":{"200":{"description":"The overview.","headers":{"Cache-Control":{"$ref":"#/components/headers/CacheControl"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Overview"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/AdminUnavailable"}}}},"/v1/admin/games":{"get":{"tags":["Admin"],"operationId":"listAdminGames","summary":"List recent games","description":"Returns one page of games, newest first by default. Invalid parameter values fall back to their defaults instead of failing.","security":[{"adminToken":[]}],"parameters":[{"$ref":"#/components/parameters/Page"},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Search"},{"$ref":"#/components/parameters/SortOrder"},{"name":"sortBy","in":"query","required":false,"description":"Column to sort by.","schema":{"type":"string","enum":["started","length","rounds"],"default":"started"}},{"name":"status","in":"query","required":false,"description":"Only rows with this value; empty means all.","schema":{"type":"string","enum":["","playing","finished","abandoned"],"default":""}},{"name":"reason","in":"query","required":false,"description":"Only rows with this value; empty means all.","schema":{"type":"string","enum":["","full","early","timeout","again"],"default":""}},{"name":"visibility","in":"query","required":false,"description":"Only rows with this value; empty means all.","schema":{"type":"string","enum":["","public","private"],"default":""}}],"responses":{"200":{"description":"One page of results.","headers":{"Cache-Control":{"$ref":"#/components/headers/CacheControl"}},"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/Page"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/GameRow"}}}}]},"example":{"data":[{"id":"g_3f9a1c2b7d4e","roomCode":"KTM42","isPublic":true,"startedAt":"2026-09-26T05:00:00Z","endedAt":"2026-09-26T05:12:40Z","status":"finished","startReason":"early","humans":2,"bots":2,"roundsPlayed":3,"winner":"Asha","players":[]}],"pagination":{"page":1,"limit":10,"total":1,"totalPages":1}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/AdminUnavailable"}}}},"/v1/admin/players":{"get":{"tags":["Admin"],"operationId":"listAdminPlayers","summary":"List players","description":"Returns one page of players (one per browser/device). Invalid parameter values fall back to their defaults instead of failing.","security":[{"adminToken":[]}],"parameters":[{"$ref":"#/components/parameters/Page"},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Search"},{"$ref":"#/components/parameters/SortOrder"},{"name":"sortBy","in":"query","required":false,"description":"Column to sort by.","schema":{"type":"string","enum":["name","lastSeen","firstSeen","visits","games","games7d","wins","avgWait"],"default":"lastSeen"}},{"name":"active","in":"query","required":false,"description":"Only rows with this value; empty means all.","schema":{"type":"string","enum":["","7d"],"default":""}}],"responses":{"200":{"description":"One page of results.","headers":{"Cache-Control":{"$ref":"#/components/headers/CacheControl"}},"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/Page"},{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PlayerRow"}}}}]},"example":{"data":[{"id":"c_8d1e5f0a9b","name":"Asha","firstSeen":"2026-09-20T10:00:00Z","lastSeen":"2026-09-26T05:12:40Z","visits":12,"games":9,"wins":4,"games7d":5,"avgWaitMs":42000,"lastRoom":"KTM42","location":{"country":"NP","region":"Bagmati","city":"Kathmandu"},"ip":null}],"pagination":{"page":1,"limit":10,"total":1,"totalPages":1}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/AdminUnavailable"}}}},"/v1/admin/close":{"post":{"tags":["Admin"],"operationId":"closeRoom","summary":"Close a room","description":"Ends a room on whichever server instance hosts it and sends its players back to the lobby screen. A game in progress is recorded as abandoned.","security":[{"adminToken":[]}],"parameters":[{"$ref":"#/components/parameters/RoomCode"}],"responses":{"200":{"description":"The close request was accepted.","headers":{"Cache-Control":{"$ref":"#/components/headers/CacheControl"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CloseRoomResponse"},"example":{"result":"closed"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/AdminUnavailable"}}}},"/api/ws":{"get":{"tags":["Legacy"],"operationId":"getLegacyHealth","summary":"Check service health (legacy)","description":"Deprecated: use `GET /v1/health`. Kept for installed Call Break apps. A WebSocket upgrade on this path opens the game socket (see [the game socket documentation](/docs/socket)); `?admin=<action>` serves the admin endpoints with the old `{\"error\": \"…\"}` body.","deprecated":true,"security":[],"responses":{"200":{"description":"The API is up.","headers":{"Cache-Control":{"$ref":"#/components/headers/CacheControl"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LegacyHealthResponse"},"example":{"ok":true,"online":3,"rooms":1}}}}}}}},"components":{"securitySchemes":{"adminToken":{"type":"http","scheme":"bearer","description":"The API's `ADMIN_TOKEN` (set in the API's environment). Wrong tokens are rate-limited per client."}},"headers":{"CacheControl":{"description":"`no-store` for live data; `public, max-age=300` for the catalog and the API description.","schema":{"type":"string"}}},"parameters":{"Page":{"name":"page","in":"query","required":false,"description":"1-based page number.","schema":{"type":"integer","minimum":1,"default":1}},"Limit":{"name":"limit","in":"query","required":false,"description":"Rows per page.","schema":{"type":"integer","enum":[10,25,50,100],"default":10}},"Search":{"name":"search","in":"query","required":false,"description":"Case-insensitive text search.","schema":{"type":"string","maxLength":100}},"SortOrder":{"name":"sortOrder","in":"query","required":false,"description":"Sort direction.","schema":{"type":"string","enum":["asc","desc"],"default":"desc"}},"RoomCode":{"name":"room","in":"query","required":true,"description":"The 5-character room code.","schema":{"type":"string","pattern":"^[A-Z0-9]{5}$"},"example":"ABCDE"}},"responses":{"BadRequest":{"description":"The request is invalid.","headers":{"Cache-Control":{"$ref":"#/components/headers/CacheControl"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"bad_request","message":"Invalid room code."}}}}},"Unauthorized":{"description":"The admin token is missing or wrong.","headers":{"Cache-Control":{"$ref":"#/components/headers/CacheControl"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"unauthorized","message":"Wrong or missing admin token."}}}}},"RateLimited":{"description":"Too many wrong admin tokens from this client. Wait a minute.","headers":{"Cache-Control":{"$ref":"#/components/headers/CacheControl"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"rate_limited","message":"Too many wrong tokens. Wait a minute and try again."}}}}},"AdminUnavailable":{"description":"The admin API is disabled (no `ADMIN_TOKEN`) or analytics storage is unavailable; the message says which.","headers":{"Cache-Control":{"$ref":"#/components/headers/CacheControl"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"example":{"error":{"code":"unavailable","message":"Analytics storage is unavailable: 2 database migration(s) are pending. Run `pnpm db:migrate` against this database."}}}}}},"schemas":{"ErrorCode":{"type":"string","description":"Stable, machine-readable error code.","enum":["bad_request","unauthorized","forbidden","not_found","method_not_allowed","rate_limited","unavailable","internal"]},"ApiError":{"type":"object","description":"The body of every `/v1` error response.","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"$ref":"#/components/schemas/ErrorCode"},"message":{"type":"string","description":"Human-readable explanation."}}}}},"HealthResponse":{"type":"object","required":["ok","online","rooms","persistence"],"properties":{"ok":{"type":"boolean"},"online":{"type":"integer","minimum":0,"description":"Players connected to this instance."},"rooms":{"type":"integer","minimum":0,"description":"Rooms open on this instance."},"persistence":{"type":"boolean","description":"Whether online rooms are saved to the database and restorable after restarts."}}},"LegacyHealthResponse":{"type":"object","required":["ok","online","rooms"],"properties":{"ok":{"type":"boolean"},"online":{"type":"integer","minimum":0},"rooms":{"type":"integer","minimum":0}}},"GameInfo":{"type":"object","required":["slug","name","nameNe","tagline","players","status","modes"],"properties":{"slug":{"type":"string","enum":["callbreak","marriage","ludo","dhumal"],"description":"Stable identifier."},"name":{"type":"string"},"nameNe":{"type":"string","description":"Name in Nepali (Devanagari)."},"tagline":{"type":"string"},"players":{"type":"string","description":"Player count, for display."},"status":{"type":"string","enum":["available","coming-soon"]},"modes":{"type":"array","items":{"type":"string","enum":["bots","online","private-room","offline"]}}}},"GamesResponse":{"type":"object","required":["games"],"properties":{"games":{"type":"array","items":{"$ref":"#/components/schemas/GameInfo"}}}},"Page":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{}},"pagination":{"type":"object","required":["page","limit","total","totalPages"],"properties":{"page":{"type":"integer","minimum":1},"limit":{"type":"integer"},"total":{"type":"integer","minimum":0},"totalPages":{"type":"integer","minimum":0,"description":"0 when there are no results."}}}}},"CloseRoomResponse":{"type":"object","required":["result"],"properties":{"result":{"type":"string","enum":["closed","queued"],"description":"`closed`: this instance hosted the room. `queued`: another instance will close it within seconds."}}},"LiveRoom":{"type":"object","required":["instanceId","code","isPublic","status","createdAt","seats"],"properties":{"instanceId":{"type":"string"},"code":{"type":"string"},"isPublic":{"type":"boolean"},"status":{"type":"string","enum":["lobby","playing","finished"]},"createdAt":{"type":"integer","description":"Epoch milliseconds."},"autoStartAt":{"type":["integer","null"],"description":"Epoch ms when a public lobby fills with bots."},"gameId":{"type":["string","null"]},"gameStartedAt":{"type":["integer","null"]},"round":{"type":["integer","null"]},"rounds":{"type":"integer"},"phase":{"type":["string","null"]},"totals":{"type":["array","null"],"items":{"type":"integer"},"description":"Scores in tenths (41 = +4.1)."},"seats":{"type":"array","items":{"type":["object","null"],"required":["name","kind","connected","botControlled","seatedMs"],"properties":{"name":{"type":"string"},"kind":{"type":"string","enum":["human","bot"]},"connected":{"type":"boolean"},"botControlled":{"type":"boolean","description":"A human seat currently played by a bot (disconnected or left)."},"seatedMs":{"type":["integer","null"],"description":"Milliseconds since this player sat down (humans only)."}}}},"founders":{"type":"boolean"},"bench":{"type":["object","null"],"required":["name","kind","connected","botControlled","seatedMs"],"properties":{"name":{"type":"string"},"kind":{"type":"string","enum":["human","bot"]},"connected":{"type":"boolean"},"botControlled":{"type":"boolean","description":"A human seat currently played by a bot (disconnected or left)."},"seatedMs":{"type":["integer","null"],"description":"Milliseconds since this player sat down (humans only)."}}}}},"Overview":{"type":"object","required":["generatedAt","storage","live","kpis","startReasons7d","gamesPerDay","onlineSeries"],"properties":{"generatedAt":{"type":"integer","description":"Epoch milliseconds."},"storage":{"type":"object","properties":{"kind":{"type":"string","enum":["postgres","pglite-file","pglite-memory"]},"persistent":{"type":"boolean"},"shared":{"type":"boolean"}}},"live":{"type":"object","properties":{"instances":{"type":"integer"},"online":{"type":"integer"},"idle":{"type":"integer"},"waiting":{"type":"integer"},"playing":{"type":"integer"},"rooms":{"type":"array","items":{"$ref":"#/components/schemas/LiveRoom"}}}},"kpis":{"type":"object","additionalProperties":{"type":["number","null"]}},"startReasons7d":{"type":"object","properties":{"full":{"type":"integer"},"early":{"type":"integer"},"timeout":{"type":"integer"},"again":{"type":"integer"}}},"gamesPerDay":{"type":"array","items":{"type":"object","properties":{"day":{"type":"string","format":"date"},"games":{"type":"integer"}}}},"onlineSeries":{"type":"array","items":{"type":"object","properties":{"t":{"type":"string","format":"date-time"},"online":{"type":"integer"}}}}}},"GameRow":{"type":"object","required":["id","roomCode","isPublic","startedAt","status","startReason","humans","bots","roundsPlayed","players"],"properties":{"id":{"type":"string"},"roomCode":{"type":"string"},"isPublic":{"type":"boolean"},"startedAt":{"type":"string","format":"date-time"},"endedAt":{"type":["string","null"],"format":"date-time"},"status":{"type":"string","enum":["playing","finished","abandoned"]},"startReason":{"type":"string","enum":["full","early","timeout","again"]},"humans":{"type":"integer"},"bots":{"type":"integer"},"roundsPlayed":{"type":"integer"},"winner":{"type":["string","null"]},"players":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"isBot":{"type":"boolean"},"score":{"type":["integer","null"],"description":"Final score in tenths."},"place":{"type":["integer","null"]},"location":{"type":["object","null"],"description":"Where the player connected from, worked out from the IP address.","required":["country","region","city"],"properties":{"country":{"type":"string","description":"ISO 3166-1 alpha-2 country code.","example":"NP"},"region":{"type":["string","null"]},"city":{"type":["string","null"]}}},"ip":{"type":["string","null"]}}}}}},"PlayerRow":{"type":"object","required":["id","name","firstSeen","lastSeen","visits","games","wins","games7d"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"firstSeen":{"type":"string","format":"date-time"},"lastSeen":{"type":"string","format":"date-time"},"visits":{"type":"integer"},"games":{"type":"integer"},"wins":{"type":"integer"},"games7d":{"type":"integer"},"avgWaitMs":{"type":["integer","null"]},"lastRoom":{"type":["string","null"]},"location":{"type":["object","null"],"description":"Where the player connected from, worked out from the IP address.","required":["country","region","city"],"properties":{"country":{"type":"string","description":"ISO 3166-1 alpha-2 country code.","example":"NP"},"region":{"type":["string","null"]},"city":{"type":["string","null"]}}},"ip":{"type":["string","null"],"description":"IP address of the player's latest connection."}}}}}}