The API is in beta
Access is approved per server. Open your dashboard, pick the API section for the server you administer, and request access there; once it is granted you can create keys. While v0 is in beta, endpoints and response fields can still change.
⚠️ This API is in BETA
Beta is not a disclaimer here. It is three facts, and each one changes a decision you are about to make:
- Access is approval-only. You cannot create a key until the server (or personal workspace) you are integrating has been approved. Request it from the API section of your dashboard at https://scoreboards.dev/dashboard — see §1.
- The surface can still change. Fields get added and shapes get adjusted in response to what the first integrations actually need.
- A breaking change can ship inside
/v1without a new version prefix while the beta lasts. Pin the document version you built against (info.version, currently0.1.0, deliberately pre-1.0).
Every response says so on the wire: X-API-Lifecycle: beta, on a 200, a
401 and a 404 alike. The day that header stops saying beta, the surface
is frozen and breaking changes need a new prefix.
Approval-only access exists because of point 3: while the contract can still move, keeping the number of integrations small keeps the blast radius of a change knowable. Assume a breaking change can reach you without warning — there is no changelog and no announcement list yet — so pin what you depend on and re-read this page before you upgrade. If that trade is wrong for you, wait for 1.0.
The Scoreboards API is a REST API over the leaderboards your Discord server (or
personal workspace) already runs: read the standings, read match history, and
record new matches and scores from your own code. It speaks JSON over HTTPS at
https://api.scoreboards.dev/v1, authenticates with a bearer key you mint in
the dashboard, and every response is snake_case with RFC 3339 UTC timestamps.
The one thing that surprises people: writes are queued. A POST does not
change the standings and does not return a rank. It returns 202 Accepted
and a handle, because on most boards the match lands in your Discord
validation channel and nothing moves until a human clicks Approve. You poll the
handle to find out what happened. There is one exception, covered below: a
board configured to apply results immediately can answer 201 Created if the
work finishes inside about three seconds.
If you build on the assumption that POST → standings changed, you will build
something broken. Read §6 Polling the handle before you
ship.
The API is English-only. Error text, field names and endpoint paths never
change with Accept-Language, so an error string is the same thing to search
for in every environment.
Every endpoint in v0
| Method | Path | Scope |
|---|---|---|
GET | /v1/boards | boards:read |
GET | /v1/boards/{board} | boards:read |
GET | /v1/boards/{board}/entries | entries:read |
GET | /v1/boards/{board}/entries/{player_id} | entries:read |
GET | /v1/boards/{board}/matches | matches:read |
GET | /v1/boards/{board}/matches/{match_id} | matches:read |
POST | /v1/boards/{board}/matches | matches:write |
POST | /v1/boards/{board}/scores | scores:write |
GET | /v1/submissions/{handle} | boards:read |
That is the whole surface. Anything not in this table does not exist in v0 — see §10.
Where the full reference lives. This page teaches the shape of an integration; the field-by-field contract is generated from the code and published two ways:
- the rendered reference at https://scoreboards.dev/docs/api — every
operation, parameter, schema and example, plus a copyable
curlper endpoint; - the OpenAPI 3.1 document at https://api.scoreboards.dev/openapi.json — no key needed, CORS-readable, and the input to any client generator you like.
If this page and that document ever disagree, the document is right: it is projected out of the same zod schemas the handlers validate with.
1. Get access, then a key
Step one is approval, and it is not instant. During the beta a key can only be created by an owner — one Discord server, or one personal workspace — whose access has been approved.
- Open the dashboard at https://scoreboards.dev/dashboard and pick the server (or your personal workspace). You need Manage Server on it; for a personal workspace, you need to own it.
- Go to Settings → API.
- Press Request access and say what you are building. That one sentence is the whole application — it goes to a maintainer, who approves or declines it.
- When it is approved, the same API section gains the key UI. Nothing else changes and there is nothing to install.
There is nowhere else to ask and nobody to message: the request goes through the
dashboard, and the answer comes back there. Until an owner is approved they have
no keys, so every call is a 401.
Then mint the key, in that same Settings → API section: give it a label, pick Live key or Test key (read-only), choose Read only or Read and write, and press Create key.
The secret is shown once, at creation. Only a SHA-256 digest is stored, so
there is no "show it again" — copy it now or revoke it and mint another. You may
hold up to 10 active keys per owner, which is what makes rotation zero-downtime:
mint the new one, move your integration over, revoke the old one. Revoking takes
effect immediately; anything still using that key starts getting 401.
Approval can also be withdrawn, and withdrawing it revokes that owner's live keys in the same step — there is no "access revoked but the keys still work" state.
A key is bound to one owner — one Discord server, or one personal
workspace — and can address every board that owner has. It can never see
anybody else's board: another owner's board id returns 404, exactly like a
board id that does not exist.
Access level → scopes
| Access level | Scopes on the key |
|---|---|
| Read only | boards:read, entries:read, matches:read |
| Read and write | the three above, plus matches:write, scores:write |
Scopes are checked exactly, with no implication: matches:write does not grant
reads. Missing one is a 403 that names it.
sk_live_ vs sk_test_. Test-mode keys (sk_test_…) serve every read and
refuse every write:
{
"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_5/matches",
"code": "permission_denied",
"request_id": "req_9f50ee9092ad"
}The Key type selector decides the prefix, and the Access level selector
decides the scopes on the row. They are not the same switch: a test key keeps
whatever scopes you picked, and every write it attempts is still refused, so
pair sk_test_ with an integration you only want to read.
Never put a key in client-side code. /v1 sends no CORS headers at all —
not a restrictive origin, none — so a browser cannot call it. That is
deliberate: a secret key in a bundle is a leaked key.
2. Your first call
export SB_KEY=sk_live_your_key_here
curl -s "https://api.scoreboards.dev/v1/boards?limit=3" \
-H "Authorization: Bearer $SB_KEY"{
"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": true,
"next_cursor": "eyJzIjozLCJwIjoiYm9hcmRfMyJ9"
}Drop ?limit=3 to get up to 50 boards per page; next_cursor walks the rest
(see §3 for how cursors work).
That is the whole integration contract, per board, without reading any prose:
id— the board id you put in every path. It is the board's immutable slug — usuallyboard_<n>— and not its display name, which owners rename freely. Read it from this response rather than deriving it from a name.write.endpoint— where this board's writes go. Boards that keep a result (ELO,League) take/matches; boards that keep a score (Classic,Highscore,Time) take/scores.write.requires/write.rejects— the exact field names. Sending a rejected field is a422that names it; the API never silently ignores a key it does not know.write.operator— how an approved write is applied:elo,league_points,accumulate(Classic adds to the running total), orkeep_highest/keep_lowest(Highscore and Time keep the better value — which one depends on the board'ssort:desc→ highest,asc→ lowest). It is derived from the board's configuration and read-only — you cannot override it per submission.write.score_unit— present only onTimeboards, where it is always"milliseconds".validation_window_hours/auto_applies— whether a human has to approve.auto_applies: true(window0) is the only case where a write can reach the standings without someone clicking Approve.999means manual review with no countdown shown.
Fetch one board the same way:
curl -s https://api.scoreboards.dev/v1/boards/board_1 \
-H "Authorization: Bearer $SB_KEY"Look at the headers once, too (curl -i), because two of them are on every
single response — success or failure, authenticated or not:
X-API-Lifecycle: beta
X-Request-Id: req_9f50ee9092adX-API-Lifecycle is the beta notice in machine-readable form: your code can
assert on it, and the day it stops saying beta is the day this contract is
frozen. X-Request-Id is the correlation id — it is repeated in the
request_id field of every error body, and quoting it is what lets support find
the exact log line for your failed call.
3. Read the standings
curl -s "https://api.scoreboards.dev/v1/boards/board_1/entries?limit=3" \
-H "Authorization: Bearer $SB_KEY"{
"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
},
{
"rank": 3,
"player_id": "discord:198374652000000003",
"player_name": "Cal",
"last_sub": "2026-07-22T12:00:00.000Z",
"elo": 1200,
"wins": 3,
"draws": 2,
"losses": 4
}
],
"has_more": true,
"next_cursor": "eyJzIjoxMjAwLCJwIjoiMTk4Mzc0NjUyMDAwMDAwMDAzIn0"
}Which value fields an entry carries depends on the board type:
| Board type | Value field | Counters |
|---|---|---|
ELO | elo | wins, draws, losses |
League | score (league points) | wins, draws, losses |
Classic, Highscore, Time | score | submissions |
Rating boards store one row per submission rather than per player, so they
have no ranked entries in v0 and return 422 board_type_mismatch.
Player ids are namespaced
Every player_id on the wire carries its kind:
| Wire form | What it is |
|---|---|
discord:198374652000000001 | a Discord member, by snowflake |
custom:Ava Custom | a non-Discord player, by name |
role:998877 | a Discord role competing as one entity |
team:5 | a fixed-team entity |
Pass those straight back into /entries/{player_id} and ?player=
(percent-encode as usual — custom:Ava%20Custom). A bare snowflake is also
accepted on input, so you can paste a Discord id in directly.
Do not build these ids by hand from your own database. Send the structured participant forms on writes (§5) and let the API derive the id — the internal key for a custom player is not what you would guess, and guessing it creates a duplicate row the real player never sees.
Cursor pagination
limit defaults to 50 and caps at 200. When has_more is true, pass
next_cursor back verbatim:
curl -s "https://api.scoreboards.dev/v1/boards/board_1/entries?limit=3&cursor=eyJzIjoxMjAwLCJwIjoiMTk4Mzc0NjUyMDAwMDAwMDAzIn0" \
-H "Authorization: Bearer $SB_KEY"{
"data": [
{
"rank": 4,
"player_id": "custom:Ava Custom",
"player_name": "Ava Custom",
"last_sub": "2026-07-24T12:00:00.000Z",
"elo": 1100,
"wins": 1,
"draws": 0,
"losses": 1
},
{
"rank": 5,
"player_id": "custom:demo_1",
"player_name": "Demo Player",
"last_sub": "2026-07-25T12:00:00.000Z",
"elo": 1050,
"wins": 0,
"draws": 0,
"losses": 0
},
{
"rank": 6,
"player_id": "role:998877",
"player_name": "The Regulars",
"last_sub": "2026-07-25T13:00:00.000Z",
"elo": 1010,
"wins": 1,
"draws": 1,
"losses": 1
}
],
"has_more": true,
"next_cursor": "eyJzIjoxMDEwLCJwIjoicm9sZV85OTg4NzcifQ"
}The cursor is opaque and composite — it encodes both the score and the player
id, so a page boundary that falls in the middle of a tie (ranks 2 and 3 above
are both on 1200) resumes exactly where the previous page stopped. Treat it as
opaque: pass next_cursor back verbatim. A cursor that is not well-formed is a
400, but a hand-built one is not rejected — cursors are not signed, so
synthesising one just drops you at an arbitrary position with no error to tell
you. There is no offset, on purpose — a leaderboard mutates between requests
and offset paging double-counts.
One player, without paging
To get a single player's rank and score — the "where am I?" lookup — address them directly:
curl -s "https://api.scoreboards.dev/v1/boards/board_1/entries/discord:198374652000000001" \
-H "Authorization: Bearer $SB_KEY"{
"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
}The rank is computed against the whole board, so it is correct without reading a
single page. A player with no entry on that board is a 404.
Match history
ELO and League boards also expose their matches, newest first. Any other
board type returns 422 board_type_mismatch.
curl -s "https://api.scoreboards.dev/v1/boards/board_1/matches?player=custom:demo_1&limit=1" \
-H "Authorization: Bearer $SB_KEY"{
"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"
}
],
"has_more": false,
"next_cursor": null
}idis the per-board match number, the one shown in Discord. Use it withGET /v1/boards/{board}/matches/{match_id}.statusis one ofpending,approved,rejected,adjusted,set;appliedsays whether the result is live on the leaderboard.- The roster
elois the rating snapshotted when the match was submitted, not when it was approved — see §4. - League matches additionally carry
team1_scoreandteam2_score; on an ELO board those fields are absent, because an ELO board has no scoreline.
Filters: ?player=<player_id>, ?status=<status>, ?limit=, ?cursor=.
4. Before you write: the async model
POST /v1/boards/{board}/matches
│
▼
202 Accepted { "handle": "sub_1", "status": "queued", "applied": false }
│
▼
the bot creates the match and posts it to your validation channel
│
▼
a moderator clicks Approve ──► ratings move, Discord embed updates
│
▼
GET /v1/submissions/sub_1 → { "status": "approved", "applied": true, "match": {...} }Three consequences worth internalising before you write any code:
A 202 is not a standings change. On a board with a 24-hour validation
window, nothing moves until a human acts. applied: false in the response body
means exactly what it says.
Ratings are snapshotted at submission, not at approval. Each participant's
rating is read when the match is created. If you push a ten-match tournament
back to back, all ten are computed against the pre-tournament ratings, whatever
order they are approved in. That is deterministic and identical to what the
Discord bot does — but it is not "apply match 1, then rate match 2 against the
new numbers". If you need sequential ratings, wait for each handle to reach
approved before submitting the next match.
201 is possible but never guaranteed. On a board with
auto_applies: true, the API waits up to about three seconds for the work to
finish. If it does, you get 201 Created with the final state; if it does not,
you get the same 202 and the same handle. Handle both — a busy minute is
enough to turn one into the other.
5. Record a match, or a score
The recipes below all belong to one example workspace. Each one is shaped by the
board it writes to, so here is what those boards look like — every value comes
from GET /v1/boards/{board}:
| Board | type | team_size | fixed_teams | sort | auto_applies |
|---|---|---|---|---|---|
board_5 | ELO | 1 | false | desc | false |
board_1 | ELO | 3 | false | desc | false |
board_6 | ELO | 5 | true | desc | false |
board_2 | League | 1 | false | desc | true |
board_3 | Highscore | 1 | false | desc | false |
board_7 | Time | 1 | false | asc | false |
board_8 | Classic | 1 | false | desc | false |
Every POST needs three headers:
Authorization: Bearer sk_live_...
Content-Type: application/json
Idempotency-Key: <a value you generate, and reuse when you retry>Idempotency-Key is required — see §7. In the curl
examples below it is generated once into a shell variable, because a retry must
send the same value:
IDEM=$(uuidgen)The JavaScript examples share this helper:
const BASE = 'https://api.scoreboards.dev/v1';
const KEY = process.env.SB_KEY;
async function post(path, body, idempotencyKey) {
const res = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${KEY}`,
'Content-Type': 'application/json',
// Generate this per logical write and persist it, so a retry after a
// timeout reuses it instead of enqueuing the match a second time.
'Idempotency-Key': idempotencyKey ?? crypto.randomUUID(),
},
body: JSON.stringify(body),
});
if (!res.ok) {
const problem = await res.json(); // application/problem+json
throw new Error(`${problem.code}: ${problem.detail} [${problem.request_id}]`);
}
return res.json(); // 202 { status: "queued" } — or 201 { status: "approved" }
}Participants
Four forms, all of them objects — never a bare string:
| Form | Use it for |
|---|---|
{"discord_id": "198374652000000001"} | a Discord member. 17–20 digits; a shorter id is refused rather than silently turned into a custom player |
{"name": "Zed", "custom": true} | a player who is not in Discord. The custom: true flag is mandatory |
{"role_id": "role_998877"} | a Discord role as one entity. Guild-owned boards only. The role_ prefix is required |
{"team_id": "team_5"} | a fixed-team entity. Fixed-teams boards only. The team_ prefix is required |
The prefixes on role_id and team_id are part of the wire format, not
decoration. A bare id is refused:
{
"detail": "team2.0.role_id: must look like role_998877",
"code": "invalid_request",
"status": 422
}(This is the one place the write bodies are stricter than the ids in a read
response: ?player= and /entries/{player_id} accept a bare Discord snowflake,
write bodies do not accept a bare role_id or team_id.)
The same entity may appear only once per match, across both sides. That is
checked on the resolved ids, so a role and the Discord id it resolves to
collide — {"role_id": "role_198374652000000001"} on one side and
{"discord_id": "198374652000000001"} on the other is:
{
"detail": "role_198374652000000001 appears in both team1 and team2. A participant may take part once per match. (discord:198374652000000001 and role_198374652000000001 are the same entity to the scoring worker.)",
"code": "duplicate_participant",
"status": 422
}ELO, 1v1
team_size: 1, so exactly one participant per side. winner is required and is
"team1", "team2" or "draw" — never a score.
IDEM=$(uuidgen)
curl -s -X POST https://api.scoreboards.dev/v1/boards/board_5/matches \
-H "Authorization: Bearer $SB_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEM" \
-d '{
"team1": [{"discord_id": "198374652000000001"}],
"team2": [{"discord_id": "198374652000000002"}],
"winner": "team1"
}'{
"handle": "sub_1",
"board": "board_5",
"status": "queued",
"applied": false,
"queued_at": "2026-07-26T13:40:47.978Z"
}const ack = await post('/boards/board_5/matches', {
team1: [{ discord_id: '198374652000000001' }],
team2: [{ discord_id: '198374652000000002' }],
winner: 'team1',
});
// → { handle: 'sub_1', board: 'board_5', status: 'queued', applied: false, ... }ELO, NvN
Each side must carry exactly team_size participants — not "up to", not
"at least". board_1 is a 3v3, so three per side, and the sides may mix
Discord members, custom players and roles freely:
IDEM=$(uuidgen)
curl -s -X POST https://api.scoreboards.dev/v1/boards/board_1/matches \
-H "Authorization: Bearer $SB_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEM" \
-d '{
"team1": [
{"discord_id": "198374652000000001"},
{"discord_id": "198374652000000002"},
{"name": "Zed", "custom": true}
],
"team2": [
{"discord_id": "198374652000000003"},
{"discord_id": "198374652000000004"},
{"role_id": "role_998877"}
],
"winner": "team1"
}'{
"handle": "sub_2",
"board": "board_1",
"status": "queued",
"applied": false,
"queued_at": "2026-07-26T13:40:47.994Z"
}const ack = await post('/boards/board_1/matches', {
team1: [
{ discord_id: '198374652000000001' },
{ discord_id: '198374652000000002' },
{ name: 'Zed', custom: true },
],
team2: [
{ discord_id: '198374652000000003' },
{ discord_id: '198374652000000004' },
{ role_id: 'role_998877' },
],
winner: 'team1',
});Get the count wrong and you get a 422 that says so:
{
"type": "https://scoreboards.dev/errors/team-size-mismatch",
"title": "Wrong number of participants",
"status": 422,
"detail": "`board_1` is a 3v3 board, so `team1` must carry exactly 3 participants; got 1.",
"instance": "/v1/boards/board_1/matches",
"code": "team_size_mismatch",
"request_id": "req_530c22369a5a"
}Fixed teams — the one people get wrong
A fixed-teams board ("fixed_teams": true) plays teams against each other,
not their members. board_6 reports "team_size": 5, and it still takes
exactly one team entity per side. Five players per side is wrong. One player
per side is wrong. One team per side is right:
IDEM=$(uuidgen)
curl -s -X POST https://api.scoreboards.dev/v1/boards/board_6/matches \
-H "Authorization: Bearer $SB_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEM" \
-d '{
"team1": [{"team_id": "team_5"}],
"team2": [{"team_id": "team_6"}],
"winner": "team2"
}'{
"handle": "sub_3",
"board": "board_6",
"status": "queued",
"applied": false,
"queued_at": "2026-07-26T13:40:48.007Z"
}const ack = await post('/boards/board_6/matches', {
team1: [{ team_id: 'team_5' }],
team2: [{ team_id: 'team_6' }],
winner: 'team2',
});Send five players per side and you get:
{
"detail": "`board_6` plays fixed teams, so `team1` must name exactly 1 team entity (e.g. {\"team_id\": \"team_5\"}); got 5.",
"code": "team_size_mismatch",
"status": 422
}Send one player per side — the right count, the wrong kind — and you get:
{
"detail": "team1[0]: this board uses fixed teams, so each side is one {\"team_id\": \"team_<n>\"} — the match is between the two team entities, not between their members.",
"code": "unknown_player",
"status": 422
}Check fixed_teams on the board before you build the roster. Team ids come from
the board's standings: a fixed-teams board's entries are the teams, with
player_id values like team:5.
League — scores, and no winner
A League board is the mirror image of an ELO board. It takes both scores and
derives the winner from them; sending winner yourself is refused, because it
would let you contradict your own scoreline. Points come from the board's
points_win / points_draw / points_loss, not from you.
IDEM=$(uuidgen)
curl -s -X POST https://api.scoreboards.dev/v1/boards/board_2/matches \
-H "Authorization: Bearer $SB_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEM" \
-d '{
"team1": [{"discord_id": "198374652000000001"}],
"team2": [{"discord_id": "198374652000000002"}],
"team1_score": 3,
"team2_score": 1
}'board_2 has auto_applies: true, so this one can come back 201 Created
already applied:
{
"handle": "sub_4",
"board": "board_2",
"status": "approved",
"applied": true,
"queued_at": "2026-07-26T13:40:48.031Z",
"match": {
"id": 4,
"url": "https://scoreboards.dev/leaderboards/111222333444555666/board_2",
"applied": true
}
}…and equally it can come back 202 with "status": "queued" if the work took
longer than the wait budget. Branch on the body, not on the status code.
const ack = await post('/boards/board_2/matches', {
team1: [{ discord_id: '198374652000000001' }],
team2: [{ discord_id: '198374652000000002' }],
team1_score: 3,
team2_score: 1,
});
if (ack.applied) {
console.log('already on the board:', ack.match.id);
} else {
console.log('queued, poll', ack.handle);
}Both scores are required, integers, >= 0. Sending winner instead:
{
"detail": "team1_score: Required; team2_score: Required; winner: not allowed on this request",
"code": "invalid_request",
"status": 422
}And the reverse — a scoreline on an ELO board:
{
"detail": "team1_score, team2_score: not allowed on this request",
"code": "invalid_request",
"status": 422
}Scores: Highscore, Classic and Time
Classic, Highscore and Time boards take one player and one integer, at
/scores. What happens to that number on approval is the board's operator,
not your choice: Classic adds it to the player's running total, while
Highscore and Time keep the better of the old and new value — keep_highest on
a desc board, keep_lowest on an asc one. Read write.operator if you are
unsure which you are talking to.
IDEM=$(uuidgen)
curl -s -X POST https://api.scoreboards.dev/v1/boards/board_3/scores \
-H "Authorization: Bearer $SB_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEM" \
-d '{
"player": {"discord_id": "198374652000000001"},
"score": 145530
}'{
"handle": "sub_5",
"board": "board_3",
"status": "queued",
"applied": false,
"queued_at": "2026-07-26T13:40:51.233Z"
}// Highscore (board_3): keep_highest
await post('/boards/board_3/scores', {
player: { discord_id: '198374652000000001' },
score: 145530,
});
// Classic (board_8): accumulate — this ADDS 3 to the player's running total
await post('/boards/board_8/scores', {
player: { name: 'Ava', custom: true },
score: 3,
});
// Time (board_7): keep_lowest, and the unit is MILLISECONDS
await post('/boards/board_7/scores', {
player: { discord_id: '198374652000000001' },
score: 90500, // 1:30.500
});Time boards take milliseconds, as an integer. 1:30.5 is 90500. Not
seconds, not "1:30", not a float — a float is a 422
(score: Expected integer, received float). This is the single most common
integration bug on the whole API, which is why the board's write contract
states "score_unit": "milliseconds" in its own response. Check it.
The same player forms apply as for matches, so a non-Discord player is
{"name": "...", "custom": true}.
Posting a score to a match board (or a match to a score board) is a 422 that
tells you where to go instead:
{
"detail": "`board_1` is a ELO board, which records matches rather than scores. POST to /v1/boards/board_1/matches instead.",
"code": "board_type_mismatch",
"status": 422
}6. Polling the handle
Every accepted write returns a handle like sub_1. Resolve it with:
curl -s https://api.scoreboards.dev/v1/submissions/sub_1 \
-H "Authorization: Bearer $SB_KEY"The full lifecycle, as the same handle actually reports it:
{ "handle": "sub_1", "status": "queued", "board": "board_5", "match": null }{ "handle": "sub_1", "status": "processing", "board": "board_5", "match": null }{
"handle": "sub_1",
"status": "pending_review",
"board": "board_5",
"match": {
"id": 7,
"url": "https://scoreboards.dev/leaderboards/111222333444555666/board_5",
"applied": false
}
}{
"handle": "sub_1",
"status": "approved",
"board": "board_5",
"match": {
"id": 7,
"url": "https://scoreboards.dev/leaderboards/111222333444555666/board_5",
"applied": true
}
}…or, if a moderator declined it:
{
"handle": "sub_1",
"status": "rejected",
"board": "board_5",
"match": {
"id": 7,
"url": "https://scoreboards.dev/leaderboards/111222333444555666/board_5",
"applied": false
}
}status | What it means | Terminal? |
|---|---|---|
queued | The write is durably recorded; the bot has not picked it up yet | no |
processing | The bot is working on it | no |
pending_review | The match/score exists and is sitting in the validation channel, waiting for a human | no |
approved | Applied. The standings have moved | yes |
rejected | A moderator declined it. Nothing was applied | yes |
failed | The bot could not process the event. Nothing was applied | yes |
match appears as soon as the bot has created the row, and match.id is the
per-board match number you can pass to
GET /v1/boards/{board}/matches/{match_id}. match.url is the board's public
page, not a per-match page.
How to poll. status: "approved" is the only signal that the standings
changed — applied on the handle and on match say the same thing. On an
auto-applying board the answer usually arrives within seconds. On a board with a
24-hour window it will sit at pending_review until someone acts, so poll with
backoff (a few seconds, then tens of seconds, then minutes) rather than hot-
looping; you have 600 reads per minute and no reason to spend them. Handles
resolve for 30 days, then 404.
A handle belonging to another owner is an indistinguishable 404, so there is
nothing to learn by walking the numbers.
7. Idempotency
Idempotency-Key is required on every POST. Without one:
{
"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_5/matches",
"code": "invalid_request",
"request_id": "req_1c5f4de7b605"
}It is mandatory rather than optional because a write here is not a harmless duplicate: a blind retry after a timeout puts a second match in someone's validation channel and, once approved, a second rating change. A timeout is indistinguishable from a failure, so "did it land?" is unanswerable unless you chose a key in advance.
Generate one value per logical write — a UUID is ideal — persist it with the work, and send the same value on every retry of that same write.
Same key, same body → the stored response, replayed. Byte for byte, right
down to the original queued_at, plus an Idempotent-Replayed: true header. No
second match is enqueued.
HTTP/1.1 202 Accepted
Idempotent-Replayed: true
{"handle":"sub_1","board":"board_5","status":"queued","applied":false,"queued_at":"2026-07-26T13:40:47.978Z"}Same key, different body → 409. This is the case that protects you: a
caller who fixes a typo and retries with the same key would otherwise be handed
the result of the original request, describing data they did not send.
{
"type": "https://scoreboards.dev/errors/idempotency-conflict",
"title": "Idempotency key reused with a different body",
"status": 409,
"detail": "Idempotency-Key elo1v1 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_5/matches",
"code": "idempotency_conflict",
"request_id": "req_da61da093421"
}The comparison is over the raw body, so reordering keys or changing 1 to
1.0 counts as a different request. New request → new key.
Two more properties worth knowing:
- Concurrent duplicates. If two requests with the same key are in flight,
exactly one executes; the other gets a
409withRetry-After: 1telling it to come back for the stored response. - Only successes are stored. A
422is not replayed — fix the body and reuse the same key. Authentication failures and429s are decided before the idempotency layer and are never stored either.
Records are kept for at least 24 hours — the retention window is a floor, not an expiry you can plan around, and today nothing sweeps the table. So treat a key as permanently spent: a key you used yesterday and send again tomorrow may still replay yesterday's stored response instead of enqueuing anything. Mint a fresh value per logical write and never recycle one.
8. Errors
Every failure from a /v1 endpoint is
RFC 9457 application/problem+json:
{
"type": "https://scoreboards.dev/errors/board-type-mismatch",
"title": "Wrong payload for this board type",
"status": 422,
"detail": "`board_3` is a Highscore board. Only ELO and League boards record matches; use /v1/boards/board_3/entries instead.",
"instance": "/v1/boards/board_3/matches",
"code": "board_type_mismatch",
"request_id": "req_8d2f440e6c60"
}Branch on code, never on title or detail. The code is the stable
contract; the human text may be reworded at any time. request_id is also
echoed as an X-Request-Id response header — quote it if you contact support,
and send your own X-Request-Id if you want your logs and ours to share a
correlation id.
code | Status | When | What to do |
|---|---|---|---|
invalid_request | 400 / 413 / 422 | Malformed JSON, a missing or unusable Idempotency-Key, a cursor we did not issue, a bad field, an unknown field, a body over 64 KB | Fix the request. Retrying it unchanged will fail identically |
authentication_failed | 401 | Missing, malformed, unknown or revoked key | Check the Authorization: Bearer sk_live_… header; mint a new key if it was revoked |
permission_denied | 403 | The key lacks the scope, or a sk_test_ key attempted a write | Mint a key with Read and write access. The detail lists the scopes the key does have |
not_found | 404 | No such board, match, entry or handle for this key's owner | Check the id. Another owner's board is deliberately indistinguishable from one that does not exist |
idempotency_conflict | 409 | The key was used for a different body, or the original request is still in flight | New key for a new request; for an in-flight duplicate, honour Retry-After and re-send to collect the stored response |
rate_limited | 429 | Over 600 reads or 60 writes per minute for this key | Sleep for Retry-After seconds and retry. See §9 |
board_type_mismatch | 422 | Matches on a score board, scores on a match board, or anything on a Rating board | Use the endpoint in the board's write.endpoint |
team_size_mismatch | 422 | Wrong number of participants on a side | Exactly team_size per side — or exactly one team entity per side on a fixed-teams board |
duplicate_participant | 422 | The same entity appears twice in one match | Checked on the resolved ids, so role_<snowflake> and that same bare snowflake count as one entity |
unknown_player | 422 | A participant object the board cannot accept — a team entity on a non-fixed board, a player on a fixed-teams board, a role on a user-owned board | Match the participant form to the board (fixed_teams, and whether the owner is a Discord server) |
board_locked | 409 | Reserved. No v0 endpoint emits it today | Treat as non-retryable if you ever see it |
internal_error | 500 | Something broke on our side | Retry with the same Idempotency-Key. If it persists, quote the request_id |
There is no exception to the shape. A mistyped URL that matches no /v1
route is a not_found problem document like any other, so you never have to
special-case one response. (Without a usable key you get a 401 instead —
authentication is decided before routing, so an unauthenticated caller cannot
probe which paths exist.)
9. Rate limits
Per key, per minute:
| Class | Limit | Counts |
|---|---|---|
| Read | 600 / minute | GET, HEAD, OPTIONS |
| Write | 60 / minute | everything else |
They are separate buckets, so a burst of writes cannot starve the polling that watches for the result of those writes. The bucket refills continuously rather than resetting on a window boundary, so a tournament pushed as 40 matches in ten seconds goes through and then meters you at the configured rate.
Both header families are emitted on every response your key authenticated for —
successes and errors alike (a 401 is decided before the limiter runs, so that
one carries none) — and their Reset values are in different units:
RateLimit-Limit: 60
RateLimit-Remaining: 0
RateLimit-Reset: 60 ← delta-seconds. NOT an epoch
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1785073396 ← unix epoch seconds. NOT a delay
Retry-After: 1 ← 429 responses onlyRateLimit-Reset is the IETF spelling: seconds from now until the quota is
whole again. X-RateLimit-Reset is the legacy spelling every older client
library reads: the same instant as a unix timestamp. Read the one your client
expects; swapping them produces a client that either hammers or sleeps for
decades.
Over the limit:
{
"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 1s.",
"instance": "/v1/boards/board_8/scores",
"code": "rate_limited",
"request_id": "req_80f94f9d1a09"
}Retry-After is the time until one token exists — the shortest correct wait.
RateLimit-Reset is the longer, conservative number: when the whole quota is
back.
Request bodies are capped at 64 KB, which is roughly a hundred times the largest legal request (an 8v8 roster).
10. Not in v0
So you do not go looking: there are no outbound webhooks, no way to void or
reverse a match through the API (do it in Discord or the dashboard), no board
creation or deletion, no season endpoints, and no writes to Rating boards —
those need an image upload and have no v0 endpoint, though they still resolve
for reads at GET /v1/boards/{board}.
If one of those is what your integration actually needs, say so. A real caller's request outranks the roadmap.