Offentligt API · v0BetaDokumentversion 0.1.0

Scoreboards API-reference

Læs dine leaderboards og send resultater ind i dem fra alt, der kan sende en HTTP-forespørgsel. Ni endpoints, én nøgle, ingen SDK nødvendig.

API'et er i beta

Adgang godkendes pr. server. Åbn dit dashboard, vælg API-sektionen for den server, du administrerer, og anmod om adgang der; når adgangen er givet, kan du oprette nøgler. Mens v0 er i beta, kan endpoints og felter i svar stadig ændre sig, og alle godkendte integrationer får besked, før det sker.

Læs det her først: skrivninger er asynkrone

En skrivning returnerer ikke en placering. Den returnerer 202 Accepted og et handle, fordi API'et kun lægger en kommando i kø — Discord-botten ejer al ratingberegning. På et board med et valideringsvindue sker der intet på leaderboardet, før en moderator klikker Approve i Discord, og det kan være et døgn senere, eller aldrig.

201 Created sker i præcis ét tilfælde: boardets valideringsvindue er nul timer, og workeren blev færdig inden for API'ets ventetid på cirka tre sekunder. En timeout svarer stadig 202, så håndtér begge dele.

At polle GET /submissions/{handle} er den eneste rigtige måde at finde ud af, hvad der skete. En integration, der opfatter et vellykket POST som et ændret leaderboard, er i stykker, før den overhovedet er sat i drift.

Før du skriver kode

Otte ting, der afgør, om en integration virker. Ingen af dem kan gættes ud fra listen over endpoints.

En skrivning er en anmodning, ikke et resultat

POST lægger en kommando i kø og svarer med et handle som sub_88213 og applied: false. Poll handlet — gå aldrig ud fra, at stillingen har flyttet sig.

Ratings fryses ved indsendelsen

Hver deltagers rating læses, når kampen oprettes, ikke når den godkendes. Ti kampe sendt lige efter hinanden regnes alle mod ratings fra før turneringen. Det er deterministisk, identisk med botten — og overraskende første gang.

ELO tager en vinder, League tager et resultat

Et ELO-board har intet scorefelt: send winner som team1, team2 eller draw. Et League-board bærer team1_score og team2_score og udleder selv vinderen. Hver af dem afviser den andens felter med en 422, der nævner den forkerte nøgle.

Time-boards tager millisekunder

Som et heltal: 1:30 er 90000. At sende sekunder er den mest sandsynlige integrationsfejl på hele API'et, og derfor oplyser hvert Time-board score_unit: milliseconds i sin egen skrivekontrakt.

Hver side bærer præcis team_size

Et 3v3-board vil have tre deltagere per side; alt andet er en 422 team_size_mismatch. Undtagelsen er et board med fixed_teams: true, hvor hver side er præcis ét team_id — kampen står mellem de to holdenheder, ikke mellem deres medlemmer.

Spiller-id'er har navnerum

Svar indeholder discord:198374652000000000, custom:Ava, role:998877 eller team:5, og stier og filtre accepterer de samme former. Et rent Discord-snowflake virker også som input, så et id kopieret fra Discord kan indsættes direkte.

Hvert POST kræver en Idempotency-Key

Den er påkrævet, ikke valgfri: en timeout kan ikke skelnes fra en fejl, og en blindt gentaget skrivning er en ekstra kamp i nogens valideringskanal. Samme nøgle med samme body afspiller det gemte svar; samme nøgle med en anden body er en 409. Posterne opbevares i mindst 24 timer — det er et gulv, ikke en udløbsdato — så betragt en nøgle som brugt for altid frem for at genbruge den.

Cursors, ikke offsets

Et leaderboard ændrer sig mellem forespørgsler, så paginering med offset tæller dobbelt. Cursors er sammensatte — en værdi plus en tie-breaker — så en sidegrænse midt i en blok af ens scorer fortsætter midt i blokken i stedet for at springe alle over. 50 rækker per side som standard, højst 200. Behandl next_cursor som uigennemsigtig.

Hvad hver boardtype accepterer

Skriveformen er fastlagt af boardet og vælges aldrig per indsendelse. I stedet for at hardkode denne tabel kan du spørge GET /boards/{board}: dens write-blok svarer for netop det board, helt ned til k-faktor eller point per sejr.

BoardtypeEndpointBody indeholderAfvises med 422Hvad godkendelsen udløser
ClassicPOST /boards/{board}/scoresplayer, scoreteam1, team2, winner, team1_score, team2_scoreLægger den indsendte score til den løbende total.
HighscorePOST /boards/{board}/scoresplayer, scoreteam1, team2, winner, team1_score, team2_scoreBeholder den bedste af den gamle og den nye score.
TimePOST /boards/{board}/scoresplayer, scoreteam1, team2, winner, team1_score, team2_scoreBeholder den bedste tid efter boardets sorteringsrækkefølge.
ELOPOST /boards/{board}/matchesteam1, team2, winnerteam1_score, team2_score, scoreEn nulsums ratingændring for holdene, plus sejr, uafgjort eller nederlag.
LeaguePOST /boards/{board}/matchesteam1, team2, team1_score, team2_scorewinner, scorePoint for sejr, uafgjort eller nederlag, plus statistikken.
RatingIntet skrive-endpoint i v0 — en rating-indsendelse kræver et billede.

Godkendelse

Send en nøgle fra dashboardet som Authorization: Bearer sk_live_.... Hver nøgle er bundet til præcis én ejer — en Discord-server eller en brugerkonto for personlige boards — så en nøgle kan aldrig ramme en anden ejers board. En andens board er en 404, aldrig en 403: at bekræfte, at det findes, ville lade hvem som helst kortlægge andres boards.

Testnøgler kan kun læse. En sk_test_-nøgle betjener alle læsninger og afviser alle skrive-scopes. Nøgler vises én gang ved oprettelsen og gemmes kun som et hash, så mister du en, opretter du bare en ny og tilbagekalder den gamle — flere aktive nøgler per ejer er tilladt, så rotation koster ingen nedetid.

Læg aldrig en hemmelig nøgle i kode, der kører i browseren. /v1-ruterne sender slet ingen CORS-headers, så en browser kan ikke kalde dem. Det er med vilje, ikke en forglemmelse.

curl 'https://api.scoreboards.dev/v1/boards' \
  -H 'Authorization: Bearer sk_live_...'

Scopes

Færrest mulige rettigheder som udgangspunkt: niveauet Kun læsning i dashboardet giver de tre læse-scopes og intet andet, og kun Læsning og skrivning lægger matches:write og scores:write oveni. Scopes tjekkes præcist og medfører ikke hinanden — matches:write giver ikke matches:read. De to skrive-scopes er adskilt, fordi konsekvenserne er forskellige: en dårlig score-integration puster ét board op, mens en dårlig kamp-integration flytter alles rating.

ScopeGiver adgang til
boards:readVis boards, læs et boards skrivekontrakt, og slå et skrive-handle op.
entries:readLæs stillingen og en enkelt deltagers placering.
matches:readLæs kamphistorik på ELO- og League-boards.
matches:writeLæg en ELO- eller League-kamp i kø. Gives aldrig til en testnøgle.
scores:writeLæg en score i kø på et Classic-, Highscore- eller Time-board. Gives aldrig til en testnøgle.

Endpoints

Ni operationer. Alle stier herunder er relative til base-URL'en, alle forespørgsler kræver en bearer-nøgle, og ethvert svar, der ikke er 2xx, er et problemdokument.

Boards

Boards and their derived write contracts.

get/v1/boards

List every board this key can address

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.

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

Requires the boards:read scope.

Parametre
NavnPlaceringTypeBeskrivelse
limitvalgfriqueryinteger

Page size. Default 50, maximum 200. Out of range is a 422.

cursorvalgfriquerystring

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.

Svar
StatusBeskrivelse
200

A page of boards.

400

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.

401

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.

403

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.

422

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.

429

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.

500

Something failed on our side. The detail is deliberately generic — quote request_id, which joins your report to our log line.

FeltTypeBeskrivelse
dataBoard[]
has_moreboolean

True when another page exists. Do not infer it from a short page — a page can be short and still have more.

next_cursorstring | null

Pass back as ?cursor=. null on the last page. Opaque: never parse it, never build one.

Eksempel på svar · 200
{
  "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
}
Eksempel på forespørgsel
curl 'https://api.scoreboards.dev/v1/boards?cursor=eyJzIjoxMjAwLCJwIjoiMTk4Mzc0NjUyMDAwMDAwMDAyIn0' \
  -H 'Authorization: Bearer sk_live_...'
get/v1/boards/{board}

Get one board and its write contract

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.

Read auto_applies before you build anything: when it is false, every write returns 202 and nothing changes until a moderator approves it in Discord.

Requires the boards:read scope.

Parametre
NavnPlaceringTypeBeskrivelse
boardpåkrævetpathstring

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.

Svar
StatusBeskrivelse
200

The board.

401

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.

403

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.

404

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.

422

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.

429

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.

500

Something failed on our side. The detail is deliberately generic — quote request_id, which joins your report to our log line.

FeltTypeBeskrivelse
idstring

Immutable public id. Use it in every path; it never changes, even when display_name does.

display_namestring
type"Classic" | "Highscore" | "Time" | "ELO" | "League" | "Rating"

Decides which write endpoint applies. ELO and League take matches; Classic, Highscore and Time take scores; Rating takes neither in v0.

sort"desc" | "asc"
team_sizeinteger

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_teamsboolean

True when the board plays fixed team entities against each other. A match then names two team_ids, not 2 × team_size players.

urlstring
writeWriteContract | null

What to POST. null on Rating boards, which have no v0 write endpoint.

validation_window_hoursinteger

Hours a submission waits for a moderator. 0 auto-applies; 999 means manual review with no countdown.

auto_appliesboolean

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.

Eksempel på svar · 200
{
  "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
}
Eksempel på forespørgsel
curl 'https://api.scoreboards.dev/v1/boards/board_1' \
  -H 'Authorization: Bearer sk_live_...'

Entries

Ranked standings — one row per participant.

get/v1/boards/{board}/entries

List ranked standings

The leaderboard, best first, cursor-paginated. rank is correct on every page, including a page resumed from a cursor.

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

Requires the entries:read scope.

Parametre
NavnPlaceringTypeBeskrivelse
boardpåkrævetpathstring

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.

limitvalgfriqueryinteger

Page size. Default 50, maximum 200. Out of range is a 422.

cursorvalgfriquerystring

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.

Svar
StatusBeskrivelse
200

A page of standings.

400

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.

401

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.

403

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.

404

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.

422

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.

429

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.

500

Something failed on our side. The detail is deliberately generic — quote request_id, which joins your report to our log line.

FeltTypeBeskrivelse
dataEntry[]
has_moreboolean

True when another page exists. Do not infer it from a short page — a page can be short and still have more.

next_cursorstring | null

Pass back as ?cursor=. null on the last page. Opaque: never parse it, never build one.

Eksempel på svar · 200
{
  "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"
}
Eksempel på forespørgsel
curl 'https://api.scoreboards.dev/v1/boards/board_1/entries?cursor=eyJzIjoxMjAwLCJwIjoiMTk4Mzc0NjUyMDAwMDAwMDAyIn0' \
  -H 'Authorization: Bearer sk_live_...'
get/v1/boards/{board}/entries/{player_id}

Get one participant's standing

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.

A participant with no row on this board is a 404 — a player who has never submitted has no standing to report.

Requires the entries:read scope.

Parametre
NavnPlaceringTypeBeskrivelse
boardpåkrævetpathstring

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.

player_idpåkrævetpathstring

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.

Svar
StatusBeskrivelse
200

The standing.

400

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.

401

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.

403

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.

404

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.

422

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.

429

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.

500

Something failed on our side. The detail is deliberately generic — quote request_id, which joins your report to our log line.

FeltTypeBeskrivelse
rankinteger

1-based, and correct on every page — a resumed page derives it with a COUNT rather than numbering from 1.

player_idstring

Namespaced: discord:<snowflake>, custom:<name>, role:<id> or team:<id>. Pass it back verbatim to /entries/{player_id} or ?player=.

player_namestring
scorevalgfrinumber | null
elovalgfrinumber | null
winsvalgfriinteger | null
drawsvalgfriinteger | null
lossesvalgfriinteger | null
submissionsvalgfriinteger | null
last_substring | null

RFC 3339 UTC. null when the participant has never submitted.

Eksempel på svar · 200
{
  "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
}
Eksempel på forespørgsel
curl 'https://api.scoreboards.dev/v1/boards/board_1/entries/discord:198374652000000001' \
  -H 'Authorization: Bearer sk_live_...'

Matches

Match history on ELO and League boards, and the asynchronous endpoint that records one. The write returns a handle, not a result.

get/v1/boards/{board}/matches

List match history

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.

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

Requires the matches:read scope.

Parametre
NavnPlaceringTypeBeskrivelse
boardpåkrævetpathstring

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.

limitvalgfriqueryinteger

Page size. Default 50, maximum 200. Out of range is a 422.

cursorvalgfriquerystring

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.

playervalgfriquerystring

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.

statusvalgfriquery"pending" | "approved" | "rejected" | "adjusted" | "set"

Only matches in this review state. adjusted and set are moderator edits made in Discord.

Svar
StatusBeskrivelse
200

A page of matches.

400

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.

401

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.

403

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.

404

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.

422

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.

429

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.

500

Something failed on our side. The detail is deliberately generic — quote request_id, which joins your report to our log line.

FeltTypeBeskrivelse
dataMatch[]
has_moreboolean

True when another page exists. Do not infer it from a short page — a page can be short and still have more.

next_cursorstring | null

Pass back as ?cursor=. null on the last page. Opaque: never parse it, never build one.

Eksempel på svar · 200
{
  "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
}
Eksempel på forespørgsel
curl 'https://api.scoreboards.dev/v1/boards/board_1/matches?cursor=eyJzIjoxMjAwLCJwIjoiMTk4Mzc0NjUyMDAwMDAwMDAyIn0&player=discord:198374652000000001&status=approved' \
  -H 'Authorization: Bearer sk_live_...'
post/v1/boards/{board}/matches

Record an ELO or League match (asynchronous)

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.

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.

The body depends on the board type, and each rejects the other's fields with a 422 naming the offending key:

  • ELO takes team1, team2 and winner (team1 | team2 | draw). It has no score field.
  • League takes team1, team2, team1_score and team2_score, and DERIVES the winner from the scoreline. Sending winner is refused.

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

Ratings are snapshotted when the match is created, not when it is approved.

Requires the matches:write scope and an Idempotency-Key header. sk_test_ keys are read-only.

Parametre
NavnPlaceringTypeBeskrivelse
boardpåkrævetpathstring

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.

Idempotency-Keypåkrævetheaderstring

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.

Anmodningens body application/json

An ELO match or a League match — whichever the board is. The two shapes are mutually exclusive.

Send præcis én af disse former — den, der passer til boardet:

EloMatchBody
FeltTypeBeskrivelse
team1Participant[]
min items 1 · max items 8
team2Participant[]
min items 1 · max items 8
winner"team1" | "team2" | "draw"

Words, not codes. draw is a legal outcome and applies the zero-sum ELO delta for a tie.

LeagueMatchBody
FeltTypeBeskrivelse
team1Participant[]
min items 1 · max items 8
team2Participant[]
min items 1 · max items 8
team1_scoreinteger
min 0 · max 9007199254740991
team2_scoreinteger
min 0 · max 9007199254740991
ELO, 3v3 (board_1)

Exactly `team_size` participants per side. Mixed forms are fine: members, a custom player, a role.

{
  "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 (board_2)

Both scores required, integers >= 0. The winner is derived; sending one is a 422.

{
  "team1": [
    {
      "discord_id": "198374652000000001"
    }
  ],
  "team2": [
    {
      "discord_id": "198374652000000002"
    }
  ],
  "team1_score": 3,
  "team2_score": 1
}
ELO on a fixed-teams board

One team entity per side, regardless of `team_size`. A draw is a legal outcome.

{
  "team1": [
    {
      "team_id": "team_5"
    }
  ],
  "team2": [
    {
      "team_id": "team_6"
    }
  ],
  "winner": "draw"
}
Svar
StatusBeskrivelse
201

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.

202

Queued. Nothing has changed on the leaderboard. applied is false and stays false until the submission is approved. This is the normal response.

400

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.

401

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.

403

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.

404

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.

409

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.

413

/v1 accepts at most 64 KB per request. The largest legal body — an 8v8 match — is well under a kilobyte.

422

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.

429

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.

500

Something failed on our side. The detail is deliberately generic — quote request_id, which joins your report to our log line.

FeltTypeBeskrivelse
handlestring

Poll GET /submissions/{handle} with this. Resolves for 30 days, then 404s.

pattern ^sub_\d+$
boardstring
status"queued" | "processing" | "pending_review" | "approved" | "rejected" | "failed"

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.

appliedboolean

True only when the result is already live on the leaderboard. False on every 202.

queued_atstring

RFC 3339 UTC, when the write was enqueued. A replayed response repeats the ORIGINAL value.

matchvalgfriMatchRef | null

Present once the worker has created the match. Absent on a plain 202, which is the normal case.

Eksempel på svar · 201
{
  "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
  }
}
Eksempel på forespørgsel
curl -X POST 'https://api.scoreboards.dev/v1/boards/board_1/matches' \
  -H 'Authorization: Bearer sk_live_...' \
  -H 'Idempotency-Key: 8f14e45f-ea6a-4e2b-9c1a-77c0e4d51a10' \
  -H 'Content-Type: application/json' \
  -d '{"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"}'
get/v1/boards/{board}/matches/{match_id}

Get one match with its rosters

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.

Requires the matches:read scope.

Parametre
NavnPlaceringTypeBeskrivelse
boardpåkrævetpathstring

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.

match_idpåkrævetpathinteger

The per-board match number (Match.id). Not globally unique.

Svar
StatusBeskrivelse
200

The match.

401

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.

403

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.

404

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.

422

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.

429

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.

500

Something failed on our side. The detail is deliberately generic — quote request_id, which joins your report to our log line.

FeltTypeBeskrivelse
idinteger

Per-board match number. Not globally unique; always address it under its board.

boardstring
team_sizeinteger
team1MatchParticipant[]
team2MatchParticipant[]
winner"team1" | "team2" | "draw" | null

On a League board this is DERIVED from the scoreline, never supplied by the caller.

team1_scorevalgfrinumber | null

League boards only. Absent on ELO boards, which have no scoreline.

team2_scorevalgfrinumber | null

League boards only. Absent on ELO boards, which have no scoreline.

status"pending" | "approved" | "rejected" | "adjusted" | "set"

pending until reviewed. adjusted and set are moderator edits made in Discord.

appliedboolean

True only once the result is live on the leaderboard (approved, adjusted or set).

submitted_atstring | null

RFC 3339 UTC, when the match was submitted — not when it was approved.

submitted_byvalgfristring | null

The Discord user who submitted it, or api for a submission made through this API.

Eksempel på svar · 200
{
  "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"
}
Eksempel på forespørgsel
curl 'https://api.scoreboards.dev/v1/boards/board_1/matches/2' \
  -H 'Authorization: Bearer sk_live_...'

Submissions

Score submissions on Classic, Highscore and Time boards, and resolving the handle that any write returns.

post/v1/boards/{board}/scores

Submit a score to a Classic, Highscore or Time board (asynchronous)

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

Time boards take MILLISECONDS as an integer1:30 is 90000. This is the most likely integration bug on the whole API; the board's write.score_unit says so too.

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

ELO and League boards take matches, not scores, and answer 422 board_type_mismatch here.

Requires the scores:write scope and an Idempotency-Key header. sk_test_ keys are read-only.

Parametre
NavnPlaceringTypeBeskrivelse
boardpåkrævetpathstring

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.

Idempotency-Keypåkrævetheaderstring

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.

Anmodningens body application/json

One participant and one integer score.

FeltTypeBeskrivelse
playerParticipant

Exactly one of the four forms. Anything else is a 422 unknown_player.

scoreinteger

Integer. On a Time board this is MILLISECONDS: 1:30 is 90000, not 90 and not 1.5.

min -9007199254740991 · max 9007199254740991
A Discord member
{
  "player": {
    "discord_id": "198374652000000001"
  },
  "score": 145530
}
A Time board — milliseconds

A run of 2 minutes 25.530 seconds. Sending `145.53` seconds would record 145 ms.

{
  "player": {
    "name": "Ava",
    "custom": true
  },
  "score": 145530
}
Svar
StatusBeskrivelse
201

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.

202

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.

400

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.

401

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.

403

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.

404

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.

409

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.

413

/v1 accepts at most 64 KB per request. The largest legal body — an 8v8 match — is well under a kilobyte.

422

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.

429

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.

500

Something failed on our side. The detail is deliberately generic — quote request_id, which joins your report to our log line.

FeltTypeBeskrivelse
handlestring

Poll GET /submissions/{handle} with this. Resolves for 30 days, then 404s.

pattern ^sub_\d+$
boardstring
status"queued" | "processing" | "pending_review" | "approved" | "rejected" | "failed"

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.

appliedboolean

True only when the result is already live on the leaderboard. False on every 202.

queued_atstring

RFC 3339 UTC, when the write was enqueued. A replayed response repeats the ORIGINAL value.

matchvalgfriMatchRef | null

Present once the worker has created the match. Absent on a plain 202, which is the normal case.

Eksempel på svar · 201
{
  "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
  }
}
Eksempel på forespørgsel
curl -X POST 'https://api.scoreboards.dev/v1/boards/board_1/scores' \
  -H 'Authorization: Bearer sk_live_...' \
  -H 'Idempotency-Key: 8f14e45f-ea6a-4e2b-9c1a-77c0e4d51a10' \
  -H 'Content-Type: application/json' \
  -d '{"player":{"discord_id":"198374652000000001"},"score":145530}'
get/v1/submissions/{handle}

Resolve a write handle

The only correct way to learn what happened to a write. status moves queuedprocessingpending_reviewapproved | rejected, or failed.

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

Handles resolve for 30 days, then 404. Another owner's handle is an indistinguishable 404, so sequential handles cannot be probed across tenants.

Requires the boards:read scope.

Parametre
NavnPlaceringTypeBeskrivelse
handlepåkrævetpathstring

The write handle returned by a POST, e.g. sub_88213. Resolves for 30 days.

Svar
StatusBeskrivelse
200

The submission state.

401

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.

403

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.

404

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.

422

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.

429

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.

500

Something failed on our side. The detail is deliberately generic — quote request_id, which joins your report to our log line.

FeltTypeBeskrivelse
handlestring
pattern ^sub_\d+$
status"queued" | "processing" | "pending_review" | "approved" | "rejected" | "failed"
boardstring
matchMatchRef | null
Eksempel på svar · 200
{
  "handle": "sub_88213",
  "status": "approved",
  "board": "board_1",
  "match": {
    "id": 3,
    "url": "https://scoreboards.dev/leaderboards/111222333444555666/board_1",
    "applied": true
  }
}
Eksempel på forespørgsel
curl 'https://api.scoreboards.dev/v1/submissions/sub_88213' \
  -H 'Authorization: Bearer sk_live_...'

Datatyper

De objekter, svarene ovenfor er bygget af. Hvert feltnavn og hver beskrivelse kommer fra de skemaer, handlerne validerer med, så de kan ikke komme ud af trit med det, API'et rent faktisk modtager og returnerer.

Problem

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.

FeltTypeBeskrivelse
typestring (uri)

A stable URI for this error class, e.g. https://scoreboards.dev/errors/board-type-mismatch.

titlestring

Short human summary. Not machine-readable.

statusinteger

Repeats the HTTP status code.

min 400 · max 599
detailstring

What went wrong with THIS request, naming the offending field where there is one.

instancestring

The path that failed, e.g. /v1/boards/board_1/matches.

code"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"

The machine-readable contract. Stable across rewordings.

request_idstring

Correlation id, also returned as X-Request-Id. Quote it to support.

Board

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.

FeltTypeBeskrivelse
idstring

Immutable public id. Use it in every path; it never changes, even when display_name does.

display_namestring
type"Classic" | "Highscore" | "Time" | "ELO" | "League" | "Rating"

Decides which write endpoint applies. ELO and League take matches; Classic, Highscore and Time take scores; Rating takes neither in v0.

sort"desc" | "asc"
team_sizeinteger

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_teamsboolean

True when the board plays fixed team entities against each other. A match then names two team_ids, not 2 × team_size players.

urlstring
writeWriteContract | null

What to POST. null on Rating boards, which have no v0 write endpoint.

validation_window_hoursinteger

Hours a submission waits for a moderator. 0 auto-applies; 999 means manual review with no countdown.

auto_appliesboolean

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.

WriteContract

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.

FeltTypeBeskrivelse
endpointstring
requiresstring[]

Fields that must be present in the write body.

rejectsstring[]

Fields this board type refuses. Sending one is a 422 naming the offending key.

operator"elo" | "league_points" | "accumulate" | "keep_highest" | "keep_lowest"

How an approved submission is applied. Derived from the board type and sort; never a caller input.

score_unitvalgfristring

Present on Time boards only. Scores are integer MILLISECONDS — 1:30 is 90000.

k_factorvalgfriinteger
start_ratingvalgfriinteger
points_winvalgfriinteger
points_drawvalgfriinteger
points_lossvalgfriinteger

Entry

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.

FeltTypeBeskrivelse
rankinteger

1-based, and correct on every page — a resumed page derives it with a COUNT rather than numbering from 1.

player_idstring

Namespaced: discord:<snowflake>, custom:<name>, role:<id> or team:<id>. Pass it back verbatim to /entries/{player_id} or ?player=.

player_namestring
scorevalgfrinumber | null
elovalgfrinumber | null
winsvalgfriinteger | null
drawsvalgfriinteger | null
lossesvalgfriinteger | null
submissionsvalgfriinteger | null
last_substring | null

RFC 3339 UTC. null when the participant has never submitted.

MatchParticipant

FeltTypeBeskrivelse
player_idstring
player_namestring
elovalgfrinumber | null

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.

Match

One recorded match on an ELO or League board. id is old_id — per-board, so match URLs are always board-nested.

FeltTypeBeskrivelse
idinteger

Per-board match number. Not globally unique; always address it under its board.

boardstring
team_sizeinteger
team1MatchParticipant[]
team2MatchParticipant[]
winner"team1" | "team2" | "draw" | null

On a League board this is DERIVED from the scoreline, never supplied by the caller.

team1_scorevalgfrinumber | null

League boards only. Absent on ELO boards, which have no scoreline.

team2_scorevalgfrinumber | null

League boards only. Absent on ELO boards, which have no scoreline.

status"pending" | "approved" | "rejected" | "adjusted" | "set"

pending until reviewed. adjusted and set are moderator edits made in Discord.

appliedboolean

True only once the result is live on the leaderboard (approved, adjusted or set).

submitted_atstring | null

RFC 3339 UTC, when the match was submitted — not when it was approved.

submitted_byvalgfristring | null

The Discord user who submitted it, or api for a submission made through this API.

MatchRef

The match a write produced, once the worker has created it.

FeltTypeBeskrivelse
idinteger
urlstring
appliedboolean

DiscordParticipant

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.

FeltTypeBeskrivelse
discord_idstring
pattern ^\d{17,20}$

CustomParticipant

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.

FeltTypeBeskrivelse
namestring
min length 1 · max length 100
customboolean

TeamParticipant

A fixed team entity. Valid only on a board with fixed_teams: true, one per side.

FeltTypeBeskrivelse
team_idstring
pattern ^team_\d+$

RoleParticipant

A Discord role competing as a single entity. Guild-owned boards only.

FeltTypeBeskrivelse
role_idstring
pattern ^role_\d+$

Participant

Exactly one of the four forms. Anything else is a 422 unknown_player.

BoardList

Cursor-paginated. Pass next_cursor back as ?cursor= for the next page.

FeltTypeBeskrivelse
dataBoard[]
has_moreboolean

True when another page exists. Do not infer it from a short page — a page can be short and still have more.

next_cursorstring | null

Pass back as ?cursor=. null on the last page. Opaque: never parse it, never build one.

EntryList

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.

FeltTypeBeskrivelse
dataEntry[]
has_moreboolean

True when another page exists. Do not infer it from a short page — a page can be short and still have more.

next_cursorstring | null

Pass back as ?cursor=. null on the last page. Opaque: never parse it, never build one.

MatchList

Cursor-paginated, newest match first.

FeltTypeBeskrivelse
dataMatch[]
has_moreboolean

True when another page exists. Do not infer it from a short page — a page can be short and still have more.

next_cursorstring | null

Pass back as ?cursor=. null on the last page. Opaque: never parse it, never build one.

EloMatchBody

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.

FeltTypeBeskrivelse
team1Participant[]
min items 1 · max items 8
team2Participant[]
min items 1 · max items 8
winner"team1" | "team2" | "draw"

Words, not codes. draw is a legal outcome and applies the zero-sum ELO delta for a tie.

LeagueMatchBody

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.

FeltTypeBeskrivelse
team1Participant[]
min items 1 · max items 8
team2Participant[]
min items 1 · max items 8
team1_scoreinteger
min 0 · max 9007199254740991
team2_scoreinteger
min 0 · max 9007199254740991

ScoreSubmissionBody

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.

FeltTypeBeskrivelse
playerParticipant

Exactly one of the four forms. Anything else is a 422 unknown_player.

scoreinteger

Integer. On a Time board this is MILLISECONDS: 1:30 is 90000, not 90 and not 1.5.

min -9007199254740991 · max 9007199254740991

WriteAccepted

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.

FeltTypeBeskrivelse
handlestring

Poll GET /submissions/{handle} with this. Resolves for 30 days, then 404s.

pattern ^sub_\d+$
boardstring
status"queued" | "processing" | "pending_review" | "approved" | "rejected" | "failed"

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.

appliedboolean

True only when the result is already live on the leaderboard. False on every 202.

queued_atstring

RFC 3339 UTC, when the write was enqueued. A replayed response repeats the ORIGINAL value.

matchvalgfriMatchRef | null

Present once the worker has created the match. Absent on a plain 202, which is the normal case.

Submission

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.

FeltTypeBeskrivelse
handlestring
pattern ^sub_\d+$
status"queued" | "processing" | "pending_review" | "approved" | "rejected" | "failed"
boardstring
matchMatchRef | null

Fejl

Enhver fejl fra et endpoint i denne reference er et RFC 9457-problemdokument leveret som application/problem+json. Forgren på code: den er den stabile del af kontrakten, mens title og detail er skrevet til mennesker og kan blive omformuleret. Én kant at tage højde for: en URL, der slet ikke rammer en rute, besvares af platformen og ikke af API'et, så en autentificeret forespørgsel til en forkert stavet sti får en 404 i ren tekst — tjek content-typen, før du parser en fejl-body.

API'et er kun på engelsk og ignorerer Accept-Language. Det er med vilje: en fejltekst, der skifter sprog mellem miljøer, er sværere at søge efter, ikke lettere. Denne dokumentation er oversat; API'et er ikke.

Alle svar indeholder et X-Request-Id. Citér det, hvis du melder et problem — det er det, der forbinder din henvendelse med vores loglinje.

{
  "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"
}
KodeStatusHvornår du får den
invalid_request400 · 413 · 422En body, en query-parameter eller en cursor matchede ikke skemaet; detaljen nævner det felt, der er galt med. Bruges også til en body over 64 KB.
authentication_failed401Manglende, forkert formateret, ukendt eller tilbagekaldt nøgle.
permission_denied403Nøglen mangler scopet — eller det er en testnøgle, og scopet er et skrive-scope.
not_found404Intet board, ingen post, kamp eller handle af den slags for denne nøgles ejer. En andens ressource kan ikke skelnes fra en, der ikke findes.
idempotency_conflict409Samme Idempotency-Key blev brugt igen med en anden body, eller en identisk forespørgsel er stadig undervejs.
rate_limited429Kvoten er brugt op. Vent det antal sekunder, der står i Retry-After.
board_type_mismatch422Rigtigt endpoint, forkert board: en score sendt til et kampboard, en kamp sendt til et scoreboard, eller hvad som helst sendt til et Rating-board.
team_size_mismatch422En side bærer ikke præcis boardets team_size — eller en side på et fixed-teams-board nævner andet end præcis ét hold.
duplicate_participant422Den samme deltager optræder to gange. Kontrolleres efter opslaget, så role_998877 og det rene snowflake 998877 tæller som én enhed.
unknown_player422En deltager kan ikke bruges: et snowflake på under 17 cifre, en rolle på et board uden Discord-server, et hold på et board uden faste hold, eller en brugerdefineret spiller sendt uden custom: true.
board_locked409Boardet modtager ikke skrivninger.
internal_error500Noget gik galt hos os. Prøv igen, og citér request_id, hvis det bliver ved.

Hastighedsgrænser

600 læsninger og 60 skrivninger i minuttet per nøgle, som to adskilte token buckets — en byge af skrivninger kan aldrig sulte den polling, der venter på deres resultat. Bucketsene fyldes løbende op, så en kort byge er tilladt, mens den vedvarende hastighed måles.

RateLimit-Reset er et antal sekunder fra nu; X-RateLimit-Reset er et Unix-tidsstempel. De beskriver det samme øjeblik i forskellige enheder, og læser man den ene som den anden, ender klienten enten med at hamre løs på API'et eller sove i årtier.

Begge headerfamilier sendes på alle svar efter godkendelsen.

HeaderBeskrivelse
RateLimit-Limit

Requests allowed per minute for this key: 600 read, 60 write. Read and write quotas are separate buckets.

RateLimit-Remaining

Whole requests left in the current bucket.

RateLimit-Reset

DELTA-SECONDS until the quota is whole again. A duration, not a timestamp (IETF draft semantics).

X-RateLimit-Limit

Legacy spelling of RateLimit-Limit, same value.

X-RateLimit-Remaining

Legacy spelling of RateLimit-Remaining, same value.

X-RateLimit-Reset

The same instant as RateLimit-Reset, as a UNIX TIMESTAMP in seconds. Not a delay (legacy semantics).

Retry-After

Delta-seconds to wait before retrying. Sent on 429, and on the 409 that means an identical request is still in flight.

X-Request-Id

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.

X-API-Lifecycle

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.

Idempotent-Replayed

true when this response was replayed from a stored idempotency record rather than executed. Only set on POST responses.

Mangler der noget?

Fortæl os, hvad du bygger. v0 leverer med vilje kun læsninger og tre skriveveje — oprettelse af boards, sæsoner, udgående webhooks og annullering af kampe er alle udskudt, indtil en rigtig integrator beder om dem, og den forespørgsel er mere værd end vores køreplan. Beta-adgang anmodes ikke her: det sker i API-sektionen i dit dashboard.

Spørg i vores Discord