Developers
The ClubBattles API
Read standings, clubs, players, matches and queues over HTTP, or through the ClubBattles MCP server if you are wiring this into an AI assistant. Everything a key can reach is public data — the same rows the ladder and club pages show.
Authenticating
Send your key as a bearer token:
curl https://api.clubbattles.com/v1/ladder \ -H "Authorization: Bearer cb_…"
Most read endpoints also answer without a key. A key gets you a documented rate limit of 600 requests a minute, an identity we can talk to about usage, and something you can revoke without changing your password. A key presented to an endpoint outside its scopes is refused rather than treated as anonymous, so a narrow key fails loudly instead of quietly working.
Keys are read-only
There is no write scope, and that is deliberate rather than unfinished. The rules this product actually makes — that a player sits on one club per title, that joining a club needs the player’s consent, that a roster locks once somebody has played a rated match — are all enforced by asking who is acting. A key in a CI environment is not a person at a keyboard, so it does not get to act as one. Queueing, reporting and roster changes happen signed in.
Your API keys
Sign in to create a key. The reference below is readable without one.
MCP server
The same data is available to AI assistants over the Model Context Protocol, with one tool per question — standings, a club’s roster, a match, queue state. Point a client at https://mcp.clubbattles.com/mcp with your key as a bearer token, or run it locally over stdio.
It is read-only for the same reason keys are, and it holds no credential of its own: every request carries yours.
Endpoints
Everything below is generated from the API’s own description, which you can read or feed to a code generator at /v1/openapi.json.
Meta
Liveness, build info and this document.
get/healthLiveness probe
Checks the database as well as the process, and answers 503 with `status: degraded` when it cannot be reached. It did NOT probe the database before 21-09-2026, which made a Postgres outage invisible — a green `/health` while every viewer appeared signed out.
get/versionWhat commit this process is running
Read ONCE at process start, on purpose. The webapp reads the same file per request, so the two disagree for the whole of every deploy — a disagreement that persists after the deploy finishes is a build that never restarted the API.
get/v1/openapi.jsonThis document
The OpenAPI 3.1 description of everything above. There is no checked-in `openapi.json` — this endpoint is the file.
Games
The titles ClubBattles runs. Adding one is a row plus an adapter — never a new process.
get/v1/gamesEvery title
The catalogue every other call needs, so it is reachable by ANY valid key regardless of scope, and anonymously. Listed titles only: sandbox titles and titles staff have hidden are left out, which is the one filter that takes a title off every list.
get/v1/games/{slug}One title
Deliberately resolves sandbox and hidden titles, unlike the list above — otherwise their own subdomains would 404.
Ladder
Standings and rating history. Everything here is derived from `rating_history` and match results.
get/v1/ladderClub standings across every title
Derived, never stored: a club’s score is the sum over the titles it has a rated match in this season of (rating − 1000), with archived clubs and archived rosters excluded. Ties break on rated matches this season, then name.
get/v1/games/{gameSlug}/ladderOne title’s ladder
Clubs with a rated match this season. Ratings reset to 1000 at every monthly UTC season boundary.
get/v1/games/{gameSlug}/clubs/{clubNumber}/rating-historyA club’s rating movements
Every entry `rating_history` holds for this club in this title. The ladder is rebuildable from this plus match results, which is why nothing but the rating module may write it.
Clubs
Club identity, per-title rosters and staff.
get/v1/clubsEvery club that exists
The apex directory. NOT a filter on the per-title list: a club that has retired from every title it entered still has an identity and appears here with no titles. Archived clubs are excluded from both.
post/v1/clubsFound a club
Makes you staff and puts you on no roster. A founder who wants to play adds themselves afterwards, which is also where the one-club-per-game seat is taken.
get/v1/clubs/{clubNumber}One club’s identity
patch/v1/clubs/{clubNumber}Rename or re-tag a club
The slug deliberately does NOT move on rename — a rename that 404s every shared link is the worse bug.
get/v1/clubs/{clubNumber}/rostersEvery title this club has entered
Includes titles the club has RETIRED from, which the club’s own `games` array excludes. That is the difference between the two and the reason this exists.
patch/v1/clubs/{clubNumber}/archiveArchive or restore a club everywhere
Archive, never delete — rating history and match lineups reference a club forever. The route deliberately does not refuse an already-archived club, or archiving would be one-way and a mis-click permanent.
post/v1/clubs/{clubNumber}/mergeMerge another club into this one
get/v1/clubs/{clubNumber}/staffWho administers this club
Running a club and playing for it are separate. Staff is club-wide and grants administration; membership is per game and grants play. Neither implies the other.
post/v1/clubs/{clubNumber}/staffAdd a staffer
Only the owner manages staff, so a staffer cannot lock the owner out.
delete/v1/clubs/{clubNumber}/staff/{userNumber}Remove a staffer
The owner’s own staff row cannot be removed, because ownership transfer does not exist.
get/v1/games/{gameSlug}/clubsClubs fielding a roster in this title
post/v1/games/{gameSlug}/clubsFound a club and enter it into this title
get/v1/games/{gameSlug}/clubs/{clubNumber}A club as it stands in one title
patch/v1/games/{gameSlug}/clubs/{clubNumber}Rename or re-tag from inside a title
Two routes rename a club — this and the apex one. They agree today; a change to one is a change to half the product.
patch/v1/games/{gameSlug}/clubs/{clubNumber}/archiveRetire or restore this title’s roster
Scoped to one title, unlike the apex archive route which archives the club everywhere.
get/v1/games/{gameSlug}/clubs/{clubNumber}/membersA club’s roster in this title
Active members only. A player is on ONE club per game, enforced by a partial unique index rather than by a guard.
post/v1/games/{gameSlug}/clubs/{clubNumber}/members/meAdd yourself to this roster
Where a founder takes their one-club-per-game seat. Refused if the roster is full or you already hold a seat in this title.
delete/v1/games/{gameSlug}/clubs/{clubNumber}/members/{userNumber}Leave, or remove a member
The roster lock binds BOTH sides: once a player has played a rated match this season, neither they nor staff may end that membership until the season changes or the club goes fourteen days without a rated match.
post/v1/games/{gameSlug}/clubs/{clubNumber}/rosterEnter this club into the title
Creates an empty roster, or restores one the club previously retired — with the members it was left with. 201 when the roster is new, 200 when it is restored.
Players
Public player profiles and lookup.
get/v1/games/{gameSlug}/players/{userNumber}A player’s profile in one title
Game-scoped because everything on it is — a rating is per game and so is a roster, so the same person has a different profile in a different title. Contact details are not in it.
get/v1/playersSearch players by name, tag or number
The autocomplete behind every field that takes a player. Signed-in rather than permissioned, because club staff need it. Returns the three fields a picker draws, all already printed on public pages — so it adds a way to LOOK somebody up, not a new fact about them.
Matches
Results, confirmation and disputes.
get/v1/games/{gameSlug}/matchesRecent matches in a title
get/v1/matches/{matchSlug}One match in full
Scores are ALWAYS club A then club B, never the reporter’s point of view — a bare "3-1" is what gets confirmed backwards, and a reversed confirmation is unrecoverable once rated.
delete/v1/matches/{matchSlug}Withdraw an unanswered result
DELETES the match, so the room reads "no result at all" and offers the report form again. Safe only because rating applies on entry to `completed`; never extend this to a rated match.
get/v1/games/{gameSlug}/clubs/{clubNumber}/matchesA club’s matches in a title
post/v1/games/{gameSlug}/clubs/{clubNumber}/matchesReport a result
The gate is enforced from the fixture found by the two club ids, never from a fixture id in the request, so it cannot be skipped by omitting a field.
get/v1/games/{gameSlug}/clubs/{clubNumber}/matches/pendingResults awaiting this club’s answer
post/v1/matches/{matchSlug}/confirmConfirm a reported result
Confirming is what enters `completed`, and entry to `completed` is what applies rating. The guard reads `match_results.reported_by` so the reporter cannot confirm their own result.
post/v1/matches/{matchSlug}/declineDispute a reported result
Sends it to staff. A resolved dispute rates at resolution time, not retroactively at the original timestamp.
Queue
The live queue: searching, fixtures and the match room.
get/v1/games/{gameSlug}/queueWhether the queue is open, and how many clubs are searching
get/v1/games/{gameSlug}/clubs/{clubNumber}/queueYour club’s own queue state
post/v1/games/{gameSlug}/clubs/{clubNumber}/queueJoin the queue
delete/v1/games/{gameSlug}/clubs/{clubNumber}/queueLeave the queue
get/v1/games/{gameSlug}/clubs/{clubNumber}/fixturesA club’s fixtures
get/v1/fixtures/{fixtureId}One fixture
Lobby credentials are NOT in this response for a caller who is not in the match — they are generated at pairing for the two clubs playing.
post/v1/fixtures/{fixtureId}/acceptAccept a proposed fixture
post/v1/fixtures/{fixtureId}/declineDecline a proposed fixture
Decline penalties are derived from the fixture row, never stored as a counter — and they are on the club, not on whoever happened to queue it in.
get/v1/fixtures/{fixtureId}/messagesMatch room chat
Polled, never socketed — the bearer token stays server-side and the process count is capped.
post/v1/fixtures/{fixtureId}/messagesPost to the match room
get/v1/fixtures/{fixtureId}/scoresheetThe match room’s scoresheet
For a title with a scorer. Everything in it is replayed from the visits entered, so it cannot disagree with them. Room members only, like the chat.
post/v1/fixtures/{fixtureId}/visitsEnter a visit
For the side whose throw it is. Send the darts — `throws`, one to three `{ ring, number? }` — or a total as `score`, with `darts` on a checkout. Darts are taken one at a time: past zero, leaving 1 on double-out, or reaching zero on anything but a double when the title is double-out, is a bust, and the visit ends on the dart that finished or went bust.
post/v1/fixtures/{fixtureId}/visits/previewWhat a visit would do
The same checks entering it runs, and nothing written — so the room can ask a player to confirm what a visit scores, whether it goes bust or wins the leg, before it counts. With `edit: true`, what correcting the side’s last visit to this would do.
put/v1/fixtures/{fixtureId}/visits/draftSave the darts picked so far
The visit being thrown, none to three darts, sent as the thrower taps them so the other side sees them land (the scoresheet’s `draft`). Only on your own throw — 409 otherwise, which only means the throw moved on. Not judged, and never a visit: entering one is still `POST …/visits`.
put/v1/fixtures/{fixtureId}/visits/lastCorrect your last visit
Replaces the most recent visit the caller’s side threw. If the other side has thrown since, the correction may not change whether the visit won the leg. Never once a result is filed.
post/v1/fixtures/{fixtureId}/scoresheet/fileSend the result from the scoresheet
Once a side has won and the report gate is open. Files the score, lineup and stats worked out from the visits; the other club then confirms or disputes it as usual.
get/v1/fixtures/{fixtureId}/signalsMatch camera signalling
The few messages two browsers swap to connect a camera — a hello, one offer, one answer, a bye. Video itself goes browser to browser and never reaches the API. Room members only, on a title with a camera; signals older than a minute are gone.
post/v1/fixtures/{fixtureId}/signalsSend a camera signal
post/v1/fixtures/{fixtureId}/camera-linksMake a phone camera link
A short-lived link a phone can open, with no sign-in, to stream the caller’s side’s board. The token is shown once and stored hashed.
get/v1/camera-links/{token}What a phone camera link opens
The token is the credential. 404 once it has expired or its match is over.
get/v1/camera-links/{token}/signalsCamera signalling, for a phone
post/v1/camera-links/{token}/signalsSend a camera signal, as a phone
Always sent as a camera — the role is set by the link, not the request.
Account
The caller’s own profile. Session only.
post/v1/users/syncCreate or refresh the caller’s account at sign-in
Guards itself rather than using the usual user guard, because it must answer BEFORE a users row exists. Not a general-purpose route — the webapp calls it once per sign-in.
get/v1/users/meYour own profile
Display name and avatar are read-only: `users/sync` overwrites them at every sign-in, so an editable name would be silently reverted.
patch/v1/users/meUpdate your bio, timezone or notification preferences
A body carrying `displayName` is ignored rather than refused, for the reason above.
get/v1/users/me/clubsClubs you play for or administer
get/v1/users/me/foundingWhether you may found a club
A ceiling on ACTIVE clubs one person may own. Archived clubs do not count towards it.
post/v1/users/me/accountsClaim a platform account
CLAIMED, never verified. Nothing checks a handle against Steam or Epic, and no reader may treat one as identity.
delete/v1/users/me/accounts/{platform}Drop a claimed platform account
API keys
Minting and revoking keys. Session only — a key can never mint a key.
get/v1/users/me/api-keysYour API keys
Live keys first, then revoked ones. Revoked keys are shown rather than hidden, because "did I already revoke that" is a question this should answer.
post/v1/users/me/api-keysMint a key
THE ONLY TIME THE TOKEN IS EVER RETURNED. Nothing stores it and no later read can recover it — lose it and you mint a new key. Requires a signed-in session, which an API key cannot provide, so a key can never mint another key.
delete/v1/users/me/api-keys/{keyId}Revoke a key
Revoke, never delete: the row is the record that the credential existed, and its prefix stays claimed so a prefix in an old log always resolves to exactly one key. Takes effect on the next request — there is no cache in front of verification.
Roadmap
What is being built, what shipped and when, and the ideas people have suggested.
get/v1/roadmapThe board and the changelog
Everything planned, being built or shipped, with the date each thing shipped — the board and the changelog in one read. Ideas still under review and declined ones are NOT here, because they grow with the number of people suggesting rather than with the work: page through them at `/v1/roadmap/ideas`. Their counts are here so a page can show them without reading them. Hidden items are excluded. Personalised when a session is present — `viewerVoted` and `viewerIsAuthor` — and identical to a signed-out read otherwise, which is why any API key may call it.
get/v1/roadmap/{itemNumber}One roadmap item
delete/v1/roadmap/{itemNumber}Withdraw your own idea
Only while nobody has answered it. It is deleted with its votes, so it stops counting towards your waiting ideas. Once answered it is part of the record, and this answers 409.
get/v1/roadmap/ideasWhat people have suggested, a page at a time
Community ideas only. `top` and `new` are the ones still taking votes (under review, planned or being built) by votes or by age; `answered` is the ones that shipped or were declined, most recently answered first, with the reason in `reply`. Numbered pages: `top` can shift under a reader as votes move, which is what `new` is for. A page past the end answers an empty list with the real `total`, not a 404.
post/v1/roadmap/ideasSuggest an idea
Posting an idea counts as your vote for it. At most 5 of your ideas may wait for an answer at once; past that this answers 409 with `error.code` `too_many_open_ideas`, and withdrawing one or getting an answer frees a slot. There is no time-based limit.
get/v1/roadmap/searchFind items like a title
Items still taking votes — under review, planned or being built — whose titles share words with `q`, best match first. Words are compared stemmed, with common words ignored; a longer `q` must share at least a third of its words. The suggestion form asks this while somebody types, so they vote for an existing idea instead of posting a copy.
put/v1/roadmap/{itemNumber}/voteVote for an item
Idempotent: voting twice is one vote. Refused with 409 (`voting_closed`) once an item has shipped or been declined.
delete/v1/roadmap/{itemNumber}/voteTake your vote back
Idempotent, and closed at the same point voting is.
Calendar
Events on the season calendar. Seasons themselves are the UTC calendar month and are not listed.
get/v1/calendarEvents in one month
Published events touching one UTC calendar month, in day order, for every title or — with `game` — for one — a multi-day event that starts before the month or runs past it is included. Seasons are not events and never appear here: a season is the UTC calendar month, so its start and end are the month itself.
get/v1/calendar/{eventNumber}One event