パブリック API · v0ベータドキュメントのバージョン 0.1.0

Scoreboards API リファレンス

HTTP リクエストを送れるものなら何からでも、リーダーボードを読み、結果を書き込めます。エンドポイントは 9 つ、キーは 1 つ、SDK は不要です。

APIはベータ版です

アクセスはサーバーごとに承認されます。ダッシュボードを開き、管理しているサーバーのAPIセクションから申請してください。承認されるとキーを作成できます。v0がベータの間はエンドポイントやレスポンスのフィールドが変更される可能性があり、変更前に承認済みの各インテグレーションへお知らせします。

最初に読むこと: 書き込みは非同期です

書き込みは順位を返しません。返すのは 202 Accepted とハンドルです。API はコマンドをキューに入れるだけで、レート計算はすべて Discord ボットが担っているからです。承認待ち時間が設定されたボードでは、モデレーターが Discord で Approve を押すまでリーダーボードは一切動きません。それは 1 日後かもしれませんし、永遠に来ないかもしれません。

201 Created が返るのは 1 つの場合だけです。ボードの承認待ち時間が 0 時間で、かつワーカーが API の約 3 秒の待機内に完了したときです。タイムアウトした場合も 202 が返るので、両方を処理してください。

何が起きたかを知る唯一の正しい方法は GET /submissions/{handle} をポーリングすることです。POST の成功を順位表の更新と見なす連携は、公開前からすでに壊れています。

コードを書く前に

連携が動くかどうかを左右する 8 点です。どれもエンドポイント一覧からは推測できません。

書き込みは依頼であって結果ではない

POST はコマンドをキューに入れ、sub_88213 のようなハンドルと applied: false を返します。ハンドルをポーリングしてください。順位が動いたと決めつけてはいけません。

レートは送信時点で固定される

各参加者のレートは試合が作成された時点で読み取られ、承認時ではありません。連続して送った 10 試合はすべて大会前のレートで計算されます。決定的でボットと同じ挙動ですが、初めてだと驚きます。

ELO は勝者を、League はスコアを受け取る

ELO ボードにスコアの項目はありません。winnerteam1team2draw のいずれかを送ります。League ボードは team1_scoreteam2_score を受け取り、勝者を自分で導きます。互いに相手のフィールドを拒否し、問題のキー名を含む 422 を返します。

Time ボードはミリ秒で受け取る

整数で指定します。1:3090000 です。秒を送ってしまうのがこの API で最も起こりやすい連携ミスであり、だからこそ Time ボードは自分の書き込み契約に score_unit: milliseconds を明記しています。

各サイドはちょうど team_size 人

3v3 のボードは各サイド 3 人を求めます。それ以外は 422 team_size_mismatch です。例外は fixed_teams: true のボードで、各サイドはちょうど 1 つの team_id になります。試合はメンバー同士ではなく、2 つのチーム実体の間で行われます。

プレイヤー ID には名前空間が付く

レスポンスは discord:198374652000000000custom:Avarole:998877team:5 の形で返り、パスやフィルタも同じ形を受け付けます。入力では素の Discord snowflake も使えるので、Discord からコピーした ID をそのまま貼り付けられます。

すべての POST に Idempotency-Key が必要

任意ではなく必須です。タイムアウトは失敗と区別できず、やみくもな再送は誰かの承認チャンネルに 2 件目の試合を作ってしまいます。同じキーで同じボディなら保存済みのレスポンスをそのまま返し、同じキーで異なるボディなら 409 です。記録は少なくとも 24 時間保持されます。これは下限であって有効期限ではないので、キーは使い回さず、一度使ったら使い切りと考えてください。

オフセットではなくカーソル

リーダーボードはリクエストの合間に変化するため、オフセットによるページングは二重に数えてしまいます。カーソルは値とタイブレーカーの組み合わせなので、同点のかたまりの途中でページが切れても、その途中から再開でき、同点の全員を飛ばすことがありません。既定は 1 ページ 50 行、最大 200 行です。next_cursor は不透明な値として扱ってください。

ボードの種類ごとに受け付ける形

書き込みの形はボードが決めるもので、送信のたびに選ぶものではありません。この表をコードに埋め込むのではなく GET /boards/{board} に尋ねてください。write ブロックが、その 1 つのボードについて K 係数や勝ち点まで含めて答えます。

ボードの種類エンドポイントボディに含めるもの422 で拒否されるもの承認時に適用される処理
ClassicPOST /boards/{board}/scoresplayer, scoreteam1, team2, winner, team1_score, team2_score送られたスコアを累計に加算します。
HighscorePOST /boards/{board}/scoresplayer, scoreteam1, team2, winner, team1_score, team2_score旧スコアと新スコアのうち良いほうを残します。
TimePOST /boards/{board}/scoresplayer, scoreteam1, team2, winner, team1_score, team2_scoreボードの並び順に従って良いほうのタイムを残します。
ELOPOST /boards/{board}/matchesteam1, team2, winnerteam1_score, team2_score, scoreチームのレートをゼロサムで増減し、勝敗・引き分けを記録します。
LeaguePOST /boards/{board}/matchesteam1, team2, team1_score, team2_scorewinner, score勝ち・引き分け・負けに応じた勝ち点と、成績を記録します。
Ratingv0 に書き込みエンドポイントはありません。レート投稿には画像のアップロードが必要です。

認証

ダッシュボードで発行したキーを Authorization: Bearer sk_live_... として送ってください。キーは必ず 1 人の所有者 — Discord サーバー、または個人ボードの場合はユーザーアカウント — に結び付いているため、他人のボードには決して届きません。他人のボードは 403 ではなく必ず 404 です。存在を認めてしまえば、誰でも他人のボードを列挙できてしまうからです。

テストキーは読み取り専用です。sk_test_ キーはすべての読み取りに応答し、書き込みスコープはすべて拒否します。キーは作成時に一度だけ表示され、ハッシュとしてのみ保存されます。紛失したら新しく発行して古いものを失効させてください。1 人の所有者が複数の有効なキーを持てるので、入れ替えにダウンタイムはありません。

秘密キーをクライアント側のコードに置かないでください。/v1 のルートは CORS ヘッダーを一切返さないため、ブラウザからは呼び出せません。これは意図的な設計であり、漏れではありません。

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

スコープ

既定は最小権限です。ダッシュボードの 読み取りのみ は 3 つの読み取りスコープだけを付与し、matches:writescores:write が加わるのは 読み取りと書き込み のときだけです。スコープは厳密に照合され、互いを含みません。matches:write があっても matches:read にはなりません。2 つの書き込みスコープを分けているのは影響範囲が違うからです。スコア連携の不具合は 1 つのボードを膨らませるだけですが、試合連携の不具合は全員のレートを動かします。

スコープ許可される操作
boards:readボードの一覧、ボードの書き込み契約の取得、書き込みハンドルの解決。
entries:read順位表の取得と、参加者 1 人の順位の取得。
matches:readELO ボードと League ボードの試合履歴の取得。
matches:writeELO または League の試合をキューに入れる。テストキーには決して付与されません。
scores:writeClassic、Highscore、Time のボードにスコアをキュー投入する。テストキーには決して付与されません。

エンドポイント

9 つの操作があります。以下のパスはすべてベース URL からの相対で、すべてのリクエストに bearer キーが必要です。2xx 以外のレスポンスはすべて problem ドキュメントです。

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.

パラメータ
名前位置説明
limit任意queryinteger

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

cursor任意querystring

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.

レスポンス
ステータス説明
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.

フィールド説明
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.

レスポンス例 · 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
}
リクエスト例
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.

パラメータ
名前位置説明
board必須pathstring

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.

レスポンス
ステータス説明
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.

フィールド説明
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.

レスポンス例 · 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
}
リクエスト例
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.

パラメータ
名前位置説明
board必須pathstring

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.

limit任意queryinteger

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

cursor任意querystring

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.

レスポンス
ステータス説明
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.

フィールド説明
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.

レスポンス例 · 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"
}
リクエスト例
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.

パラメータ
名前位置説明
board必須pathstring

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_id必須pathstring

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.

レスポンス
ステータス説明
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.

フィールド説明
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
score任意number | null
elo任意number | null
wins任意integer | null
draws任意integer | null
losses任意integer | null
submissions任意integer | null
last_substring | null

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

レスポンス例 · 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
}
リクエスト例
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.

パラメータ
名前位置説明
board必須pathstring

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.

limit任意queryinteger

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

cursor任意querystring

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.

player任意querystring

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.

status任意query"pending" | "approved" | "rejected" | "adjusted" | "set"

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

レスポンス
ステータス説明
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.

フィールド説明
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.

レスポンス例 · 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
}
リクエスト例
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.

パラメータ
名前位置説明
board必須pathstring

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-Key必須headerstring

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.

リクエストボディ application/json

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

次のいずれか 1 つだけを送ってください。ボードに合う形のものです。

EloMatchBody
フィールド説明
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
フィールド説明
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"
}
レスポンス
ステータス説明
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.

フィールド説明
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.

match任意MatchRef | null

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

レスポンス例 · 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
  }
}
リクエスト例
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.

パラメータ
名前位置説明
board必須pathstring

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_id必須pathinteger

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

レスポンス
ステータス説明
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.

フィールド説明
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_score任意number | null

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

team2_score任意number | 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_by任意string | null

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

レスポンス例 · 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"
}
リクエスト例
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.

パラメータ
名前位置説明
board必須pathstring

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-Key必須headerstring

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.

リクエストボディ application/json

One participant and one integer score.

フィールド説明
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
}
レスポンス
ステータス説明
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.

フィールド説明
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.

match任意MatchRef | null

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

レスポンス例 · 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
  }
}
リクエスト例
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.

パラメータ
名前位置説明
handle必須pathstring

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

レスポンス
ステータス説明
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.

フィールド説明
handlestring
pattern ^sub_\d+$
status"queued" | "processing" | "pending_review" | "approved" | "rejected" | "failed"
boardstring
matchMatchRef | null
レスポンス例 · 200
{
  "handle": "sub_88213",
  "status": "approved",
  "board": "board_1",
  "match": {
    "id": 3,
    "url": "https://scoreboards.dev/leaderboards/111222333444555666/board_1",
    "applied": true
  }
}
リクエスト例
curl 'https://api.scoreboards.dev/v1/submissions/sub_88213' \
  -H 'Authorization: Bearer sk_live_...'

データ型

上のレスポンスを構成するオブジェクトです。フィールド名も説明も、ハンドラーが検証に使っているスキーマから取り出しているため、API が実際に受け取り返すものとずれることはありません。

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.

フィールド説明
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.

フィールド説明
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.

フィールド説明
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_unit任意string

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

k_factor任意integer
start_rating任意integer
points_win任意integer
points_draw任意integer
points_loss任意integer

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.

フィールド説明
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
score任意number | null
elo任意number | null
wins任意integer | null
draws任意integer | null
losses任意integer | null
submissions任意integer | null
last_substring | null

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

MatchParticipant

フィールド説明
player_idstring
player_namestring
elo任意number | 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.

フィールド説明
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_score任意number | null

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

team2_score任意number | 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_by任意string | 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.

フィールド説明
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.

フィールド説明
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.

フィールド説明
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.

フィールド説明
team_idstring
pattern ^team_\d+$

RoleParticipant

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

フィールド説明
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.

フィールド説明
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.

フィールド説明
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.

フィールド説明
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.

フィールド説明
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.

フィールド説明
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.

フィールド説明
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.

フィールド説明
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.

match任意MatchRef | 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.

フィールド説明
handlestring
pattern ^sub_\d+$
status"queued" | "processing" | "pending_review" | "approved" | "rejected" | "failed"
boardstring
matchMatchRef | null

エラー

このリファレンスにあるエンドポイントの失敗は、すべて application/problem+json で返る RFC 9457 の problem ドキュメントです。分岐は code で行ってください。これが契約の安定した部分で、titledetail は人間向けの文章なので書き換わることがあります。1 つだけ例外に備えてください。どのルートにも一致しない URL は API ではなくプラットフォームが応答するため、認証済みのリクエストでもパスを打ち間違えるとプレーンテキストの 404 が返ります。エラー本文をパースする前に content type を確認してください。

API は英語のみで、Accept-Language を無視します。これは意図的です。環境ごとに言語が変わるエラー文字列は、検索しやすくなるどころか探しにくくなります。このドキュメントは翻訳されていますが、API は翻訳されません。

すべてのレスポンスに X-Request-Id が付きます。問題を報告するときはこれを添えてください。あなたの報告と私たちのログ行を結び付けるものです。

{
  "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"
}
コードステータス発生する場面
invalid_request400 · 413 · 422ボディ、クエリパラメータ、カーソルのいずれかがスキーマに合いませんでした。detail に問題のフィールド名が入ります。64 KB を超えるボディにも使われます。
authentication_failed401キーが無い、形式が不正、未知、または失効しています。
permission_denied403キーにそのスコープがありません。あるいはテストキーで、要求されたのが書き込みスコープです。
not_found404このキーの所有者には、そのボード・エントリー・試合・ハンドルが存在しません。他人のリソースは、存在しないものと区別できません。
idempotency_conflict409同じ Idempotency-Key が別のボディで再利用されたか、同一のリクエストがまだ処理中です。
rate_limited429残量がありません。Retry-After に示された秒数だけ待ってください。
board_type_mismatch422エンドポイントは正しいがボードが違います。試合ボードにスコアを送った、スコアボードに試合を送った、または Rating ボードに何かを送った場合です。
team_size_mismatch422いずれかのサイドがボードの team_size とちょうど一致していないか、固定チームのボードでサイドがちょうど 1 チーム以外を指しています。
duplicate_participant422同じ参加者が 2 回現れています。ID を解決した後に判定するため、role_998877 と素の snowflake 998877 は同一の存在として数えられます。
unknown_player422参加者が使えません。17 桁未満の snowflake、Discord サーバーのないボードでのロール、固定チームでないボードでのチーム、または custom: true を付けずに送られたカスタムプレイヤーです。
board_locked409そのボードは書き込みを受け付けていません。
internal_error500こちら側で問題が発生しました。再試行し、続くようなら request_id を添えてご連絡ください。

レート制限

キーごとに 1 分あたり読み取り 600 回、書き込み 60 回で、2 つの独立したトークンバケットとして管理されます。書き込みが集中しても、その結果を待つポーリングの分が枯渇することはありません。バケットは連続的に補充されるので、短いバーストは通り、持続的な速度だけが制限されます。

RateLimit-Reset は今からの秒数、X-RateLimit-Reset は Unix タイムスタンプです。同じ瞬間を異なる単位で表しているので、取り違えるとクライアントは API を叩き続けるか、何十年も眠り込むことになります。

認証を通過した以降のすべてのレスポンスで、両方のヘッダー群が送られます。

ヘッダー説明
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.

足りないものがありますか

何を作っているのか教えてください。v0 が読み取りと 3 つの書き込み経路だけなのは意図的です。ボード作成、シーズン、送信 Webhook、試合の取り消しはすべて、実際に必要とする人が現れるまで見送っています。その 1 件の要望のほうが、私たちのロードマップより価値があります。ベータ版のアクセス申請はここではなく、ダッシュボードの API セクションで行います。

Discord で質問する