{"openapi":"3.1.0","info":{"title":"Scoreboards API (Beta)","version":"0.1.0","summary":"Read leaderboards, and enqueue matches and scores, on Scoreboards boards. Beta: access is approval-only and the contract can still change.","description":"**BETA. Read this paragraph before you write any code.** Beta here is not a disclaimer, it\nis three concrete facts: **access is approval-only**, **the surface can still change**, and\n**a breaking change can ship inside `/v1` without a new version prefix** while the beta\nlasts. Every response carries `X-API-Lifecycle: beta`, this document is\nmachine-readably marked with `x-beta` at its root, and `info.version` is deliberately\npre-1.0 and moves with the contract. Pin the version you tested against, and treat the day\nthis header stops saying `beta` as the day the surface froze.\n\n**Getting access.** Approval is per owner — one Discord server, or one personal workspace.\nA guild admin opens the **API** section of the Scoreboards dashboard\n(<https://scoreboards.dev/dashboard>) and requests access; a maintainer reviews it, and the\nkey-creation UI unlocks for that owner once it is approved. There is nothing to do anywhere\nelse and nobody to message: the whole request goes through the dashboard. Until an owner is\napproved, no key exists for them, so every call is a `401`. The reason the gate is there is\nthe promise above — while the contract can still move, we keep the set of integrations small\nenough that every one of them can be warned before it breaks.\n\n**Writes are asynchronous. A write returns `202 Accepted` and a handle — never a rank.**\n\n`POST /boards/{board}/matches` and `POST /boards/{board}/scores` do not compute a result.\nThey enqueue a command for the Discord bot that owns all scoring, and answer with a\n`handle` (`sub_88213`) and `applied: false`. On a board with a validation window\n**nothing moves on the leaderboard until a human clicks Approve in Discord** — which may\nbe a day later, or never. Poll `GET /submissions/{handle}` to learn the outcome.\n\n`201 Created` is the one exception: on a board whose validation window is 0 hours\n(`auto_applies: true`), the API waits up to ~3 seconds for the worker and, if it finishes\nin time, answers `201` with `applied: true`. A timeout still answers `202`. Handle both.\n\nAn integration that assumes POST → standings-changed is broken before it ships.\n\n### Five more things that decide whether an integration works\n\n1. **Ratings are snapshotted at submission, not at approval.** A participant's ELO is read\n   when the match is created. Ten matches pushed back to back all compute against\n   pre-tournament ratings. Deterministic, matches the bot, and surprising.\n2. **ELO takes `winner`; League takes `team1_score`/`team2_score` and derives the winner.**\n   Each refuses the other's fields with a `422` naming the offending key. Ask\n   `GET /boards/{board}` — `write.requires` and `write.rejects` answer this per board.\n3. **Time boards take MILLISECONDS**, as an integer: `1:30` is `90000`.\n4. **Each roster carries exactly the board's `team_size`** — except a `fixed_teams` board,\n   which takes exactly ONE `team_id` per side, not `team_size` players.\n5. **`Idempotency-Key` is required on every POST.** Same key + same body replays the stored\n   response; same key + a different body is a `409`.\n\n### Identity\n\nPlayer ids on the wire are namespaced: `discord:<snowflake>`, `custom:<name>`,\n`role:<id>`, `team:<id>`. A bare Discord snowflake is also accepted on input. Board ids are\nthe immutable `board_<n>` slug, never the display name. Match ids are per-board, so match\nURLs are always board-nested.\n\n### Authentication\n\n`Authorization: Bearer sk_live_...`. A key is bound to exactly one owner — a Discord guild\nor a user — and can never address another owner's board. Keys are minted in the dashboard\nand shown once, by an owner whose access has been approved (see **Getting access** above).\n`sk_test_` keys are **read-only**: every write scope is refused.\n\nApproval can be withdrawn, and withdrawing it revokes that owner's live keys in the same\nstep — so a revoked owner's integration starts getting `401` immediately, not at some later\nre-check. There is no \"access revoked but the key still works\" state.\n\nScopes: `boards:read`, `entries:read`, `matches:read`, `matches:write`, `scores:write`.\nThe dashboard mints two levels: **Read only** grants the three read scopes, **Read and\nwrite** grants all five. Scopes are checked exactly, with no implication — `matches:write`\ndoes not grant `matches:read`. Secret keys never belong in client-side code — /v1 sends no\nCORS headers at all.\n\n### Errors\n\nRFC 9457 `application/problem+json` from every operation in this document — and from every\nrefusal on the prefix, including a mistyped URL that matches no `/v1` route at all. Branch\non the stable `code`, never on `title` or `detail`. **The API is English-only** and ignores\n`Accept-Language`: an error string that changes language between environments is harder to\nsearch for, not easier. (The docs themselves are localized; the API is not.)\n\n### Rate limits\n\n600 reads and 60 writes per minute per key, as\nseparate token buckets. Both header families are emitted on every response past\nauthentication — `RateLimit-Reset` is **delta-seconds**, `X-RateLimit-Reset` is a **unix\ntimestamp**. They are the same instant in different units; reading the wrong one is the\nclassic bug here.\n\n### Pagination\n\nCursor, not offset — a leaderboard mutates between requests. Cursors are composite\n`(value, tie-breaker)`, so a page boundary inside a block of tied scores resumes mid-tie\ninstead of skipping everyone on that score. Default page 50,\nmaximum 200. Treat `next_cursor` as opaque.","termsOfService":"https://scoreboards.dev/terms","contact":{"name":"Scoreboards support","url":"https://scoreboards.dev/support"}},"x-beta":{"lifecycle":"beta","header":"X-API-Lifecycle","access":"approval-required","request_access_url":"https://scoreboards.dev/dashboard","breaking_changes_without_version_bump":true,"contact_url":"https://scoreboards.dev/support"},"servers":[{"url":"https://api.scoreboards.dev/v1","description":"Production"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Boards","description":"Boards and their derived write contracts."},{"name":"Entries","description":"Ranked standings — one row per participant."},{"name":"Matches","description":"Match history on ELO and League boards, and the asynchronous endpoint that records one. The write returns a handle, not a result."},{"name":"Submissions","description":"Score submissions on Classic, Highscore and Time boards, and resolving the handle that any write returns."}],"paths":{"/boards":{"get":{"operationId":"listBoards","summary":"List every board this key can address","description":"Every board owned by the key's owner, oldest first, with the derived write contract on each. A key is bound to exactly one owner (a Discord guild, or a user for `u_` boards) and can never address another owner's board.\n\nStart here: `write.endpoint`, `write.requires` and `write.rejects` tell you what to send without reading any prose, and `auto_applies` tells you whether a write will ever return `201`.\n\nRequires the `boards:read` scope.","tags":["Boards"],"security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/LimitQuery"},{"$ref":"#/components/parameters/CursorQuery"}],"responses":{"200":{"description":"A page of boards.","headers":{"X-Request-Id":{"$ref":"#/components/headers/X-Request-Id"},"X-API-Lifecycle":{"$ref":"#/components/headers/X-API-Lifecycle"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BoardList"},"example":{"data":[{"id":"board_1","display_name":"Tuesday Chess","type":"ELO","sort":"desc","team_size":3,"fixed_teams":false,"url":"https://scoreboards.dev/leaderboards/111222333444555666/board_1","write":{"endpoint":"/v1/boards/board_1/matches","requires":["team1","team2","winner"],"rejects":["team1_score","team2_score","score"],"operator":"elo","k_factor":24,"start_rating":1000},"validation_window_hours":24,"auto_applies":false},{"id":"board_2","display_name":"Sunday League","type":"League","sort":"desc","team_size":1,"fixed_teams":false,"url":"https://scoreboards.dev/leaderboards/111222333444555666/board_2","write":{"endpoint":"/v1/boards/board_2/matches","requires":["team1","team2","team1_score","team2_score"],"rejects":["winner","score"],"operator":"league_points","points_win":3,"points_draw":1,"points_loss":0},"validation_window_hours":0,"auto_applies":true},{"id":"board_3","display_name":"Speedrun","type":"Highscore","sort":"desc","team_size":1,"fixed_teams":false,"url":"https://scoreboards.dev/leaderboards/111222333444555666/board_3","write":{"endpoint":"/v1/boards/board_3/scores","requires":["player","score"],"rejects":["team1","team2","winner","team1_score","team2_score"],"operator":"keep_highest"},"validation_window_hours":24,"auto_applies":false}],"has_more":false,"next_cursor":null}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/boards/{board}":{"get":{"operationId":"getBoard","summary":"Get one board and its write contract","description":"Metadata plus the derived, read-only write contract. The `operator` is fixed by the board's type and sort when it is created — Classic accumulates, Highscore keeps the maximum, Time keeps the minimum, ELO applies a zero-sum delta, League awards points — and cannot be overridden per submission.\n\nRead `auto_applies` before you build anything: when it is false, every write returns `202` and nothing changes until a moderator approves it in Discord.\n\nRequires the `boards:read` scope.","tags":["Boards"],"security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BoardPath"}],"responses":{"200":{"description":"The board.","headers":{"X-Request-Id":{"$ref":"#/components/headers/X-Request-Id"},"X-API-Lifecycle":{"$ref":"#/components/headers/X-API-Lifecycle"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Board"},"example":{"id":"board_1","display_name":"Tuesday Chess","type":"ELO","sort":"desc","team_size":3,"fixed_teams":false,"url":"https://scoreboards.dev/leaderboards/111222333444555666/board_1","write":{"endpoint":"/v1/boards/board_1/matches","requires":["team1","team2","winner"],"rejects":["team1_score","team2_score","score"],"operator":"elo","k_factor":24,"start_rating":1000},"validation_window_hours":24,"auto_applies":false}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/boards/{board}/entries":{"get":{"operationId":"listEntries","summary":"List ranked standings","description":"The leaderboard, best first, cursor-paginated. `rank` is correct on every page, including a page resumed from a cursor.\n\nELO boards carry `elo`; every other type carries `score`. Rating boards have no ranked entries in v0 (they store one row per submission, not per player) and return a 422 `board_type_mismatch`.\n\nRequires the `entries:read` scope.","tags":["Entries"],"security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BoardPath"},{"$ref":"#/components/parameters/LimitQuery"},{"$ref":"#/components/parameters/CursorQuery"}],"responses":{"200":{"description":"A page of standings.","headers":{"X-Request-Id":{"$ref":"#/components/headers/X-Request-Id"},"X-API-Lifecycle":{"$ref":"#/components/headers/X-API-Lifecycle"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntryList"},"example":{"data":[{"rank":1,"player_id":"discord:198374652000000001","player_name":"Ava","last_sub":"2026-07-20T12:00:00.000Z","elo":1450,"wins":9,"draws":2,"losses":1},{"rank":2,"player_id":"discord:198374652000000002","player_name":"Ben","last_sub":"2026-07-21T12:00:00.000Z","elo":1200,"wins":4,"draws":1,"losses":4}],"has_more":true,"next_cursor":"eyJzIjoxMjAwLCJwIjoiMTk4Mzc0NjUyMDAwMDAwMDAyIn0"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/boards/{board}/entries/{player_id}":{"get":{"operationId":"getEntry","summary":"Get one participant's standing","description":"One row of the leaderboard, with its live rank. Use a `player_id` exactly as an entries response returned it; a bare Discord snowflake also works.\n\nA participant with no row on this board is a 404 — a player who has never submitted has no standing to report.\n\nRequires the `entries:read` scope.","tags":["Entries"],"security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BoardPath"},{"$ref":"#/components/parameters/PlayerIdPath"}],"responses":{"200":{"description":"The standing.","headers":{"X-Request-Id":{"$ref":"#/components/headers/X-Request-Id"},"X-API-Lifecycle":{"$ref":"#/components/headers/X-API-Lifecycle"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Entry"},"example":{"rank":1,"player_id":"discord:198374652000000001","player_name":"Ava","last_sub":"2026-07-20T12:00:00.000Z","elo":1450,"wins":9,"draws":2,"losses":1}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/boards/{board}/matches":{"get":{"operationId":"listMatches","summary":"List match history","description":"Recorded matches, newest first. Only ELO and League boards record matches; on any other type this is a 422 `board_type_mismatch` pointing you at `/entries`.\n\nEvery participant carries the `elo` they held when the match was **submitted**, not when it was approved — so a tournament pushed as ten back-to-back matches shows pre-tournament ratings on all ten.\n\nRequires the `matches:read` scope.","tags":["Matches"],"security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BoardPath"},{"$ref":"#/components/parameters/LimitQuery"},{"$ref":"#/components/parameters/CursorQuery"},{"$ref":"#/components/parameters/PlayerQuery"},{"$ref":"#/components/parameters/StatusQuery"}],"responses":{"200":{"description":"A page of matches.","headers":{"X-Request-Id":{"$ref":"#/components/headers/X-Request-Id"},"X-API-Lifecycle":{"$ref":"#/components/headers/X-API-Lifecycle"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MatchList"},"example":{"data":[{"id":2,"board":"board_1","team_size":3,"team1":[{"player_id":"discord:198374652000000001","player_name":"Ava","elo":1450},{"player_id":"discord:198374652000000002","player_name":"Ben","elo":1200},{"player_id":"custom:demo_1","player_name":"Demo Player","elo":1050}],"team2":[{"player_id":"discord:198374652000000003","player_name":"Cal","elo":1200},{"player_id":"discord:198374652000000004","player_name":"Dee","elo":900},{"player_id":"custom:Ava Custom","player_name":"Ava Custom","elo":1100}],"winner":"team2","status":"pending","applied":false,"submitted_at":"2026-07-25T10:00:00.000Z","submitted_by":"api"},{"id":1,"board":"board_1","team_size":1,"team1":[{"player_id":"discord:198374652000000001","player_name":"Ava","elo":1450}],"team2":[{"player_id":"discord:198374652000000002","player_name":"Ben","elo":1200}],"winner":"team1","status":"approved","applied":true,"submitted_at":"2026-07-24T10:00:00.000Z","submitted_by":"api"}],"has_more":false,"next_cursor":null}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"operationId":"recordMatch","summary":"Record an ELO or League match (asynchronous)","description":"**Returns `202 Accepted` and a handle, not a rank.** The match is enqueued for the bot that owns the scoring; on a board with a validation window it then sits in the Discord validation channel until a moderator clicks Approve. Poll `GET /submissions/{handle}` to learn the outcome.\n\n`201 Created` happens only on an auto-applying board (`auto_applies: true`) whose worker finished inside the ~3s wait budget. A timeout still returns `202`. Handle both.\n\n**The body depends on the board type, and each rejects the other's fields with a 422 naming the offending key:**\n\n- **ELO** takes `team1`, `team2` and `winner` (`team1` | `team2` | `draw`). It has no score field.\n- **League** takes `team1`, `team2`, `team1_score` and `team2_score`, and DERIVES the winner from the scoreline. Sending `winner` is refused.\n\nEach roster must carry **exactly** the board's `team_size` participants — except on a board with `fixed_teams: true`, where each side is exactly ONE `team_id` and the match is between the two team entities rather than their members. Anything else is a 422 `team_size_mismatch`.\n\nRatings are snapshotted when the match is **created**, not when it is approved.\n\nRequires the `matches:write` scope and an `Idempotency-Key` header. `sk_test_` keys are read-only.","tags":["Matches"],"security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BoardPath"},{"$ref":"#/components/parameters/IdempotencyKeyHeader"}],"requestBody":{"required":true,"description":"An ELO match or a League match — whichever the board is. The two shapes are mutually exclusive.","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/EloMatchBody"},{"$ref":"#/components/schemas/LeagueMatchBody"}],"description":"Send the ELO shape to an ELO board and the League shape to a League board."},"examples":{"elo_3v3":{"summary":"ELO, 3v3 (board_1)","description":"Exactly `team_size` participants per side. Mixed forms are fine: members, a custom player, a role.","value":{"team1":[{"discord_id":"198374652000000001"},{"discord_id":"198374652000000002"},{"name":"Demo Player","custom":true}],"team2":[{"discord_id":"198374652000000003"},{"discord_id":"198374652000000004"},{"role_id":"role_998877"}],"winner":"team1"}},"league_1v1":{"summary":"League, 1v1 (board_2)","description":"Both scores required, integers >= 0. The winner is derived; sending one is a 422.","value":{"team1":[{"discord_id":"198374652000000001"}],"team2":[{"discord_id":"198374652000000002"}],"team1_score":3,"team2_score":1}},"fixed_teams":{"summary":"ELO on a fixed-teams board","description":"One team entity per side, regardless of `team_size`. A draw is a legal outcome.","value":{"team1":[{"team_id":"team_5"}],"team2":[{"team_id":"team_6"}],"winner":"draw"}}}}}},"responses":{"201":{"description":"Applied. Only ever returned by a board with `auto_applies: true` whose worker finished inside the wait budget — `applied` is true and `match` is present.","headers":{"X-Request-Id":{"$ref":"#/components/headers/X-Request-Id"},"X-API-Lifecycle":{"$ref":"#/components/headers/X-API-Lifecycle"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"},"Idempotent-Replayed":{"$ref":"#/components/headers/Idempotent-Replayed"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WriteAccepted"},"example":{"handle":"sub_88214","board":"board_2","status":"approved","applied":true,"queued_at":"2026-07-26T12:00:02.881Z","match":{"id":2,"url":"https://scoreboards.dev/leaderboards/111222333444555666/board_2","applied":true}}}}},"202":{"description":"Queued. **Nothing has changed on the leaderboard.** `applied` is false and stays false until the submission is approved. This is the normal response.","headers":{"X-Request-Id":{"$ref":"#/components/headers/X-Request-Id"},"X-API-Lifecycle":{"$ref":"#/components/headers/X-API-Lifecycle"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"},"Idempotent-Replayed":{"$ref":"#/components/headers/Idempotent-Replayed"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WriteAccepted"},"example":{"handle":"sub_88213","board":"board_1","status":"queued","applied":false,"queued_at":"2026-07-26T12:00:01.412Z"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/IdempotencyConflict"},"413":{"$ref":"#/components/responses/PayloadTooLarge"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/boards/{board}/matches/{match_id}":{"get":{"operationId":"getMatch","summary":"Get one match with its rosters","description":"One match by its per-board id, with both full rosters. League matches carry `team1_score` and `team2_score`; ELO matches have no scoreline and omit both.\n\nRequires the `matches:read` scope.","tags":["Matches"],"security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BoardPath"},{"$ref":"#/components/parameters/MatchIdPath"}],"responses":{"200":{"description":"The match.","headers":{"X-Request-Id":{"$ref":"#/components/headers/X-Request-Id"},"X-API-Lifecycle":{"$ref":"#/components/headers/X-API-Lifecycle"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Match"},"examples":{"elo_team":{"summary":"A 3v3 ELO match awaiting review","description":"Rosters come from `match_players`; `applied` stays false until a moderator approves it.","value":{"id":2,"board":"board_1","team_size":3,"team1":[{"player_id":"discord:198374652000000001","player_name":"Ava","elo":1450},{"player_id":"discord:198374652000000002","player_name":"Ben","elo":1200},{"player_id":"custom:demo_1","player_name":"Demo Player","elo":1050}],"team2":[{"player_id":"discord:198374652000000003","player_name":"Cal","elo":1200},{"player_id":"discord:198374652000000004","player_name":"Dee","elo":900},{"player_id":"custom:Ava Custom","player_name":"Ava Custom","elo":1100}],"winner":"team2","status":"pending","applied":false,"submitted_at":"2026-07-25T10:00:00.000Z","submitted_by":"api"}},"league":{"summary":"An approved 1v1 League match","description":"`winner` was derived from the scoreline, not supplied by the caller.","value":{"id":1,"board":"board_2","team_size":1,"team1":[{"player_id":"discord:198374652000000001","player_name":"Ava","elo":null}],"team2":[{"player_id":"discord:198374652000000002","player_name":"Ben","elo":null}],"winner":"team1","team1_score":3,"team2_score":1,"status":"approved","applied":true,"submitted_at":"2026-07-25T11:00:00.000Z","submitted_by":"api"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/boards/{board}/scores":{"post":{"operationId":"submitScore","summary":"Submit a score to a Classic, Highscore or Time board (asynchronous)","description":"**Returns `202 Accepted` and a handle, not a new rank.** Like every /v1 write it enqueues, and on a board with a validation window the score waits for a moderator. Poll `GET /submissions/{handle}`.\n\n**Time boards take MILLISECONDS as an integer** — `1:30` is `90000`. This is the most likely integration bug on the whole API; the board's `write.score_unit` says so too.\n\nWhat the score does on approval is fixed by the board and is not a caller choice: Classic adds it to the running total, Highscore keeps the higher value, Time keeps the lower one.\n\nELO and League boards take matches, not scores, and answer 422 `board_type_mismatch` here.\n\nRequires the `scores:write` scope and an `Idempotency-Key` header. `sk_test_` keys are read-only.","tags":["Submissions"],"security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BoardPath"},{"$ref":"#/components/parameters/IdempotencyKeyHeader"}],"requestBody":{"required":true,"description":"One participant and one integer score.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScoreSubmissionBody"},"examples":{"discord_member":{"summary":"A Discord member","value":{"player":{"discord_id":"198374652000000001"},"score":145530}},"time_board":{"summary":"A Time board — milliseconds","description":"A run of 2 minutes 25.530 seconds. Sending `145.53` seconds would record 145 ms.","value":{"player":{"name":"Ava","custom":true},"score":145530}}}}}},"responses":{"201":{"description":"Applied. Only ever returned by a board with `auto_applies: true` whose worker finished inside the wait budget — `applied` is true and `match` points at the stored submission.","headers":{"X-Request-Id":{"$ref":"#/components/headers/X-Request-Id"},"X-API-Lifecycle":{"$ref":"#/components/headers/X-API-Lifecycle"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"},"Idempotent-Replayed":{"$ref":"#/components/headers/Idempotent-Replayed"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WriteAccepted"},"example":{"handle":"sub_88216","board":"board_7","status":"approved","applied":true,"queued_at":"2026-07-26T12:00:04.017Z","match":{"id":118,"url":"https://scoreboards.dev/leaderboards/111222333444555666/board_7","applied":true}}}}},"202":{"description":"Queued. **Nothing has changed on the leaderboard.** The score waits in the Discord validation channel until a moderator approves it. This is the normal response.","headers":{"X-Request-Id":{"$ref":"#/components/headers/X-Request-Id"},"X-API-Lifecycle":{"$ref":"#/components/headers/X-API-Lifecycle"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"},"Idempotent-Replayed":{"$ref":"#/components/headers/Idempotent-Replayed"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WriteAccepted"},"example":{"handle":"sub_88215","board":"board_3","status":"queued","applied":false,"queued_at":"2026-07-26T12:00:03.204Z"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/IdempotencyConflict"},"413":{"$ref":"#/components/responses/PayloadTooLarge"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/submissions/{handle}":{"get":{"operationId":"getSubmission","summary":"Resolve a write handle","description":"The only correct way to learn what happened to a write. `status` moves `queued` → `processing` → `pending_review` → `approved` | `rejected`, or `failed`.\n\nThe answer is read from the live match or submission row, not just from the queue, so it keeps up with a moderator approving hours later. Once the row exists, `match.id` links to it and `match.applied` says whether it is live on the leaderboard.\n\nHandles resolve for 30 days, then 404. Another owner's handle is an indistinguishable 404, so sequential handles cannot be probed across tenants.\n\nRequires the `boards:read` scope.","tags":["Submissions"],"security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/HandlePath"}],"responses":{"200":{"description":"The submission state.","headers":{"X-Request-Id":{"$ref":"#/components/headers/X-Request-Id"},"X-API-Lifecycle":{"$ref":"#/components/headers/X-API-Lifecycle"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Submission"},"example":{"handle":"sub_88213","status":"approved","board":"board_1","match":{"id":3,"url":"https://scoreboards.dev/leaderboards/111222333444555666/board_1","applied":true}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"429":{"$ref":"#/components/responses/TooManyRequests"},"500":{"$ref":"#/components/responses/InternalError"}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"An API key from the dashboard: `Authorization: Bearer sk_live_...`. Bound to one owner. `sk_test_` keys serve reads and refuse every write. During the beta a key can only be created by an owner whose access has been approved — request it from the API section of the Scoreboards dashboard."}},"schemas":{"Problem":{"title":"Problem","description":"Every /v1 failure, as `application/problem+json` (RFC 9457). Branch on `code`, never on `title` or `detail` — those are English prose for humans and may be reworded. The API is English-only and ignores `Accept-Language`.","type":"object","required":["type","title","status","detail","instance","code","request_id"],"additionalProperties":false,"properties":{"type":{"type":"string","format":"uri","description":"A stable URI for this error class, e.g. `https://scoreboards.dev/errors/board-type-mismatch`."},"title":{"type":"string","description":"Short human summary. Not machine-readable."},"status":{"type":"integer","minimum":400,"maximum":599,"description":"Repeats the HTTP status code."},"detail":{"type":"string","description":"What went wrong with THIS request, naming the offending field where there is one."},"instance":{"type":"string","description":"The path that failed, e.g. `/v1/boards/board_1/matches`."},"code":{"type":"string","enum":["invalid_request","authentication_failed","permission_denied","not_found","idempotency_conflict","rate_limited","board_type_mismatch","team_size_mismatch","duplicate_participant","unknown_player","board_locked","internal_error"],"description":"The machine-readable contract. Stable across rewordings."},"request_id":{"type":"string","description":"Correlation id, also returned as `X-Request-Id`. Quote it to support."}},"examples":[{"type":"https://scoreboards.dev/errors/board-type-mismatch","title":"Wrong payload for this board type","status":422,"detail":"`board_3` is a Highscore board, which records scores rather than matches. POST to /v1/boards/board_3/scores instead.","instance":"/v1/boards/board_3/matches","code":"board_type_mismatch","request_id":"req_8f2a1c4d6b90"}]},"Board":{"title":"Board","description":"A leaderboard, plus the derived contract for writing to it. `id` is the immutable `game_name` slug (`board_123`), not the display name — renaming a board never changes it.","type":"object","properties":{"id":{"type":"string","description":"Immutable public id. Use it in every path; it never changes, even when `display_name` does."},"display_name":{"type":"string"},"type":{"type":"string","enum":["Classic","Highscore","Time","ELO","League","Rating"],"description":"Decides which write endpoint applies. ELO and League take matches; Classic, Highscore and Time take scores; Rating takes neither in v0."},"sort":{"type":"string","enum":["desc","asc"]},"team_size":{"type":"integer","description":"Participants per side on a match board. Each roster must carry exactly this many — unless `fixed_teams` is true, in which case each side is exactly ONE team entity."},"fixed_teams":{"type":"boolean","description":"True when the board plays fixed team entities against each other. A match then names two `team_id`s, not `2 × team_size` players."},"url":{"type":"string"},"write":{"anyOf":[{"$ref":"#/components/schemas/WriteContract"},{"type":"null"}],"description":"What to POST. `null` on Rating boards, which have no v0 write endpoint."},"validation_window_hours":{"type":"integer","description":"Hours a submission waits for a moderator. `0` auto-applies; `999` means manual review with no countdown."},"auto_applies":{"type":"boolean","description":"True only when `validation_window_hours` is 0. When false, a write returns 202 and NOTHING changes on the leaderboard until a human clicks Approve in Discord."}},"required":["id","display_name","type","sort","team_size","fixed_teams","url","write","validation_window_hours","auto_applies"],"additionalProperties":false},"WriteContract":{"title":"Write contract","description":"What this board accepts, derived from its type and sort. `operator` is read-only — it is fixed when the board is created and cannot be overridden per submission.","type":"object","properties":{"endpoint":{"type":"string"},"requires":{"type":"array","items":{"type":"string"},"description":"Fields that must be present in the write body."},"rejects":{"type":"array","items":{"type":"string"},"description":"Fields this board type refuses. Sending one is a 422 naming the offending key."},"operator":{"type":"string","enum":["elo","league_points","accumulate","keep_highest","keep_lowest"],"description":"How an approved submission is applied. Derived from the board type and sort; never a caller input."},"score_unit":{"type":"string","const":"milliseconds","description":"Present on Time boards only. Scores are integer MILLISECONDS — `1:30` is `90000`."},"k_factor":{"type":"integer"},"start_rating":{"type":"integer"},"points_win":{"type":"integer"},"points_draw":{"type":"integer"},"points_loss":{"type":"integer"}},"required":["endpoint","requires","rejects","operator"],"additionalProperties":false},"Entry":{"title":"Entry","description":"One participant's standing. `elo` is present on ELO boards and `score` on every other type; `wins`/`draws`/`losses` on ELO and League, `submissions` on Classic, Highscore and Time.","type":"object","properties":{"rank":{"type":"integer","description":"1-based, and correct on every page — a resumed page derives it with a COUNT rather than numbering from 1."},"player_id":{"type":"string","description":"Namespaced: `discord:<snowflake>`, `custom:<name>`, `role:<id>` or `team:<id>`. Pass it back verbatim to `/entries/{player_id}` or `?player=`."},"player_name":{"type":"string"},"score":{"type":["number","null"]},"elo":{"type":["number","null"]},"wins":{"type":["integer","null"]},"draws":{"type":["integer","null"]},"losses":{"type":["integer","null"]},"submissions":{"type":["integer","null"]},"last_sub":{"type":["string","null"],"description":"RFC 3339 UTC. `null` when the participant has never submitted."}},"required":["rank","player_id","player_name","last_sub"],"additionalProperties":false},"MatchParticipant":{"title":"Match participant","type":"object","properties":{"player_id":{"type":"string"},"player_name":{"type":"string"},"elo":{"type":["number","null"],"description":"The rating this participant held when the match was SUBMITTED, not when it was approved. Ten matches pushed back to back all carry pre-tournament ratings."}},"required":["player_id","player_name"],"additionalProperties":false},"Match":{"title":"Match","description":"One recorded match on an ELO or League board. `id` is `old_id` — per-board, so match URLs are always board-nested.","type":"object","properties":{"id":{"type":"integer","description":"Per-board match number. Not globally unique; always address it under its board."},"board":{"type":"string"},"team_size":{"type":"integer"},"team1":{"type":"array","items":{"$ref":"#/components/schemas/MatchParticipant"}},"team2":{"type":"array","items":{"$ref":"#/components/schemas/MatchParticipant"}},"winner":{"anyOf":[{"type":"string","enum":["team1","team2","draw"]},{"type":"null"}],"description":"On a League board this is DERIVED from the scoreline, never supplied by the caller."},"team1_score":{"type":["number","null"],"description":"League boards only. Absent on ELO boards, which have no scoreline."},"team2_score":{"type":["number","null"],"description":"League boards only. Absent on ELO boards, which have no scoreline."},"status":{"type":"string","enum":["pending","approved","rejected","adjusted","set"],"description":"`pending` until reviewed. `adjusted` and `set` are moderator edits made in Discord."},"applied":{"type":"boolean","description":"True only once the result is live on the leaderboard (`approved`, `adjusted` or `set`)."},"submitted_at":{"type":["string","null"],"description":"RFC 3339 UTC, when the match was submitted — not when it was approved."},"submitted_by":{"type":["string","null"],"description":"The Discord user who submitted it, or `api` for a submission made through this API."}},"required":["id","board","team_size","team1","team2","winner","status","applied","submitted_at"],"additionalProperties":false},"MatchRef":{"title":"Match reference","description":"The match a write produced, once the worker has created it.","type":"object","properties":{"id":{"type":"integer"},"url":{"type":"string"},"applied":{"type":"boolean"}},"required":["id","url","applied"],"additionalProperties":false},"DiscordParticipant":{"title":"Discord member","description":"A real Discord member. Must be 17-20 digits: a shorter id is stored as a CUSTOM player of that name rather than as the member, so it is refused here instead of creating a row nobody owns.","type":"object","properties":{"discord_id":{"type":"string","pattern":"^\\d{17,20}$"}},"required":["discord_id"],"additionalProperties":false},"CustomParticipant":{"title":"Custom (non-Discord) player","description":"A player who is not a Discord member. `custom: true` is required and explicit — it is what stops a Discord id typed into `name` from creating a lookalike row. The internal key is built for you.","type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":100},"custom":{"type":"boolean","const":true}},"required":["name","custom"],"additionalProperties":false},"TeamParticipant":{"title":"Fixed team","description":"A fixed team entity. Valid only on a board with `fixed_teams: true`, one per side.","type":"object","properties":{"team_id":{"type":"string","pattern":"^team_\\d+$"}},"required":["team_id"],"additionalProperties":false},"RoleParticipant":{"title":"Discord role","description":"A Discord role competing as a single entity. Guild-owned boards only.","type":"object","properties":{"role_id":{"type":"string","pattern":"^role_\\d+$"}},"required":["role_id"],"additionalProperties":false},"Participant":{"title":"Participant","description":"Exactly one of the four forms. Anything else is a 422 `unknown_player`.","oneOf":[{"$ref":"#/components/schemas/DiscordParticipant"},{"$ref":"#/components/schemas/CustomParticipant"},{"$ref":"#/components/schemas/TeamParticipant"},{"$ref":"#/components/schemas/RoleParticipant"}]},"BoardList":{"title":"Board page","description":"Cursor-paginated. Pass `next_cursor` back as `?cursor=` for the next page.","type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Board"}},"has_more":{"type":"boolean","description":"True when another page exists. Do not infer it from a short page — a page can be short and still have more."},"next_cursor":{"type":["string","null"],"description":"Pass back as `?cursor=`. `null` on the last page. Opaque: never parse it, never build one."}},"required":["data","has_more","next_cursor"],"additionalProperties":false},"EntryList":{"title":"Entry page","description":"Cursor-paginated on the composite `(score, player_id)`, so a page boundary that falls inside a block of tied scores resumes mid-tie instead of skipping everyone on that score.","type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Entry"}},"has_more":{"type":"boolean","description":"True when another page exists. Do not infer it from a short page — a page can be short and still have more."},"next_cursor":{"type":["string","null"],"description":"Pass back as `?cursor=`. `null` on the last page. Opaque: never parse it, never build one."}},"required":["data","has_more","next_cursor"],"additionalProperties":false},"MatchList":{"title":"Match page","description":"Cursor-paginated, newest match first.","type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Match"}},"has_more":{"type":"boolean","description":"True when another page exists. Do not infer it from a short page — a page can be short and still have more."},"next_cursor":{"type":["string","null"],"description":"Pass back as `?cursor=`. `null` on the last page. Opaque: never parse it, never build one."}},"required":["data","has_more","next_cursor"],"additionalProperties":false},"EloMatchBody":{"title":"ELO match — two rosters and a winner","description":"The body an **ELO** board takes. An ELO board has no score field, so `team1_score`, `team2_score` and `score` are refused with a 422 naming the key. Each roster carries exactly the board's `team_size` — or exactly one `team_id` when `fixed_teams` is true.","type":"object","properties":{"team1":{"type":"array","items":{"$ref":"#/components/schemas/Participant"},"minItems":1,"maxItems":8},"team2":{"type":"array","items":{"$ref":"#/components/schemas/Participant"},"minItems":1,"maxItems":8},"winner":{"type":"string","enum":["team1","team2","draw"],"description":"Words, not codes. `draw` is a legal outcome and applies the zero-sum ELO delta for a tie."}},"required":["team1","team2","winner"],"additionalProperties":false},"LeagueMatchBody":{"title":"League match — two rosters and both scores","description":"The body a **League** board takes. League DERIVES the winner from the scoreline, so a caller-supplied `winner` is refused with a 422 — accepting it would let the caller contradict their own scores. Points come from the board's `points_win`/`points_draw`/`points_loss`.","type":"object","properties":{"team1":{"type":"array","items":{"$ref":"#/components/schemas/Participant"},"minItems":1,"maxItems":8},"team2":{"type":"array","items":{"$ref":"#/components/schemas/Participant"},"minItems":1,"maxItems":8},"team1_score":{"type":"integer","minimum":0,"maximum":9007199254740991},"team2_score":{"type":"integer","minimum":0,"maximum":9007199254740991}},"required":["team1","team2","team1_score","team2_score"],"additionalProperties":false},"ScoreSubmissionBody":{"title":"Score submission","description":"The body a **Classic**, **Highscore** or **Time** board takes. On a Time board `score` is integer MILLISECONDS — `1:30` is `90000`. Sending seconds is the single most likely integration bug on this API. What happens on approval is fixed by the board: Classic accumulates, Highscore keeps the maximum, Time keeps the minimum.","type":"object","properties":{"player":{"$ref":"#/components/schemas/Participant"},"score":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Integer. On a **Time** board this is MILLISECONDS: `1:30` is `90000`, not `90` and not `1.5`."}},"required":["player","score"],"additionalProperties":false},"WriteAccepted":{"title":"Write acknowledgement","description":"What a POST returns. It is a receipt, not a result: `applied` is false on every board with a validation window, and stays false until a moderator approves the submission in Discord.","type":"object","properties":{"handle":{"type":"string","pattern":"^sub_\\d+$","description":"Poll `GET /submissions/{handle}` with this. Resolves for 30 days, then 404s."},"board":{"type":"string"},"status":{"type":"string","enum":["queued","processing","pending_review","approved","rejected","failed"],"description":"On a 202 this is `queued` (or, rarely, `failed` — the worker ran and threw inside the wait budget); `approved` only on a 201. `pending_review` never appears here: it is what the HANDLE reports once the worker has posted the submission for review. Poll the handle for that."},"applied":{"type":"boolean","description":"True only when the result is already live on the leaderboard. False on every 202."},"queued_at":{"type":"string","description":"RFC 3339 UTC, when the write was enqueued. A replayed response repeats the ORIGINAL value."},"match":{"anyOf":[{"$ref":"#/components/schemas/MatchRef"},{"type":"null"}],"description":"Present once the worker has created the match. Absent on a plain 202, which is the normal case."}},"required":["handle","board","status","applied","queued_at"],"additionalProperties":false},"Submission":{"title":"Submission","description":"The state of a write handle, read from the LIVE row rather than the queue — so it keeps up with a moderator approving or rejecting the match hours after the queue entry finished.","type":"object","properties":{"handle":{"type":"string","pattern":"^sub_\\d+$"},"status":{"type":"string","enum":["queued","processing","pending_review","approved","rejected","failed"]},"board":{"type":"string"},"match":{"anyOf":[{"$ref":"#/components/schemas/MatchRef"},{"type":"null"}]}},"required":["handle","status","board","match"],"additionalProperties":false}},"parameters":{"BoardPath":{"name":"board","in":"path","required":true,"description":"The board id — `metadata.game_name`, e.g. `board_1`. Immutable, and never the display name. Older boards may carry a free-form (even non-ASCII) name, so URL-encode it.","schema":{"type":"string","minLength":1,"maxLength":100},"example":"board_1"},"PlayerIdPath":{"name":"player_id","in":"path","required":true,"description":"A namespaced player id as returned by the entries endpoints: `discord:<snowflake>`, `custom:<name>`, `role:<id>` or `team:<id>`. A bare Discord snowflake is also accepted, so a caller can paste one straight in. Anything else is a 400.","schema":{"type":"string","minLength":1,"maxLength":160},"example":"discord:198374652000000001"},"MatchIdPath":{"name":"match_id","in":"path","required":true,"description":"The per-board match number (`Match.id`). Not globally unique.","schema":{"type":"integer","exclusiveMinimum":0},"example":2},"HandlePath":{"name":"handle","in":"path","required":true,"description":"The write handle returned by a POST, e.g. `sub_88213`. Resolves for 30 days.","schema":{"type":"string","pattern":"^sub_\\d+$"},"example":"sub_88213"},"LimitQuery":{"name":"limit","in":"query","required":false,"description":"Page size. Default 50, maximum 200. Out of range is a 422.","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}},"CursorQuery":{"name":"cursor","in":"query","required":false,"description":"Opaque cursor. Pass the previous page's `next_cursor` back verbatim, or omit it to start at the top. Cursors are composite `(value, tie-breaker)` so a page boundary inside a block of tied scores resumes mid-tie. A malformed cursor is a 400; cursors are NOT signed, so a hand-built one is accepted and silently starts you at an arbitrary position. Never synthesize one.","schema":{"type":"string","minLength":1,"maxLength":512},"example":"eyJzIjoxMjAwLCJwIjoiMTk4Mzc0NjUyMDAwMDAwMDAyIn0"},"PlayerQuery":{"name":"player","in":"query","required":false,"description":"Only matches this participant took part in. Same namespaced form as `Entry.player_id`; a bare snowflake is accepted. An unusable value is a 400, not an empty list.","schema":{"type":"string","minLength":1,"maxLength":160},"example":"discord:198374652000000001"},"StatusQuery":{"name":"status","in":"query","required":false,"description":"Only matches in this review state. `adjusted` and `set` are moderator edits made in Discord.","schema":{"type":"string","enum":["pending","approved","rejected","adjusted","set"]},"example":"approved"},"IdempotencyKeyHeader":{"name":"Idempotency-Key","in":"header","required":true,"description":"**Required on every POST.** A value you generate per logical write (a UUID is ideal) and reuse when you retry. Same key + same body replays the stored response byte for byte; same key + a DIFFERENT body is a 409, so a caller who edits and retries never silently receives the old result. Records are kept for at least 24h — that is a floor, not an expiry, so treat a key as permanently spent rather than recycling one. Omitting the header is a 400 — retrying without one risks a duplicate match in someone's validation channel.","schema":{"type":"string","minLength":1,"maxLength":255},"example":"8f14e45f-ea6a-4e2b-9c1a-77c0e4d51a10"}},"headers":{"RateLimit-Limit":{"description":"Requests allowed per minute for this key: 600 read, 60 write. Read and write quotas are separate buckets.","schema":{"type":"integer"},"example":600},"RateLimit-Remaining":{"description":"Whole requests left in the current bucket.","schema":{"type":"integer"},"example":128},"RateLimit-Reset":{"description":"DELTA-SECONDS until the quota is whole again. A duration, **not** a timestamp (IETF draft semantics).","schema":{"type":"integer"},"example":42},"X-RateLimit-Limit":{"description":"Legacy spelling of `RateLimit-Limit`, same value.","schema":{"type":"integer"},"example":600},"X-RateLimit-Remaining":{"description":"Legacy spelling of `RateLimit-Remaining`, same value.","schema":{"type":"integer"},"example":128},"X-RateLimit-Reset":{"description":"The same instant as `RateLimit-Reset`, as a UNIX TIMESTAMP in seconds. **Not** a delay (legacy semantics).","schema":{"type":"integer"},"example":1769342442},"Retry-After":{"description":"Delta-seconds to wait before retrying. Sent on 429, and on the 409 that means an identical request is still in flight.","schema":{"type":"integer"},"example":42},"X-Request-Id":{"description":"Correlation id for this request, echoed on every response including errors. An inbound `X-Request-Id` matching `[A-Za-z0-9._-]{1,64}` is honoured.","schema":{"type":"string"},"example":"req_8f2a1c4d6b90"},"X-API-Lifecycle":{"description":"Always `beta` today. Sent on EVERY /v1 response — including a 401 and a 404 — so a client learns the contract is provisional on its first call, without reading any documentation. The day it stops saying `beta`, the surface has been frozen and breaking changes will require a new version prefix; treat a change in this value as the signal to re-read the changelog.","schema":{"type":"string"},"example":"beta"},"Idempotent-Replayed":{"description":"`true` when this response was replayed from a stored idempotency record rather than executed. Only set on POST responses.","schema":{"type":"boolean"},"example":false}},"responses":{"BadRequest":{"description":"`invalid_request` at the transport level: a body that is not JSON, a missing or unusable `Idempotency-Key`, a malformed cursor, or a player id that is not one this API issues. A body that parsed but was semantically wrong is a 422 instead.","headers":{"X-Request-Id":{"$ref":"#/components/headers/X-Request-Id"},"X-API-Lifecycle":{"$ref":"#/components/headers/X-API-Lifecycle"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"},"example":{"type":"https://scoreboards.dev/errors/invalid-request","title":"Invalid request","status":400,"detail":"Missing Idempotency-Key. Every POST to /v1 must carry an Idempotency-Key header — a value you generate per logical write (a UUID is ideal) and reuse when you retry. Retrying without one risks a duplicate match.","instance":"/v1/boards/board_1/matches","code":"invalid_request","request_id":"req_8f2a1c4d6b90"}}}},"Unauthorized":{"description":"Missing, malformed, unknown or revoked API key. Present the key as `Authorization: Bearer sk_live_...`. Authentication runs before the rate limiter, so this response carries no `RateLimit-*` headers.","headers":{"X-Request-Id":{"$ref":"#/components/headers/X-Request-Id"},"X-API-Lifecycle":{"$ref":"#/components/headers/X-API-Lifecycle"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"},"example":{"type":"https://scoreboards.dev/errors/authentication-failed","title":"Authentication failed","status":401,"detail":"Missing API key. Provide a valid key as `Authorization: Bearer sk_live_...`. Keys are created in the dashboard.","instance":"/v1/boards","code":"authentication_failed","request_id":"req_8f2a1c4d6b90"}}}},"Forbidden":{"description":"The key authenticated but may not do this: it is missing the scope the operation needs, or it is a `sk_test_` key, which is read-only and is never granted a write scope.","headers":{"X-Request-Id":{"$ref":"#/components/headers/X-Request-Id"},"X-API-Lifecycle":{"$ref":"#/components/headers/X-API-Lifecycle"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"},"example":{"type":"https://scoreboards.dev/errors/permission-denied","title":"Permission denied","status":403,"detail":"Test-mode keys are read-only, so matches:write is never granted. Use a sk_live_ key to write.","instance":"/v1/boards/board_1/matches","code":"permission_denied","request_id":"req_8f2a1c4d6b90"}}}},"NotFound":{"description":"No such resource **for this key's owner**. A board that exists but belongs to somebody else returns exactly this, indistinguishable from one that does not exist — confirming existence would let a caller enumerate other people's boards.","headers":{"X-Request-Id":{"$ref":"#/components/headers/X-Request-Id"},"X-API-Lifecycle":{"$ref":"#/components/headers/X-API-Lifecycle"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"},"example":{"type":"https://scoreboards.dev/errors/not-found","title":"Not found","status":404,"detail":"No board `board_99` exists for this API key's owner.","instance":"/v1/boards/board_99","code":"not_found","request_id":"req_8f2a1c4d6b90"}}}},"IdempotencyConflict":{"description":"The `Idempotency-Key` was already used for a request with a DIFFERENT body, or an identical request is still in flight (that variant carries `Retry-After`). Use a new key for a new request, or resend the original body byte for byte.","headers":{"X-Request-Id":{"$ref":"#/components/headers/X-Request-Id"},"X-API-Lifecycle":{"$ref":"#/components/headers/X-API-Lifecycle"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"},"Retry-After":{"$ref":"#/components/headers/Retry-After"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"},"example":{"type":"https://scoreboards.dev/errors/idempotency-conflict","title":"Idempotency key reused with a different body","status":409,"detail":"Idempotency-Key 8f14e45f-ea6a-4e2b-9c1a-77c0e4d51a10 was already used for a request with a different body. Use a new key for a new request, or resend the original body byte for byte.","instance":"/v1/boards/board_1/matches","code":"idempotency_conflict","request_id":"req_8f2a1c4d6b90"}}}},"PayloadTooLarge":{"description":"/v1 accepts at most 64 KB per request. The largest legal body — an 8v8 match — is well under a kilobyte.","headers":{"X-Request-Id":{"$ref":"#/components/headers/X-Request-Id"},"X-API-Lifecycle":{"$ref":"#/components/headers/X-API-Lifecycle"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"},"example":{"type":"https://scoreboards.dev/errors/invalid-request","title":"Invalid request","status":413,"detail":"The request body is too large. /v1 accepts at most 64 KB.","instance":"/v1/boards/board_1/matches","code":"invalid_request","request_id":"req_8f2a1c4d6b90"}}}},"UnprocessableEntity":{"description":"The request was understood and refused. `code` says which rule: `invalid_request` (schema, including a `.strict()` body naming the unrecognized key), `board_type_mismatch`, `team_size_mismatch`, `duplicate_participant` or `unknown_player`.","headers":{"X-Request-Id":{"$ref":"#/components/headers/X-Request-Id"},"X-API-Lifecycle":{"$ref":"#/components/headers/X-API-Lifecycle"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"},"example":{"type":"https://scoreboards.dev/errors/invalid-request","title":"Invalid request","status":422,"detail":"team1_score, team2_score: not allowed on this request","instance":"/v1/boards/board_1/matches","code":"invalid_request","request_id":"req_8f2a1c4d6b90"}}}},"TooManyRequests":{"description":"Per-key token bucket: 600 reads and 60 writes per minute, tracked as separate buckets so a burst of writes cannot starve the polling that watches for their result. Rate limiting is evaluated BEFORE idempotency, so a 429 is never a replay.","headers":{"X-Request-Id":{"$ref":"#/components/headers/X-Request-Id"},"X-API-Lifecycle":{"$ref":"#/components/headers/X-API-Lifecycle"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"},"Retry-After":{"$ref":"#/components/headers/Retry-After"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"},"example":{"type":"https://scoreboards.dev/errors/rate-limited","title":"Rate limit exceeded","status":429,"detail":"Too many write requests for this API key: the limit is 60 per 60s. Retry in 4s.","instance":"/v1/boards/board_1/matches","code":"rate_limited","request_id":"req_8f2a1c4d6b90"}}}},"InternalError":{"description":"Something failed on our side. The detail is deliberately generic — quote `request_id`, which joins your report to our log line.","headers":{"X-Request-Id":{"$ref":"#/components/headers/X-Request-Id"},"X-API-Lifecycle":{"$ref":"#/components/headers/X-API-Lifecycle"},"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"},"example":{"type":"https://scoreboards.dev/errors/internal-error","title":"Internal error","status":500,"detail":"An unexpected error occurred. Quote the request_id if you contact support.","instance":"/v1/boards/board_1/entries","code":"internal_error","request_id":"req_8f2a1c4d6b90"}}}}}}}