API công khai · v0BetaPhiên bản tài liệu 0.1.0

Tài liệu API Scoreboards

Đọc bảng xếp hạng của bạn và gửi kết quả vào đó từ bất cứ thứ gì có thể tạo một yêu cầu HTTP. Chín endpoint, một khóa, không cần SDK.

API đang ở giai đoạn beta

Quyền truy cập được duyệt theo từng máy chủ. Hãy mở bảng điều khiển, vào mục API của máy chủ bạn quản trị và gửi yêu cầu ở đó; sau khi được duyệt bạn có thể tạo khóa. Trong thời gian v0 còn beta, các endpoint và trường phản hồi vẫn có thể thay đổi, và mọi tích hợp đã được duyệt sẽ được báo trước.

Đọc phần này trước: ghi dữ liệu là bất đồng bộ

Một thao tác ghi không trả về thứ hạng. Nó trả về 202 Accepted cùng một handle, bởi API chỉ xếp một lệnh vào hàng đợi — toàn bộ việc tính điểm thuộc về bot Discord. Trên bảng có thời gian chờ duyệt, bảng xếp hạng không thay đổi cho tới khi một người kiểm duyệt bấm Approve trong Discord, và điều đó có thể xảy ra một ngày sau, hoặc không bao giờ.

201 Created chỉ xảy ra trong đúng một trường hợp: thời gian chờ duyệt của bảng bằng 0 giờ và worker hoàn tất trong khoảng ba giây mà API chờ. Nếu hết thời gian chờ, phản hồi vẫn là 202, nên hãy xử lý cả hai.

Hỏi lại GET /submissions/{handle} là cách đúng duy nhất để biết chuyện gì đã xảy ra. Một tích hợp coi POST thành công là bảng xếp hạng đã đổi thì đã hỏng ngay từ trước khi chạy thật.

Trước khi viết dòng mã nào

Tám điều quyết định một tích hợp có chạy được hay không. Không điều nào đoán ra được từ danh sách endpoint.

Ghi là một yêu cầu, không phải một kết quả

POST xếp một lệnh vào hàng đợi và trả về handle dạng sub_88213 cùng applied: false. Hãy hỏi lại handle đó; đừng bao giờ mặc định rằng thứ hạng đã thay đổi.

Điểm đánh giá được chốt lúc gửi

Điểm của từng người chơi được đọc khi trận đấu được tạo, không phải khi được duyệt. Mười trận gửi liên tiếp đều tính trên điểm trước giải. Điều này ổn định, giống hệt bot — và gây bất ngờ trong lần đầu.

ELO nhận người thắng, League nhận tỉ số

Bảng ELO không có trường điểm: gửi winnerteam1, team2 hoặc draw. Bảng League mang team1_scoreteam2_score rồi tự suy ra người thắng. Mỗi bên từ chối trường của bên kia bằng một 422 có nêu tên khóa sai.

Bảng Time nhận mili-giây

Dưới dạng số nguyên: 1:3090000. Gửi giây là lỗi tích hợp dễ mắc nhất của cả API này, nên mỗi bảng Time đều ghi rõ score_unit: milliseconds trong hợp đồng ghi của chính nó.

Mỗi bên mang đúng team_size người

Bảng 3v3 cần ba người mỗi bên; bất cứ con số nào khác đều là 422 team_size_mismatch. Ngoại lệ là bảng có fixed_teams: true, nơi mỗi bên là đúng một team_id — trận đấu diễn ra giữa hai thực thể đội, không phải giữa các thành viên của chúng.

Id người chơi có tiền tố không gian tên

Phản hồi mang discord:198374652000000000, custom:Ava, role:998877 hoặc team:5, và đường dẫn lẫn bộ lọc đều chấp nhận các dạng đó. Một snowflake Discord thuần cũng dùng được ở đầu vào, nên id sao chép từ Discord có thể dán thẳng vào.

Mọi POST đều cần Idempotency-Key

Bắt buộc chứ không tùy chọn: hết thời gian chờ không phân biệt được với thất bại, và một lần gửi lại mù quáng sẽ thành trận đấu thứ hai trong kênh duyệt của ai đó. Cùng khóa với cùng nội dung sẽ phát lại phản hồi đã lưu; cùng khóa với nội dung khác là 409. Bản ghi được giữ ít nhất 24 giờ — đó là mức tối thiểu chứ không phải hạn dùng — nên hãy coi một khóa là đã dùng xong vĩnh viễn thay vì tái sử dụng.

Con trỏ, không phải offset

Bảng xếp hạng thay đổi giữa hai yêu cầu, nên phân trang theo offset sẽ đếm trùng. Con trỏ là tổ hợp — một giá trị cộng một tiêu chí phá hòa — nhờ đó ranh giới trang rơi vào giữa một cụm điểm bằng nhau sẽ tiếp tục ngay trong cụm đó thay vì bỏ qua tất cả. Mặc định 50 dòng mỗi trang, tối đa 200. Hãy coi next_cursor là giá trị mờ.

Mỗi loại bảng chấp nhận những gì

Dạng dữ liệu ghi do bảng quy định, không bao giờ được chọn theo từng lần gửi. Thay vì viết cứng bảng này, hãy hỏi GET /boards/{board}: khối write trả lời cho đúng bảng đó, tới tận hệ số k hay số điểm mỗi trận thắng.

Loại bảngEndpointNội dung mang theoBị từ chối với 422Điều được áp dụng khi duyệt
ClassicPOST /boards/{board}/scoresplayer, scoreteam1, team2, winner, team1_score, team2_scoreCộng điểm vừa gửi vào tổng đang có.
HighscorePOST /boards/{board}/scoresplayer, scoreteam1, team2, winner, team1_score, team2_scoreGiữ lại giá trị tốt hơn giữa điểm cũ và điểm mới.
TimePOST /boards/{board}/scoresplayer, scoreteam1, team2, winner, team1_score, team2_scoreGiữ lại thời gian tốt hơn theo thứ tự sắp xếp của bảng.
ELOPOST /boards/{board}/matchesteam1, team2, winnerteam1_score, team2_score, scoreThay đổi điểm đánh giá theo tổng bằng không cho hai đội, kèm thắng, hòa hoặc thua.
LeaguePOST /boards/{board}/matchesteam1, team2, team1_score, team2_scorewinner, scoreĐiểm cho trận thắng, hòa hoặc thua, kèm thành tích.
RatingKhông có endpoint ghi ở v0 — một lượt chấm điểm cần tải ảnh lên.

Xác thực

Gửi khóa lấy từ bảng điều khiển dưới dạng Authorization: Bearer sk_live_.... Mỗi khóa gắn với đúng một chủ sở hữu — một máy chủ Discord, hoặc một tài khoản người dùng cho bảng cá nhân — nên một khóa không bao giờ chạm tới bảng của người khác. Bảng của người khác trả về 404, không bao giờ là 403: xác nhận rằng nó tồn tại sẽ cho phép bất kỳ ai liệt kê bảng của người khác.

Khóa thử nghiệm chỉ đọc được. Khóa sk_test_ phục vụ mọi thao tác đọc và từ chối mọi scope ghi. Khóa chỉ hiển thị một lần lúc tạo và chỉ được lưu dưới dạng hash, nên nếu mất, hãy tạo khóa mới rồi thu hồi khóa cũ — mỗi chủ sở hữu được phép có nhiều khóa đang hoạt động, nên việc xoay khóa không gây gián đoạn.

Đừng bao giờ đặt khóa bí mật vào mã chạy phía trình duyệt. Các route /v1 hoàn toàn không gửi header CORS nào, nên trình duyệt không gọi được. Đó là chủ ý, không phải thiếu sót.

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

Scope

Mặc định là ít quyền nhất: mức Chỉ đọc trong bảng điều khiển cấp ba scope đọc và không gì hơn, chỉ Đọc và ghi mới thêm matches:writescores:write. Các scope được kiểm tra chính xác và không suy ra lẫn nhau — matches:write không cấp matches:read. Hai scope ghi tách riêng vì mức thiệt hại khác nhau: một tích hợp điểm số sai chỉ thổi phồng một bảng, còn một tích hợp trận đấu sai làm dịch chuyển điểm của tất cả mọi người.

ScopeCho phép
boards:readLiệt kê bảng, đọc hợp đồng ghi của một bảng và tra cứu handle ghi.
entries:readĐọc bảng thứ hạng và vị trí của một người chơi.
matches:readĐọc lịch sử trận đấu trên bảng ELO và League.
matches:writeXếp hàng một trận ELO hoặc League. Không bao giờ cấp cho khóa thử nghiệm.
scores:writeXếp hàng một điểm số trên bảng Classic, Highscore hoặc Time. Không bao giờ cấp cho khóa thử nghiệm.

Endpoint

Chín thao tác. Mọi đường dẫn bên dưới là tương đối so với URL gốc, mọi yêu cầu đều cần khóa bearer, và mọi phản hồi không phải 2xx đều là một tài liệu mô tả lỗi.

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.

Tham số
TênVị tríKiểuMô tả
limittùy chọnqueryinteger

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

cursortùy chọnquerystring

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.

Phản hồi
Trạng tháiMô tả
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.

TrườngKiểuMô tả
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.

Phản hồi ví dụ · 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
}
Yêu cầu ví dụ
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.

Tham số
TênVị tríKiểuMô tả
boardbắt buộcpathstring

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.

Phản hồi
Trạng tháiMô tả
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.

TrườngKiểuMô tả
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.

Phản hồi ví dụ · 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
}
Yêu cầu ví dụ
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.

Tham số
TênVị tríKiểuMô tả
boardbắt buộcpathstring

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.

limittùy chọnqueryinteger

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

cursortùy chọnquerystring

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.

Phản hồi
Trạng tháiMô tả
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.

TrườngKiểuMô tả
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.

Phản hồi ví dụ · 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"
}
Yêu cầu ví dụ
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.

Tham số
TênVị tríKiểuMô tả
boardbắt buộcpathstring

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_idbắt buộcpathstring

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.

Phản hồi
Trạng tháiMô tả
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.

TrườngKiểuMô tả
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
scoretùy chọnnumber | null
elotùy chọnnumber | null
winstùy chọninteger | null
drawstùy chọninteger | null
lossestùy chọninteger | null
submissionstùy chọninteger | null
last_substring | null

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

Phản hồi ví dụ · 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
}
Yêu cầu ví dụ
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.

Tham số
TênVị tríKiểuMô tả
boardbắt buộcpathstring

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.

limittùy chọnqueryinteger

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

cursortùy chọnquerystring

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.

playertùy chọnquerystring

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.

statustùy chọnquery"pending" | "approved" | "rejected" | "adjusted" | "set"

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

Phản hồi
Trạng tháiMô tả
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.

TrườngKiểuMô tả
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.

Phản hồi ví dụ · 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
}
Yêu cầu ví dụ
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.

Tham số
TênVị tríKiểuMô tả
boardbắt buộcpathstring

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-Keybắt buộcheaderstring

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.

Nội dung yêu cầu application/json

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

Gửi đúng một trong các dạng sau — dạng khớp với bảng của bạn:

EloMatchBody
TrườngKiểuMô tả
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
TrườngKiểuMô tả
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"
}
Phản hồi
Trạng tháiMô tả
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.

TrườngKiểuMô tả
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.

matchtùy chọnMatchRef | null

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

Phản hồi ví dụ · 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
  }
}
Yêu cầu ví dụ
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.

Tham số
TênVị tríKiểuMô tả
boardbắt buộcpathstring

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_idbắt buộcpathinteger

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

Phản hồi
Trạng tháiMô tả
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.

TrườngKiểuMô tả
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_scoretùy chọnnumber | null

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

team2_scoretùy chọnnumber | 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_bytùy chọnstring | null

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

Phản hồi ví dụ · 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"
}
Yêu cầu ví dụ
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.

Tham số
TênVị tríKiểuMô tả
boardbắt buộcpathstring

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-Keybắt buộcheaderstring

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.

Nội dung yêu cầu application/json

One participant and one integer score.

TrườngKiểuMô tả
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
}
Phản hồi
Trạng tháiMô tả
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.

TrườngKiểuMô tả
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.

matchtùy chọnMatchRef | null

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

Phản hồi ví dụ · 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
  }
}
Yêu cầu ví dụ
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.

Tham số
TênVị tríKiểuMô tả
handlebắt buộcpathstring

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

Phản hồi
Trạng tháiMô tả
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.

TrườngKiểuMô tả
handlestring
pattern ^sub_\d+$
status"queued" | "processing" | "pending_review" | "approved" | "rejected" | "failed"
boardstring
matchMatchRef | null
Phản hồi ví dụ · 200
{
  "handle": "sub_88213",
  "status": "approved",
  "board": "board_1",
  "match": {
    "id": 3,
    "url": "https://scoreboards.dev/leaderboards/111222333444555666/board_1",
    "applied": true
  }
}
Yêu cầu ví dụ
curl 'https://api.scoreboards.dev/v1/submissions/sub_88213' \
  -H 'Authorization: Bearer sk_live_...'

Kiểu dữ liệu

Những đối tượng tạo nên các phản hồi ở trên. Mọi tên trường và mô tả đều lấy từ chính các schema mà handler dùng để kiểm tra, nên chúng không thể lệch khỏi những gì API thực sự nhận và trả về.

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.

TrườngKiểuMô tả
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.

TrườngKiểuMô tả
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.

TrườngKiểuMô tả
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_unittùy chọnstring

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

k_factortùy chọninteger
start_ratingtùy chọninteger
points_wintùy chọninteger
points_drawtùy chọninteger
points_losstùy chọninteger

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.

TrườngKiểuMô tả
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
scoretùy chọnnumber | null
elotùy chọnnumber | null
winstùy chọninteger | null
drawstùy chọninteger | null
lossestùy chọninteger | null
submissionstùy chọninteger | null
last_substring | null

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

MatchParticipant

TrườngKiểuMô tả
player_idstring
player_namestring
elotùy chọnnumber | 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.

TrườngKiểuMô tả
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_scoretùy chọnnumber | null

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

team2_scoretùy chọnnumber | 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_bytùy chọnstring | 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.

TrườngKiểuMô tả
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.

TrườngKiểuMô tả
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.

TrườngKiểuMô tả
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.

TrườngKiểuMô tả
team_idstring
pattern ^team_\d+$

RoleParticipant

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

TrườngKiểuMô tả
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.

TrườngKiểuMô tả
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.

TrườngKiểuMô tả
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.

TrườngKiểuMô tả
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.

TrườngKiểuMô tả
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.

TrườngKiểuMô tả
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.

TrườngKiểuMô tả
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.

TrườngKiểuMô tả
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.

matchtùy chọnMatchRef | 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.

TrườngKiểuMô tả
handlestring
pattern ^sub_\d+$
status"queued" | "processing" | "pending_review" | "approved" | "rejected" | "failed"
boardstring
matchMatchRef | null

Lỗi

Mọi thất bại từ một endpoint trong tài liệu tham chiếu này đều là tài liệu lỗi theo RFC 9457, trả về dưới dạng application/problem+json. Hãy rẽ nhánh theo code: đó là phần ổn định của hợp đồng, còn titledetail viết cho con người đọc và có thể được diễn đạt lại. Một trường hợp cần lường trước: URL không khớp với bất kỳ route nào sẽ do nền tảng trả lời chứ không phải API, nên một yêu cầu đã xác thực gửi tới đường dẫn gõ sai sẽ nhận 404 ở dạng văn bản thuần — hãy kiểm tra content type trước khi phân tích phần thân lỗi.

API chỉ dùng tiếng Anh và bỏ qua Accept-Language. Đó là chủ ý: một chuỗi lỗi đổi ngôn ngữ theo môi trường sẽ khó tra cứu hơn chứ không dễ hơn. Tài liệu này được dịch; API thì không.

Mọi phản hồi đều mang một X-Request-Id. Hãy trích dẫn nó khi báo lỗi — đó là thứ nối báo cáo của bạn với dòng log của chúng tôi.

{
  "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"
}
Trạng tháiKhi nào gặp
invalid_request400 · 413 · 422Nội dung, tham số truy vấn hoặc con trỏ không khớp schema; phần detail nêu tên trường có vấn đề. Cũng dùng cho nội dung lớn hơn 64 KB.
authentication_failed401Khóa bị thiếu, sai định dạng, không tồn tại hoặc đã bị thu hồi.
permission_denied403Khóa thiếu scope đó — hoặc đây là khóa thử nghiệm và scope được yêu cầu là scope ghi.
not_found404Không có bảng, mục, trận đấu hay handle như vậy cho chủ sở hữu của khóa này. Tài nguyên của người khác không thể phân biệt với tài nguyên không tồn tại.
idempotency_conflict409Cùng một Idempotency-Key được dùng lại với nội dung khác, hoặc một yêu cầu y hệt vẫn đang chạy.
rate_limited429Hạn mức đã cạn. Hãy đợi số giây ghi trong Retry-After.
board_type_mismatch422Đúng endpoint, sai bảng: gửi điểm tới bảng trận đấu, gửi trận đấu tới bảng điểm, hoặc gửi bất cứ thứ gì tới bảng Rating.
team_size_mismatch422Một bên không mang đúng team_size của bảng — hoặc một bên trên bảng đội cố định nêu thứ gì đó khác với đúng một đội.
duplicate_participant422Cùng một người chơi xuất hiện hai lần. Việc kiểm tra diễn ra sau khi phân giải id, nên role_998877 và snowflake thuần 998877 được tính là một thực thể.
unknown_player422Một người chơi không dùng được: snowflake ngắn hơn 17 chữ số, một role trên bảng không có máy chủ Discord, một đội trên bảng không dùng đội cố định, hoặc một người chơi tùy chỉnh gửi mà thiếu custom: true.
board_locked409Bảng này hiện không nhận thao tác ghi.
internal_error500Có gì đó hỏng ở phía chúng tôi. Hãy thử lại, và trích dẫn request_id nếu lỗi lặp lại.

Giới hạn tần suất

600 lượt đọc và 60 lượt ghi mỗi phút cho mỗi khóa, dưới dạng hai token bucket riêng biệt — một đợt ghi dồn dập không bao giờ làm cạn phần dành cho việc hỏi lại kết quả của chính chúng. Các bucket được nạp lại liên tục, nên một đợt dồn ngắn vẫn được chấp nhận còn tốc độ duy trì thì bị giới hạn.

RateLimit-Reset là số giây kể từ bây giờ; X-RateLimit-Reset là dấu thời gian Unix. Chúng mô tả cùng một thời điểm bằng hai đơn vị khác nhau, và đọc nhầm cái này thành cái kia sẽ khiến client hoặc dội bom API, hoặc ngủ suốt vài chục năm.

Cả hai nhóm header đều được gửi trên mọi phản hồi sau bước xác thực.

HeaderMô tả
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.

Còn thiếu gì không?

Hãy kể cho chúng tôi bạn đang xây dựng gì. v0 cố tình chỉ có các thao tác đọc và ba đường ghi — tạo bảng, mùa giải, webhook gửi đi và hủy trận đều được hoãn cho tới khi có người thật sự cần, và một yêu cầu như thế đáng giá hơn lộ trình của chúng tôi. Quyền truy cập beta không được yêu cầu ở đây: việc đó diễn ra ở mục API trong bảng điều khiển của bạn.

Hỏi trong Discord của chúng tôi