Leaderboard API
Who this is for
You run a game somewhere (a browser game on your own site, a bot, a script that scores a quiz) and a moderator has set up a leaderboard for it on their Discord server. This guide covers the six HTTP routes that game talks to — one to submit a result to a board, one to submit it to every board the key feeds at once, three to read standings, one to read the moderator's game preferences and the server clock — plus the launch link the bot hands players and the small browser helper that reads it. Everything the moderator does lives in the moderator guide.
Example host throughout: https://village-vote.fun. Your server's API tab on the setup page shows the real URLs with your server id, board slug and key filled in. In a hurry? The integration guide is the short path through this page.
The model
A board is one leaderboard on one Discord server, addressed by the server's id and the board's slug (friday-arcade). A game key identifies your game on that server and is bound to the boards it may feed; it is the only credential you send. A result is one finished game for one player: an idempotencyKey you made up, the player, and either a points value or an outcome, depending on the board's mode. A points board either sums a player's results or keeps their best one (the moderator chose which when they made it, and a lowest-wins board is always best). An Elo board is for versus games: every result is a match with a win, loss or draw against one opponent, everyone starts at 1000, and the two ratings move after each match. The player is named one of two ways: by a play session the bot issued when the player launched your game from Discord (the preferred way), or by a discordUserId your game supplies itself. A match pairs two players' reports; when the bot made the match through a challenge, the session already knows which match it belongs to. The bot stores every result in a ledger, keeps standings derived from it, and edits the live message in Discord.
Authentication
Every request carries the key's public key as a Bearer token:
Authorization: Bearer lbk_3f9a2c…
The moderator makes the key on the setup page (Keys tab) and gives you the public key, the server id and the board slug; for a hosted game the bot made the key itself and puts it in the launch link. The key must be bound to the board you call, and the gameId in your body must match the key's game id. A revoked key answers 401 bad_key from then on.
A game with a backend may also have been given the key's secret (lbsec_…). With it, a submit carries two more headers, an HMAC over the exact body and a timestamp:
X-Leaderboard-Timestamp: 1760371200
X-Leaderboard-Signature: sha256=9f2c…
They are optional unless the moderator has set the key to require them; see Request signing. Hosted games never sign.
serverId in the URL is the Discord server (guild) id; boardSlug is the board's slug. CORS is open on these routes (the signing headers are allowed too), so a plain browser fetch from your game's origin works without a proxy.
Play sessions and the launch link
When a player presses ▶ Play on the board's message or runs /play, the bot replies to them privately with a link to your game. For an external game that is the key's launch URL (set by the moderator on the Keys card, https:// only); for a hosted game it is the bot's own copy. The link carries:
https://example.com/snake/?lbs=<token>&key=lbk_…&server=<serverId>&game=snake&boards=friday-arcade,saturday-arcade&api=https://village-vote.fun
| Parameter | Meaning |
|---|---|
lbs |
The session token. Send it as sessionToken in the result body. Never put it in a URL of your own. |
key |
The public key, for the Authorization header. |
server |
The server id for the URL. |
game |
The key's game id, for gameId. |
boards |
Comma-separated slugs of every board this launch may score on: the board the player launched from, plus any the moderator grouped with it. Submit to each, with the same idempotencyKey (or once, to the fan-out route). |
api |
The base URL to build /api/leaderboard/… from. |
match, opponent, role |
Only on a challenge launch (see Versus games). |
A session is bound to one player and one key, lives 30 minutes from its last renewal (the helper renews it every ten minutes while the page is open, and whenever the tab comes back into view, up to twelve hours from launch; a game without the helper calls POST …/session/touch with { "sessionToken" } itself and gets { "expiresAt" } back), and may submit to each board named in boards — as many distinct results as the player finishes (every run of an arcade game, say); the board's aggregate decides what they are worth, and a replay of one that already landed is 409 duplicate. A result that carries a valid sessionToken is credited to that player; any discordUserId in the body is ignored, so you need not send one. Read the token on load and remove it from the address bar at once with history.replaceState (the helper below does this), because a URL leaks through history, referrers and logs. After the session expires a retry of a result it already submitted (same idempotencyKey) is still answered 409 duplicate for 24 hours; any other use is 401 session_expired, and the player launches again from Discord.
A game opened without a launch link has no session. It can still submit with a discordUserId — unless the board has Require a play session on, in which case it answers 401 session_required. Hosted boards have it on from the start.
Submit a result
POST /api/leaderboard/{serverId}/{boardSlug}/results
Authorization: Bearer lbk_…
Content-Type: application/json
{
"gameId": "snake",
"sessionToken": "<lbs from the launch link>",
"idempotencyKey": "7c1e9d2a-5b0e-4f7a-9c1d-2a6f8e3b4c5d",
"points": 1240,
"initials": "DAH",
"outcome": "win",
"meta": { "level": 7 }
}
| Field | Required | Meaning |
|---|---|---|
gameId |
yes | Your key's game id (1–40 chars). Mismatch answers 403 key_not_bound. |
sessionToken |
one of these two | The lbs token from the launch link (up to 200 chars). Names the player; discordUserId is then ignored. |
discordUserId |
one of these two | The player's Discord user id, 17–20 digits, when there is no session. Your game has to know it (ask the player, or take it from your own Discord login). |
idempotencyKey |
yes | A string unique to this finished game for this player, 1–128 chars (a UUID is fine). See below. |
points |
points boards | Integer. Must be 0 or more on a highest-wins board; any integer on a lowest-wins board. The moderator may have set a ceiling. On an Elo board it is optional and recorded for history only. |
initials |
no | Arcade initials, shown on boards in initials mode. See the rules below. |
outcome |
Elo boards | win, loss or draw, this player's own result. Required on an Elo board (400 outcome_required), where it is what the rating moves on; optional on a points board, where it is recorded and counted in wins/losses/draws but ranking is by points. |
opponentId |
no | The opponent's Discord user id. Ignored on a challenge launch (the session knows the opponent). On an Elo board, omit it for a solo game rated against the house; the player's own id is 400 invalid_body. See Versus games. |
matchId |
no | 1–128 chars. Ignored on a challenge launch. Otherwise names the match within your key and this board, so both players' reports meet; when absent, the idempotencyKey is the match key. |
windowId |
windowed games only | Non-negative integer: the puzzle window this result belongs to, from the preferences route's clock. Required for a windowed game (400 invalid_window without it); ignored by every other game. See Windowed games. |
meta |
no | Any JSON up to 1 KB serialised, stored for the moderator's history view. Never shown on the board. |
The body is strict: an unknown field answers 400 invalid_body, and message names the field. So does a body with neither sessionToken nor discordUserId.
Response 201 Created:
{
"resultId": 8812,
"status": "applied",
"standing": {
"rank": 3,
"displayName": "DAH",
"discordUserId": "123456789012345678",
"points": 1240,
"rating": null,
"games": 6,
"wins": 4,
"losses": 2,
"draws": 0,
"bestResult": 1240
},
"ratingChange": null,
"board": { "name": "Friday Arcade Night", "mode": "points", "players": 22 }
}
displayName follows the board's display mode (Discord name, anonymous name, or initials), so your "you are now #3 as Amber Fox" screen works on every board. discordUserId is present only on boards that show Discord names. points is the player's total; it is what ranks on a sum board. On a best board, and on every lowest-wins board, bestResult is what ranks and games counts the runs. rating is always null on points boards. On an Elo board standing.rating is what ranks and points means nothing; games, wins, losses and draws are the player's record. ratingChange is { "before": 1000, "after": 1016 } for this player when the request applied the match — so a game can show "+16" without diffing standings — and null otherwise (always on points boards).
The status code says what the report did. 201 with "status": "applied" scored now: a points result, or the first report of an Elo match on a board without mutual confirmation, with standing and (Elo) ratingChange present. 202 with "status": "pending" is Elo under mutual confirmation: the match waits for the opponent's report, and standing and ratingChange are null. 200 answered an earlier report of the same match: "status": "confirmed" carries standing, and ratingChange when this report released a pending match; "status": "disputed" carries standing: null. See Versus games.
Errors are { "error": "<code>", "message": "<human text>" }, plus the extra fields noted. The key and the body are checked first, then the session, then the signature, then the result itself:
| Status | error |
When |
|---|---|---|
| 400 | invalid_body |
Schema failure, or no sessionToken and no discordUserId; message names the field |
| 400 | points_required |
No points on a points board |
| 400 | outcome_required |
No outcome on an Elo board |
| 400 | invalid_window |
A windowed game sent no windowId, or one that is not the current window or the one just closed |
| 401 | bad_key |
Missing, malformed, unknown or revoked key, or a key from another server |
| 401 | bad_session |
sessionToken matches no session for this key |
| 401 | session_expired |
The session is past 30 minutes since its last renewal and this is not a retry of something it already submitted |
| 401 | session_wrong_board |
The session was not launched for this board (boards in the link says which) |
| 401 | session_required |
The board requires a play session and the body has no sessionToken |
| 401 | signature_required |
The key requires signed requests and there is no X-Leaderboard-Signature |
| 401 | stale_timestamp |
A signed request whose X-Leaderboard-Timestamp is missing, not unix seconds, or more than 5 minutes from the server clock |
| 401 | bad_signature |
The signature does not match the body and timestamp, is malformed, or the key has no secret |
| 403 | key_not_bound |
The key is not bound to this board, or gameId is not the key's game |
| 404 | board_not_found |
Unknown server id or slug |
| 409 | duplicate |
This idempotencyKey was already accepted for this player, or this player already reported this match, or both players have; body carries the original resultId |
| 409 | match_conflict |
The match key names a match this player is not in, with a different opponent, or reported solo; recorded struck for moderator review, body carries resultId |
| 409 | invalid_status |
A report into a match that was declined, expired, abandoned, unconfirmed or struck |
| 409 | board_frozen |
The moderator froze the board |
| 410 | board_archived |
The board is archived; it will never accept another result |
| 422 | points_too_high |
Above the board's ceiling; recorded struck for moderator review, body carries resultId |
| 429 | rate_limited |
Retry-After header in seconds; body carries retryAfterSecs |
| 500 | internal |
Something broke on the bot's side; safe to retry with the same idempotencyKey |
Submit to every board at once
A key the moderator has bound to several boards (Friday's and Saturday's, or a sum board and a best board fed by the same game) can report one finished game to all of them in one request, when nothing in the body differs per board:
POST /api/leaderboard/{serverId}/results
Authorization: Bearer lbk_…
Content-Type: application/json
Same body, same headers as the single-board route. The bot runs the single-board rules for every open board the key is bound to (a frozen or archived board is simply not in the list) and answers 200:
{ "results": [
{ "board": "friday-arcade", "status": 201, "body": { "resultId": 8812, "status": "applied", "standing": { "…": "…" }, "ratingChange": null, "board": { "…": "…" } } },
{ "board": "friday-elo", "status": 401, "body": { "error": "session_wrong_board", "message": "…" } }
] }
Each entry's status and body are exactly what that board's own route would have answered, errors included, so read them one by one: one board may take the result while another says 409 duplicate, 422 points_too_high or 400 outcome_required. windowId, sessionToken, matchId and the rest apply per board as usual; in particular a session only covers the boards it was launched for (boards in the link), so any other board answers 401 session_wrong_board inside its entry. The request as a whole fails, with the ordinary error shape and status, only for what is checked before any board is tried: a bad key (401 bad_key), a bad body or gameId (400 invalid_body, 403 key_not_bound), the rate limit (429), a bad or missing signature (401 signature_required, stale_timestamp or bad_signature), or a key with no open board at all (403 key_not_bound).
Idempotency and retries
idempotencyKey is required and is unique per board, key and player, so the same finished game may be reported to every board the key is bound to (a launch group, or the fan-out route) under one key, and a retry to any of them is still that board's 409 duplicate. Generate it once per finished game, when the game ends, and keep it until the submit succeeds. Then retry freely on network errors, timeouts, 429 and 500: a retry of a result that already landed answers 409 duplicate with the original resultId in the body, and your game treats that exactly like 201. This is also what stops the same winning game being counted twice. Do not derive the key from the score or the player alone; two different games with the same score must have different keys.
A session does not limit how many results you send: a retry under a new key is not a retry, it is a second result, and it counts — harmless on a best board, a double count on a sum board. So keep the key with the finished game, not with the submit call. A retry under the same key is always safe, during the session and for 24 hours after it expires. Windowed games have a natural key, <boardSlug>:<windowId>, which the helper derives for you; it makes a second solve of the same window a duplicate by construction.
Initials
initials is trimmed, uppercased, stripped to A–Z0–9 and cut to three characters: d.a.h becomes DAH, ♥ becomes nothing. Send up to 16 raw characters; what remains after normalisation is what is stored. A short block list of three-letter words is refused silently: the result still counts, and the player shows as the first three letters of their anonymous name instead. A player who never sends initials gets the same fallback. Initials are updated by every result that carries them, including one that is later struck, and apply across the whole board, so send them on every submission or only when the player changes them, as you prefer.
The ceiling
A moderator who knows Snake cannot score above 2,000 in one game sets 2,000 on the board. A result above it answers 422 points_too_high and is recorded struck, so the moderator sees the attempt in history and in the log channel; it never counts. Ask the moderator what the ceiling is and treat 422 as a bug in your scoring, not something to retry.
Rate limits
Three limits apply to every request, per bot instance; the first to run dry answers 429 with a Retry-After header:
- per key: 60 requests a minute by default; the moderator sets it on the key.
- per key and IP: a quarter of the key's limit, minimum 5.
- per IP, across every key: 300 a minute.
Bursts up to the limit are allowed; refill is continuous. A server-side game that submits for all its players from one machine hits the per-key+IP limit first, so ask the moderator to raise the key's limit. Read Retry-After, wait, and retry with the same idempotencyKey.
Request signing
A key for an external game can have a secret: lbsec_ followed by 64 hex characters, shown to the moderator once when the key is made (or when they first require signing on an older key, or rotate it) and handed to you out of band. It belongs on a server, in an environment variable or a secrets store, never in a page a player can load. With it you sign each submit:
stringToSign = timestamp + "." + rawBody timestamp: unix seconds; rawBody: the exact bytes you send
X-Leaderboard-Signature: sha256=<hex of HMAC-SHA256(secret, stringToSign)>
X-Leaderboard-Timestamp: <timestamp>
The rules the server applies:
- Signatures are checked whenever they are sent, whether or not the key requires them. A good one marks the result as signed (the moderator's log line and history say so); a wrong, malformed or stale one is refused even on a key where signing is optional. So start signing as soon as you have the secret and let the moderator flip Require signing once they see your results arriving signed; from then on an unsigned request is
401 signature_required. - The timestamp must be an integer in seconds within 5 minutes of the server clock, or the answer is
401 stale_timestamp(also when the header is missing, or sent in milliseconds). Everything else that fails is401 bad_signature: a mismatch, a header that is notsha256=plus 64 hex characters, or a key that has no secret at all. - Serialise once, sign that string, send that string. The server verifies the bytes it received; a body re-serialised by your HTTP library (different whitespace, a different key order) fails. Build the body with one
JSON.stringify/json.dumpscall, sign it, and pass the same string as the request body, never the object. - There is no nonce and no replay cache:
idempotencyKeyalready makes a replayed body409 duplicate, and the timestamp bounds how long a captured request is useful. - Rotation (the moderator's Rotate secret button) kills the old secret immediately: requests signed with it are
401 bad_signaturefrom then on. Deploy the new secret before they rotate.
Node (18+):
import { createHmac, randomUUID } from 'node:crypto';
const secret = process.env.LEADERBOARD_SECRET; // lbsec_…, from the moderator
const body = JSON.stringify({ gameId: 'snake', discordUserId: '<discordUserId>', idempotencyKey: randomUUID(), points: 1240 });
const timestamp = Math.floor(Date.now() / 1000);
const signature = 'sha256=' + createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');
const res = await fetch('https://village-vote.fun/api/leaderboard/<serverId>/<boardSlug>/results', {
method: 'POST',
headers: {
Authorization: 'Bearer lbk_…',
'Content-Type': 'application/json',
'X-Leaderboard-Timestamp': String(timestamp),
'X-Leaderboard-Signature': signature,
},
body, // the string that was signed, as is
});
Python (requests):
import hashlib, hmac, json, os, time, uuid
import requests
secret = os.environ["LEADERBOARD_SECRET"]
body = json.dumps({"gameId": "snake", "discordUserId": "<discordUserId>",
"idempotencyKey": str(uuid.uuid4()), "points": 1240}, separators=(",", ":"))
ts = int(time.time())
sig = "sha256=" + hmac.new(secret.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest()
url = "https://village-vote.fun/api/leaderboard/<serverId>/<boardSlug>/results"
res = requests.post(url, data=body, headers={ # data=, not json=: json= would re-serialise the body
"Authorization": "Bearer lbk_…", "Content-Type": "application/json",
"X-Leaderboard-Timestamp": str(ts), "X-Leaderboard-Signature": sig,
})
In a browser, do not. The same three lines work with
SubtleCrypto, but a browser game cannot keep a secret: anyone who reads its source, or its network tab, has it, and can sign whatever they like. Signing there is a deterrent against the laziest tampering and nothing more; a play session is the defence for a browser game. If your game has any backend, submit from the backend and sign there; if it has none, use sessions and ask the moderator to require them.
Reading standings
GET /api/leaderboard/{serverId}/{boardSlug}/standings?limit=10
Authorization: Bearer lbk_…
limit defaults to the board's top N and is capped at 100.
{
"board": { "name": "Friday Arcade Night", "slug": "friday-arcade", "mode": "points",
"displayMode": "initials", "status": "open", "order": "desc" },
"standings": [
{ "rank": 1, "displayName": "DAH", "points": 1240, "rating": null,
"games": 6, "wins": 4, "losses": 2, "draws": 0, "bestResult": 1240 }
],
"players": 22
}
order is desc (highest wins) or asc (lowest wins). players is the number of players with a counted result, whatever limit you asked for.
The session's own row, for a "your rank" screen, on any board:
GET /api/leaderboard/{serverId}/{boardSlug}/standings/me?session=<lbs token>
Authorization: Bearer lbk_…
Answers { "standing": { … }, "players": 22 }, with standing: null when the player has no counted result yet, and 401 bad_session when the token is not one issued for this key and board. This is the one place a token travels in a URL; it is your own API call, not a page address, so it does not land in history. (Inside Discord the player has the same row from the bot's own /mystanding command, privately, with their board name spelled out; your game need not duplicate that.)
One player's row by id, for games without sessions: GET /api/leaderboard/{serverId}/{boardSlug}/standings/{discordUserId} with the same header and the same answer.
Redaction rule: Discord user ids appear in standings responses only on boards whose display mode is Discord names. On anonymous and initials boards the by-id route answers
403 lookup_not_allowed, because it would let a key holder map any Discord id to its anonymous name one request at a time. Thestandings/meroute works everywhere, because the session already proves who is asking; without one, the submit response is where your game learns the player's own row.
Game preferences and the server clock
A hosted game's catalogue entry may declare preferences: options the moderator sets per game per board on the setup page (a word list, how many words a puzzle hides, how long a window lasts). The game reads them once at launch:
GET /api/leaderboard/{serverId}/{boardSlug}/preferences?game=<gameId>
Authorization: Bearer lbk_…
{
"game": "word-scavengers",
"board": { "slug": "scav-night", "name": "Scavenger Night", "mode": "points", "order": "desc", "aggregate": "sum" },
"preferences": { "wordList": ["ANTLER", "BARN", "CIDER", "…"], "zombieWords": ["GHOUL", "SHAMBLE"],
"gridSize": "standard", "windowMinutes": 15 },
"clock": { "now": 1760371200000, "windowed": true, "windowId": 117357,
"seed": "3f9a2c7e1b0d4a8f9e6c5b2a1d0f3e7c", "windowMinutes": 15,
"windowStartsAt": 1760371200000, "windowEndsAt": 1760372100000 }
}
preferences is every field the entry declares, the moderator's value or the default; a field they never touched is still present. Word lists arrive normalised: upper-case, letters only, deduplicated. clock.now is the server's time in milliseconds, so a game that cares about time can correct for the player's clock. For a game that is not windowed, clock is { "now": …, "windowed": false }.
game must be the key's game id (403 key_not_bound otherwise, and 404 key_not_found for a key with no catalogue entry, which is every external game). The route needs the key and the board but no session, so a page opened without a launch link can still read them. It is exactly as public as the standings route, which is the point to take in: a preference is not a secret. Anything served here can be read from the browser's network tab, so a decoy list is a deterrent, not a trap, and nothing the player must never see belongs in one.
A moderator may change a preference while the board is live. The change reaches players on their next launch; results already recorded stand. Changing one that affects scoring on a board with results is confirmed on the page, logged, and stamps "Rules changed" on the board's footer, so players can tell.
Windowed games
A puzzle game that lets each browser pick its own puzzle can be ground: clear local storage, get a new one. So a windowed game (Word Scavengers is one) takes its puzzle from the server clock instead. Time is divided into windows of windowMinutes; windowId is the number of the current one, and seed is random, issued by the server the first time anyone asks for that window and stored, so a window's puzzle does not exist until it opens and every player on the board gets the same one. Seed your generator from seed, never from windowId, and submit the window with the result:
const lb = LeaderboardClient.fromLaunchUrl();
const { preferences, clock } = await lb.preferences();
const puzzle = buildPuzzle(preferences, clock.seed);
// … play until solved …
await lb.submit({ points: 1, windowId: clock.windowId });
Three server-side rules make this honest:
- The result's
windowIdmust be the current window or the one just closed (a solve may straddle the boundary); anything else, or nowindowIdat all, is400 invalid_window. - The idempotency key is
<boardSlug>:<windowId>, which the helper fills in when you passwindowIdwithout a key of your own. A second solve of the same window is therefore409 duplicate: one solve per window per player, however many times the page is reloaded. clock.nowis the server's time;lb.serverNow()keeps it after the call, so a countdown towindowEndsAtis right even on a wrong-clocked machine. When the window ends,lb.preferences({ fresh: true })fetches the next one.
Versus games and challenges
On an Elo board every result is a match, and the rating moves once per match, not once per report. The arithmetic is the classic one: expected = 1 / (1 + 10^((Rb − Ra) / 400)), then R' = R + K × (score − expected) with a win worth 1, a draw 0.5 and a loss 0, rounded to an integer; the moderator sets K (default 32). From 1000 against 1000 the winner goes to 1016 and the loser to 984; a draw moves nobody; a 1000 player who beats a 1400 player ends at 1029 and the favourite at 1371.
A game that pairs players itself (a backend, or a client that only reports from one side) names the match with matchId, the same string from both sides, each report with its own idempotencyKey, its own outcome and the other player as opponentId. If you leave matchId out, the idempotencyKey is the match key, which only works when both sides send the same one. Within your key and the board that string names exactly one match, created by the first report and fixed to its two participants. Later reports with the same key are reconciled against it:
- the opponent, naming the first reporter (or no opponent) with the mirrored outcome (
lossagainstwin,drawagainstdraw) → the confirmation:200"status": "confirmed"; - the opponent with a contradicting outcome →
200"status": "disputed"withstanding: null; the log channel flags it for the moderator; - the same player again, under any
idempotencyKey→409 duplicatewith the originalresultId; - anyone else, a participant naming a different opponent, or an
opponentIdon a match first reported solo →409 match_conflict; the attempt is recorded struck so the moderator can see it, and the body carries itsresultId; - either participant again once the confirmation exists →
409 duplicate; a participant reporting into a match that wentunconfirmedor was struck →409 invalid_status.
Omit opponentId and the match is a solo game against the house: a phantom fixed at 1000 that never gets a row. It is a convenience for games with no second human, and it applies at once whatever the board's confirmation setting.
Mutual confirmation is a board setting. With it off, the first report applies the match: 201 "status": "applied", with standing and this player's ratingChange; the opponent gets a standings row without having submitted anything, and the confirmation that follows answers 200 with ratingChange: null (the rating already moved). A contradicting second report is still 200 disputed, but the match stays applied and the ratings stay where they moved; the moderator strikes if they disagree. With it on, the first report answers 202 "status": "pending" with standing and ratingChange both null; the mirrored second report answers 200 "status": "confirmed" and carries the confirming player's ratingChange, because that is the request in which the ratings moved; a contradicting one answers 200 "status": "disputed" and the match never scores. A pending match that nobody confirms within the board's confirmation timeout (10 minutes by default; the housekeeping sweep runs every minute, so it is accurate to a minute) is marked unconfirmed, logged, and never scores, and a late report into it is 409 invalid_status. Design your client to show "waiting for your opponent" on a 202 and to treat 200 confirmed like 201.
If your players arrive through a challenge from Discord (the bot's own matchmaking: one player names another, who accepts from a post in the channel), the launch link also carries match=<match key>&opponent=<discordUserId>&role=a|b (a is the challenger, b the challenged) and the session is bound to that match. Each player plays from their own link and reports their own outcome — win on one side, loss on the other, or draw on both — and leaves matchId and opponentId out: the session decides which match the report belongs to, and anything in the body is ignored. The reconciliation above still applies to the two reports (the first is the primary, the second confirms or disputes), and match_conflict cannot arise, because the participants were fixed before play and nobody else holds a session for that match. A report into a challenge that was declined, expired or abandoned is 409 invalid_status. On an Elo board the challenge match is what the ratings are computed from, under the same confirmation rules and status codes; on a points board each report scores points as it lands and the match is a record. A challenger may also post an open challenge that the first player to accept takes: their link then has no opponent parameter (the helper's lb.match.opponentId is null) until someone accepts, so a game should show "waiting for an opponent" rather than a name, and the session still names the match, so reports reconcile as above once the opponent exists.
A challenge has two clocks, three under mutual confirmation. While it is open (unanswered) it expires, with its sessions, 30 minutes after it was posted. Accepting moves it to accepted and starts a gameplay clock: 2 hours from acceptance, or the game's gameTimeoutMinutes preference when the catalogue entry declares one. A match still unfinished when that runs out — nobody reported, or one side did and the other never will — is marked abandoned by the bot's housekeeping: the channel post is edited, both sessions are voided (a late report is 401 session_expired), and the match never scores. The 30-minute expiry no longer applies once a challenge is accepted, so a match that is being played cannot expire out from under its players. The third clock is the confirmation timeout above, which starts with the first report on a board that requires both.
The client helper
Every hosted game talks to the bot through one script, and an external browser game may load it too:
<script src="https://village-vote.fun/js/leaderboard-client.js"></script>
const lb = LeaderboardClient.fromLaunchUrl(); // reads the launch parameters and strips them from the
// address bar (pass { keepUrl: true } to leave them); the
// launch is remembered for the tab, so a second call or a
// reload mid-game gets the same session back
lb.canSubmit; // false when the page was opened without a launch link
lb.canRead; // true when the link named a key and a board: preferences work without a session
lb.match; // { key, opponentId, role } on a challenge launch, else null
lb.boards; // the slugs from the link; lb.gameId, lb.serverId, lb.publicKey, lb.apiBase likewise
const { preferences, clock } = await lb.preferences(); // the preferences response for the first board,
// fetched once and cached ({ fresh: true } re-fetches); null without a launch link
lb.serverNow(); // Date.now() corrected to the server clock, once preferences() ran
const results = await lb.submit({ points: 1240, initials: 'DAH', outcome: 'win', meta: { level: 7 } });
// → [{ board: 'friday-arcade', ok: true, status: 201, body: { resultId, status, standing, ratingChange, … } }, …]
// one per board in the link, in order; ok is true for 2xx (201, 202 pending, 200 confirmed or disputed)
// and for a 409 duplicate;
// a network failure is { ok: false, status: 0, body: { error: 'network', message } }
await lb.submit({ points: 1, windowId: clock.windowId }); // windowed games: key becomes '<board>:<windowId>'
await lb.submit({ points: { highest: 1, lowest: seconds } }); // a score for highest-wins boards, a time for
// lowest-wins boards: each board in the link gets the one that ranks
// on it (its order comes from preferences({ board }), cached per board)
await lb.myStanding(); // the standing from standings/me on the first board, or null
await lb.standings('friday-arcade', 10); // the standings response, or null
submit fills in sessionToken and gameId and makes up an idempotencyKey per call, sending that same key to every board in the link (keys are unique per board, so this is one finished game on each), except that a call carrying windowId and no key of its own uses <board>:<windowId> for each board. It posts to each board's own URL in turn rather than the fan-out route; each entry it returns is that board's answer plus ok, so reading them is the same job as reading a fan-out response. If your game may call it again for the same finished game (a retry button, a reload), make the key yourself and pass it as idempotencyKey; otherwise the retry is a second result and counts again. submit rejects when canSubmit is false, with "Launch this game from Discord to get on the board." — show that and let them play anyway. outcome is passed through as given; matchId and opponentId in the call are only sent when there is no lb.match, since on a challenge launch the session already names them. The helper does not sign requests: it is for browser games, which cannot keep a secret, and a key that requires signing cannot be used from it. A backend signs with the snippets under Request signing.
Worked examples
Replace <serverId>, <boardSlug> and lbk_… with the values from the setup page's API tab, or read them from the launch link.
Hosted or browser game, with the helper:
const lb = LeaderboardClient.fromLaunchUrl();
let gameKey = null; // idempotency key for the game in progress
function onGameStart() { gameKey = crypto.randomUUID(); }
async function onGameOver(points, initials) {
if (!lb.canSubmit) return showBanner('Launch this from Discord to get on the board.');
const [first] = await lb.submit({ points, initials, idempotencyKey: gameKey });
if (first.ok && first.body.standing) {
showBanner(`You are #${first.body.standing.rank} as ${first.body.standing.displayName}`);
} else if (first.ok) {
showBanner('Already counted');
} else {
showBanner(first.body.message); // "This play session has expired; launch the game again from Discord."
}
}
A versus game on an Elo board, with the helper — a challenge launch needs only the outcome; a game with its own matchmaking names the match and the opponent itself.
const lb = LeaderboardClient.fromLaunchUrl();
async function onMatchOver(outcome) { // 'win', 'loss' or 'draw', from this player's side
const [first] = lb.match
? await lb.submit({ outcome }) // a challenge launch: the session knows the match
: await lb.submit({ outcome, matchId: room.id, opponentId: room.opponentDiscordId });
const { status, body } = first;
if (status === 202) showBanner('Waiting for your opponent to confirm…');
else if (body.ratingChange) showBanner(`${body.ratingChange.before} → ${body.ratingChange.after}, now #${body.standing.rank}`);
else if (body.status === 'disputed') showBanner('Your opponent reported a different result; a moderator will look.');
else showBanner(first.ok ? 'Recorded' : body.message);
}
A backend, in Node (18+, built-in fetch) — one that got the token from its own client, or knows the player's id itself. Any HTTP client in any language does the same: one POST, one header, one JSON body. A backend that has the key's secret adds the two signing headers from Request signing, which is where the body has to be serialised once and sent as is.
import { randomUUID } from 'node:crypto';
const base = 'https://village-vote.fun/api/leaderboard/<serverId>/<boardSlug>';
const headers = { Authorization: 'Bearer lbk_…', 'Content-Type': 'application/json' };
const player = sessionToken ? { sessionToken } : { discordUserId: '<discordUserId>' };
const res = await fetch(`${base}/results`, {
method: 'POST',
headers,
body: JSON.stringify({ gameId: 'snake', ...player, idempotencyKey: randomUUID(), points: 1240 }),
});
const data = await res.json();
if (res.status === 201) console.log(`rank ${data.standing.rank} as ${data.standing.displayName}`);
else if (res.status === 409 && data.error === 'duplicate') console.log('already counted', data.resultId);
else console.log(res.status, data.error, data.message, res.headers.get('Retry-After'));
Security, honestly
The public key is an identifier, not a secret. It names your game so results are attributed and so a moderator can revoke it; it does not prove the request came from your game (a signature is what does that, below). If your game runs in the browser, anyone who opens the developer tools can read the key, the server id and the slug.
The play session is the defence that matters. A player gets a token only by launching from Discord, as themselves; a result with that token is credited to them whatever the body says; the token scores only on the boards it was issued for and dies 30 minutes after the game page last renewed it (twelve hours from launch at most); and the bot stores only its hash. On a board that requires a session, a key holder can no longer invent a player or score for someone else — the most they can do is submit invented points for their own session, which the ceiling, a best aggregate, a puzzle window and the moderator's review catch between them. On a challenge the session also fixes the match and the opponent, so a player cannot report into a match they are not in. Use sessions wherever players can launch from Discord, and ask the moderator to require them.
What else stands between a cheat and the board:
- Idempotency. A finished game counts once; replaying a captured request answers
409 duplicate. - The aggregate and the window. On a best board a hundred submissions are worth one good one; on a windowed game a window can be solved once, with a server-issued seed nobody can compute early.
- The ceiling. Scores above a plausible maximum are recorded struck and never counted.
- Mutual confirmation. On an Elo board that requires it, a win moves nobody's rating until the opponent's client reports the mirrored outcome; a lone report times out unconfirmed, and a contradicted one is disputed and never scores. A match key is fixed to its first two participants, so a reused id cannot score a second pairing.
- Rate limits. Per key, per key and IP, and per IP, so one client cannot flood the board or starve other players.
- Moderator review. Every result is in a ledger with its source key, whether it came through a session, its client IP suffix and any
metayou sent; the log channel reports each result and each disputed match as it lands; a moderator can strike any result with a reason, rebuild the standings, or reset the board. Sending usefulmeta(level reached, game duration) makes a fake easier to spot.
Request signing is the defence for a game with a backend. The secret never leaves your server, so a signed request proves it came from your code, not from a player who copied a request out of the developer tools; the timestamp bounds a replay, and the moderator can require it so that an unsigned request scores nothing. Its limits are just as plain: a signature says the request came from your game, not that the player played honestly, so a backend that trusts its own client's reported score is still signing whatever the client said; and in a browser it proves nothing, because the secret is in the source. The bot stores secrets encrypted at rest, which protects a copied database file and nothing more.
Without sessions the key alone identifies the game, anyone with it can submit any points for any discordUserId, and the board is a trust-but-verify arrangement between a moderator and their community. A backend that keeps the key off the client helps against casual tampering but still does not prove who played; signing from that backend proves the request is the backend's, and a session proves who the player is. Use both where you can.
What is coming later
Nothing: the API is complete for this release. The bot also runs some games entirely inside Discord (Word Bopper, in solo, party and versus modes, each in a public thread); those never touch this API, since the bot records their results directly, and the catalogue of them lives in the moderator guide. A game written against this page is unaffected by them.