API pública · v0BetaVersión del documento 0.1.0

Referencia de la API de Scoreboards

Lee tus clasificaciones y envía resultados desde cualquier cosa capaz de hacer una petición HTTP. Nueve endpoints, una clave, sin SDK.

La API está en beta

El acceso se aprueba por servidor. Abre tu panel, entra en la sección API del servidor que administras y solicita el acceso allí; una vez concedido podrás crear claves. Mientras la v0 esté en beta, los endpoints y los campos de respuesta pueden cambiar, y avisamos a cada integración aprobada antes de hacerlo.

Lee esto primero: las escrituras son asíncronas

Una escritura no devuelve una posición. Devuelve 202 Accepted y un handle, porque la API solo encola una orden: todo el cálculo de puntuación pertenece al bot de Discord. En un tablero con ventana de validación no se mueve nada en la clasificación hasta que un moderador pulsa Approve en Discord, y eso puede ser un día después, o nunca.

201 Created ocurre en un único caso: la ventana de validación del tablero es de cero horas y el worker terminó dentro de los tres segundos aproximados que espera la API. Un tiempo agotado sigue respondiendo 202, así que gestiona ambos.

Consultar GET /submissions/{handle} es la única forma correcta de saber qué ha pasado. Una integración que interpreta un POST correcto como una clasificación ya actualizada está rota antes de salir a producción.

Antes de escribir código

Ocho cosas que deciden si una integración funciona. Ninguna se adivina mirando la lista de endpoints.

Una escritura es una petición, no un resultado

POST encola una orden y responde con un handle como sub_88213 y applied: false. Consulta el handle; nunca des por hecho que la clasificación ha cambiado.

Las puntuaciones se congelan al enviar

La valoración de cada participante se lee cuando el partido se crea, no cuando se aprueba. Diez partidos enviados seguidos se calculan todos contra las valoraciones previas al torneo. Es determinista, idéntico al bot y sorprendente la primera vez.

ELO recibe un ganador, League recibe un marcador

Un tablero ELO no tiene campo de puntuación: envía winner como team1, team2 o draw. Un tablero League lleva team1_score y team2_score y deduce él mismo el ganador. Cada uno rechaza los campos del otro con un 422 que nombra la clave sobrante.

Los tableros Time reciben milisegundos

Como entero: 1:30 es 90000. Enviar segundos es el error de integración más probable de toda la API, y por eso cada tablero Time indica score_unit: milliseconds en su propio contrato de escritura.

Cada bando lleva exactamente team_size

Un tablero 3v3 quiere tres participantes por bando; cualquier otra cosa es un 422 team_size_mismatch. La excepción es un tablero con fixed_teams: true, donde cada bando es exactamente un team_id: el partido es entre las dos entidades de equipo, no entre sus miembros.

Los ids de jugador llevan espacio de nombres

Las respuestas traen discord:198374652000000000, custom:Ava, role:998877 o team:5, y las rutas y los filtros aceptan las mismas formas. Un snowflake de Discord a secas también sirve como entrada, así que un id copiado de Discord se puede pegar tal cual.

Cada POST necesita un Idempotency-Key

Es obligatorio, no opcional: un tiempo agotado no se distingue de un fallo, y una escritura reintentada a ciegas es un segundo partido en el canal de validación de alguien. La misma clave con el mismo cuerpo reproduce la respuesta guardada; la misma clave con otro cuerpo es un 409. Los registros se conservan al menos 24 horas — es un mínimo, no una caducidad —, así que da una clave por gastada para siempre en lugar de reutilizarla.

Cursores, no desplazamientos

Una clasificación cambia entre peticiones, así que paginar por offset cuenta dos veces. Los cursores son compuestos —un valor más un desempate—, de modo que un corte de página dentro de un bloque de puntuaciones empatadas continúa dentro del bloque en lugar de saltarse a todos. 50 filas por página por defecto, 200 como máximo. Trata next_cursor como opaco.

Qué acepta cada tipo de tablero

La forma de la escritura la fija el tablero y no se elige en cada envío. En lugar de fijar esta tabla en tu código, pregunta a GET /boards/{board}: su bloque write responde para ese tablero concreto, hasta el factor k o los puntos por victoria.

Tipo de tableroEndpointEl cuerpo llevaRechazado con 422Qué aplica la aprobación
ClassicPOST /boards/{board}/scoresplayer, scoreteam1, team2, winner, team1_score, team2_scoreSuma la puntuación enviada al total acumulado.
HighscorePOST /boards/{board}/scoresplayer, scoreteam1, team2, winner, team1_score, team2_scoreConserva la mejor entre la puntuación antigua y la nueva.
TimePOST /boards/{board}/scoresplayer, scoreteam1, team2, winner, team1_score, team2_scoreConserva el mejor tiempo según el orden del tablero.
ELOPOST /boards/{board}/matchesteam1, team2, winnerteam1_score, team2_score, scoreUn cambio de valoración de suma cero para los equipos, más victoria, empate o derrota.
LeaguePOST /boards/{board}/matchesteam1, team2, team1_score, team2_scorewinner, scorePuntos por victoria, empate o derrota, más el registro.
RatingSin endpoint de escritura en v0: un envío de rating necesita subir una imagen.

Autenticación

Presenta una clave del panel como Authorization: Bearer sk_live_.... Cada clave está ligada a un único propietario —un servidor de Discord, o una cuenta de usuario para tableros personales—, así que una clave nunca puede tocar el tablero de otro. El tablero ajeno es un 404, nunca un 403: confirmar que existe permitiría a cualquiera enumerar los tableros de los demás.

Las claves de prueba son de solo lectura. Una clave sk_test_ atiende cualquier lectura y rechaza todos los scopes de escritura. Las claves se muestran una sola vez al crearlas y se guardan solo como hash, así que si pierdes una, crea otra y revoca la antigua: se permiten varias claves activas por propietario, de modo que rotar no cuesta tiempo de caída.

Nunca pongas una clave secreta en código de cliente. Las rutas /v1 no envían ninguna cabecera CORS, así que un navegador no puede llamarlas. Es deliberado, no un olvido.

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

Scopes

Mínimo privilegio por defecto: el nivel Solo lectura del panel concede los tres scopes de lectura y nada más, y solo Lectura y escritura añade matches:write y scores:write. Los scopes se comprueban de forma exacta y no se implican entre sí: matches:write no concede matches:read. Los dos scopes de escritura están separados porque su alcance es distinto: una integración de puntuaciones defectuosa infla un tablero, y una de partidos defectuosa mueve la valoración de todos.

ScopePermite
boards:readListar tableros, leer el contrato de escritura de uno y resolver un handle de escritura.
entries:readLeer la clasificación y la posición de un participante concreto.
matches:readLeer el historial de partidos en tableros ELO y League.
matches:writeEncolar un partido ELO o League. Nunca se concede a una clave de prueba.
scores:writeEncolar una puntuación en un tablero Classic, Highscore o Time. Nunca se concede a una clave de prueba.

Endpoints

Nueve operaciones. Todas las rutas de abajo son relativas a la URL base, toda petición necesita una clave bearer, y toda respuesta que no sea 2xx es un documento de problema.

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.

Parámetros
NombreUbicaciónTipoDescripción
limitopcionalqueryinteger

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

cursoropcionalquerystring

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.

Respuestas
EstadoDescripción
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.

CampoTipoDescripción
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.

Respuesta de ejemplo · 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
}
Petición de ejemplo
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.

Parámetros
NombreUbicaciónTipoDescripción
boardobligatoriopathstring

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.

Respuestas
EstadoDescripción
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.

CampoTipoDescripción
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.

Respuesta de ejemplo · 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
}
Petición de ejemplo
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.

Parámetros
NombreUbicaciónTipoDescripción
boardobligatoriopathstring

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.

limitopcionalqueryinteger

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

cursoropcionalquerystring

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.

Respuestas
EstadoDescripción
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.

CampoTipoDescripción
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.

Respuesta de ejemplo · 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"
}
Petición de ejemplo
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.

Parámetros
NombreUbicaciónTipoDescripción
boardobligatoriopathstring

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_idobligatoriopathstring

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.

Respuestas
EstadoDescripción
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.

CampoTipoDescripción
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
scoreopcionalnumber | null
eloopcionalnumber | null
winsopcionalinteger | null
drawsopcionalinteger | null
lossesopcionalinteger | null
submissionsopcionalinteger | null
last_substring | null

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

Respuesta de ejemplo · 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
}
Petición de ejemplo
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.

Parámetros
NombreUbicaciónTipoDescripción
boardobligatoriopathstring

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.

limitopcionalqueryinteger

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

cursoropcionalquerystring

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.

playeropcionalquerystring

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.

statusopcionalquery"pending" | "approved" | "rejected" | "adjusted" | "set"

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

Respuestas
EstadoDescripción
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.

CampoTipoDescripción
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.

Respuesta de ejemplo · 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
}
Petición de ejemplo
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.

Parámetros
NombreUbicaciónTipoDescripción
boardobligatoriopathstring

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-Keyobligatorioheaderstring

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.

Cuerpo de la petición application/json

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

Envía exactamente una de estas formas: la que corresponda al tablero.

EloMatchBody
CampoTipoDescripción
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
CampoTipoDescripción
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"
}
Respuestas
EstadoDescripción
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.

CampoTipoDescripción
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.

matchopcionalMatchRef | null

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

Respuesta de ejemplo · 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
  }
}
Petición de ejemplo
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.

Parámetros
NombreUbicaciónTipoDescripción
boardobligatoriopathstring

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_idobligatoriopathinteger

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

Respuestas
EstadoDescripción
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.

CampoTipoDescripción
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_scoreopcionalnumber | null

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

team2_scoreopcionalnumber | 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_byopcionalstring | null

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

Respuesta de ejemplo · 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"
}
Petición de ejemplo
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.

Parámetros
NombreUbicaciónTipoDescripción
boardobligatoriopathstring

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-Keyobligatorioheaderstring

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.

Cuerpo de la petición application/json

One participant and one integer score.

CampoTipoDescripción
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
}
Respuestas
EstadoDescripción
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.

CampoTipoDescripción
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.

matchopcionalMatchRef | null

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

Respuesta de ejemplo · 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
  }
}
Petición de ejemplo
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.

Parámetros
NombreUbicaciónTipoDescripción
handleobligatoriopathstring

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

Respuestas
EstadoDescripción
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.

CampoTipoDescripción
handlestring
pattern ^sub_\d+$
status"queued" | "processing" | "pending_review" | "approved" | "rejected" | "failed"
boardstring
matchMatchRef | null
Respuesta de ejemplo · 200
{
  "handle": "sub_88213",
  "status": "approved",
  "board": "board_1",
  "match": {
    "id": 3,
    "url": "https://scoreboards.dev/leaderboards/111222333444555666/board_1",
    "applied": true
  }
}
Petición de ejemplo
curl 'https://api.scoreboards.dev/v1/submissions/sub_88213' \
  -H 'Authorization: Bearer sk_live_...'

Tipos de datos

Los objetos con los que se construyen las respuestas anteriores. Cada nombre de campo y cada descripción salen de los esquemas con los que validan los handlers, así que no pueden desviarse de lo que la API acepta y devuelve de verdad.

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.

CampoTipoDescripción
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.

CampoTipoDescripción
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.

CampoTipoDescripción
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_unitopcionalstring

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

k_factoropcionalinteger
start_ratingopcionalinteger
points_winopcionalinteger
points_drawopcionalinteger
points_lossopcionalinteger

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.

CampoTipoDescripción
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
scoreopcionalnumber | null
eloopcionalnumber | null
winsopcionalinteger | null
drawsopcionalinteger | null
lossesopcionalinteger | null
submissionsopcionalinteger | null
last_substring | null

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

MatchParticipant

CampoTipoDescripción
player_idstring
player_namestring
eloopcionalnumber | 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.

CampoTipoDescripción
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_scoreopcionalnumber | null

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

team2_scoreopcionalnumber | 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_byopcionalstring | 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.

CampoTipoDescripción
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.

CampoTipoDescripción
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.

CampoTipoDescripción
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.

CampoTipoDescripción
team_idstring
pattern ^team_\d+$

RoleParticipant

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

CampoTipoDescripción
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.

CampoTipoDescripción
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.

CampoTipoDescripción
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.

CampoTipoDescripción
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.

CampoTipoDescripción
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.

CampoTipoDescripción
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.

CampoTipoDescripción
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.

CampoTipoDescripción
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.

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

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

Errores

Todo fallo de un endpoint de esta referencia es un documento de problema RFC 9457 servido como application/problem+json. Ramifica según code: es la parte estable del contrato, mientras que title y detail están escritos para personas y pueden reformularse. Un caso límite que conviene contemplar: una URL que no coincide con ninguna ruta la responde la plataforma y no la API, así que una petición autenticada a una ruta mal escrita recibe un 404 en texto plano; comprueba el tipo de contenido antes de parsear el cuerpo de un error.

La API está solo en inglés e ignora Accept-Language. Es deliberado: un mensaje de error que cambia de idioma según el entorno es más difícil de buscar, no más fácil. Esta documentación está traducida; la API no.

Toda respuesta incluye un X-Request-Id. Cítalo si reportas un problema: es lo que une tu aviso con nuestra línea de log.

{
  "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"
}
CódigoEstadoCuándo aparece
invalid_request400 · 413 · 422Un cuerpo, un parámetro de consulta o un cursor no encajaban con el esquema; el detalle nombra el campo conflictivo. También se usa para un cuerpo de más de 64 KB.
authentication_failed401Clave ausente, mal formada, desconocida o revocada.
permission_denied403A la clave le falta el scope, o es una clave de prueba y el scope es de escritura.
not_found404No existe ese tablero, entrada, partido o handle para el propietario de esta clave. Un recurso ajeno es indistinguible de uno inexistente.
idempotency_conflict409Se reutilizó el mismo Idempotency-Key con otro cuerpo, o una petición idéntica sigue en curso.
rate_limited429El cupo está agotado. Espera los segundos que indique Retry-After.
board_type_mismatch422Endpoint correcto, tablero equivocado: una puntuación enviada a un tablero de partidos, un partido enviado a uno de puntuaciones, o cualquier cosa enviada a un tablero Rating.
team_size_mismatch422Un bando no lleva exactamente el team_size del tablero, o un bando de un tablero de equipos fijos nombra algo que no es exactamente un equipo.
duplicate_participant422El mismo participante aparece dos veces. Se comprueba tras resolverlo, así que role_998877 y el snowflake 998877 a secas cuentan como una sola entidad.
unknown_player422Un participante no es utilizable: un snowflake de menos de 17 dígitos, un rol en un tablero sin servidor de Discord, un equipo en un tablero sin equipos fijos, o un jugador personalizado enviado sin custom: true.
board_locked409El tablero no acepta escrituras.
internal_error500Algo ha fallado por nuestra parte. Reintenta y cita el request_id si persiste.

Límites de uso

600 lecturas y 60 escrituras por minuto y clave, en dos cubos de tokens separados: una ráfaga de escrituras nunca puede dejar sin cupo a la consulta que espera su resultado. Los cubos se rellenan de forma continua, así que se permite una ráfaga corta y se limita el ritmo sostenido.

RateLimit-Reset son segundos a partir de ahora; X-RateLimit-Reset es una marca de tiempo Unix. Describen el mismo instante en unidades distintas, y confundir una con la otra hace que un cliente machaque la API o duerma durante décadas.

Ambas familias de cabeceras se envían en toda respuesta posterior a la autenticación.

CabeceraDescripción
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.

¿Falta algo?

Cuéntanos qué estás construyendo. v0 entrega lecturas y tres vías de escritura a propósito: crear tableros, temporadas, webhooks salientes y anular partidos están aplazados hasta que alguien real los pida, y esa petición vale más que nuestra hoja de ruta. El acceso a la beta no se pide aquí: eso se hace en la sección API de tu panel.

Pregunta en nuestro Discord