Öffentliche API · v0Beta

Schnellstart

Dein erster Aufruf in unter fünf Minuten, dann ein Beispiel pro Board-Typ.

⚠️ 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:

  1. 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.
  2. The surface can still change. Fields get added and shapes get adjusted in response to what the first integrations actually need.
  3. A breaking change can ship inside /v1 without a new version prefix while the beta lasts. Pin the document version you built against (info.version, currently 0.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, we keep the number of integrations small enough that every one of them can be warned before it breaks. 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

MethodPathScope
GET/v1/boardsboards:read
GET/v1/boards/{board}boards:read
GET/v1/boards/{board}/entriesentries:read
GET/v1/boards/{board}/entries/{player_id}entries:read
GET/v1/boards/{board}/matchesmatches:read
GET/v1/boards/{board}/matches/{match_id}matches:read
POST/v1/boards/{board}/matchesmatches:write
POST/v1/boards/{board}/scoresscores: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:

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.

  1. 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.
  2. Go to Settings → API.
  3. 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.
  4. 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 levelScopes on the key
Read onlyboards:read, entries:read, matches:read
Read and writethe 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 — usually board_<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 a 422 that 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), or keep_highest / keep_lowest (Highscore and Time keep the better value — which one depends on the board's sort: 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 on Time boards, where it is always "milliseconds".
  • validation_window_hours / auto_applies — whether a human has to approve. auto_applies: true (window 0) is the only case where a write can reach the standings without someone clicking Approve. 999 means 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_9f50ee9092ad

X-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 typeValue fieldCounters
ELOelowins, draws, losses
Leaguescore (league points)wins, draws, losses
Classic, Highscore, Timescoresubmissions

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 formWhat it is
discord:198374652000000001a Discord member, by snowflake
custom:Ava Customa non-Discord player, by name
role:998877a Discord role competing as one entity
team:5a 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
}
  • id is the per-board match number, the one shown in Discord. Use it with GET /v1/boards/{board}/matches/{match_id}.
  • status is one of pending, approved, rejected, adjusted, set; applied says whether the result is live on the leaderboard.
  • The roster elo is the rating snapshotted when the match was submitted, not when it was approved — see §4.
  • League matches additionally carry team1_score and team2_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}:

Boardtypeteam_sizefixed_teamssortauto_applies
board_5ELO1falsedescfalse
board_1ELO3falsedescfalse
board_6ELO5truedescfalse
board_2League1falsedesctrue
board_3Highscore1falsedescfalse
board_7Time1falseascfalse
board_8Classic1falsedescfalse

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:

FormUse 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
  }
}
statusWhat it meansTerminal?
queuedThe write is durably recorded; the bot has not picked it up yetno
processingThe bot is working on itno
pending_reviewThe match/score exists and is sitting in the validation channel, waiting for a humanno
approvedApplied. The standings have movedyes
rejectedA moderator declined it. Nothing was appliedyes
failedThe bot could not process the event. Nothing was appliedyes

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 409 with Retry-After: 1 telling it to come back for the stored response.
  • Only successes are stored. A 422 is not replayed — fix the body and reuse the same key. Authentication failures and 429s 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.

codeStatusWhenWhat to do
invalid_request400 / 413 / 422Malformed JSON, a missing or unusable Idempotency-Key, a cursor we did not issue, a bad field, an unknown field, a body over 64 KBFix the request. Retrying it unchanged will fail identically
authentication_failed401Missing, malformed, unknown or revoked keyCheck the Authorization: Bearer sk_live_… header; mint a new key if it was revoked
permission_denied403The key lacks the scope, or a sk_test_ key attempted a writeMint a key with Read and write access. The detail lists the scopes the key does have
not_found404No such board, match, entry or handle for this key's ownerCheck the id. Another owner's board is deliberately indistinguishable from one that does not exist
idempotency_conflict409The key was used for a different body, or the original request is still in flightNew key for a new request; for an in-flight duplicate, honour Retry-After and re-send to collect the stored response
rate_limited429Over 600 reads or 60 writes per minute for this keySleep for Retry-After seconds and retry. See §9
board_type_mismatch422Matches on a score board, scores on a match board, or anything on a Rating boardUse the endpoint in the board's write.endpoint
team_size_mismatch422Wrong number of participants on a sideExactly team_size per side — or exactly one team entity per side on a fixed-teams board
duplicate_participant422The same entity appears twice in one matchChecked on the resolved ids, so role_<snowflake> and that same bare snowflake count as one entity
unknown_player422A 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 boardMatch the participant form to the board (fixed_teams, and whether the owner is a Discord server)
board_locked409Reserved. No v0 endpoint emits it todayTreat as non-retryable if you ever see it
internal_error500Something broke on our sideRetry 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:

ClassLimitCounts
Read600 / minuteGET, HEAD, OPTIONS
Write60 / minuteeverything 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 only

RateLimit-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.