CLUBBATTLES

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/healthNo key needed

    Liveness 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/versionNo key needed

    What 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.jsonNo key needed

    This 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/gamesNo key needed

    Every 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}No key needed

    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/ladderStandings

    Club 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}/ladderStandings

    One 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-historyStandings

    A 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/clubsClubs

    Every 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/clubsSession only

    Found 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}Clubs

    One club’s identity

  • patch/v1/clubs/{clubNumber}Session only

    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}/rostersClubs

    Every 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}/archiveSession only

    Archive 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}/mergeSession only

    Merge another club into this one

  • get/v1/clubs/{clubNumber}/staffSession only

    Who 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}/staffSession only

    Add a staffer

    Only the owner manages staff, so a staffer cannot lock the owner out.

  • delete/v1/clubs/{clubNumber}/staff/{userNumber}Session only

    Remove a staffer

    The owner’s own staff row cannot be removed, because ownership transfer does not exist.

  • get/v1/games/{gameSlug}/clubsClubs

    Clubs fielding a roster in this title

  • post/v1/games/{gameSlug}/clubsSession only

    Found a club and enter it into this title

  • get/v1/games/{gameSlug}/clubs/{clubNumber}Clubs

    A club as it stands in one title

  • patch/v1/games/{gameSlug}/clubs/{clubNumber}Session only

    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}/archiveSession only

    Retire 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}/membersClubs

    A 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/meSession only

    Add 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}Session only

    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}/rosterSession only

    Enter 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}Players

    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/playersSession only

    Search 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}/matchesMatches

    Recent matches in a title

  • get/v1/matches/{matchSlug}Matches

    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}Session only

    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}/matchesMatches

    A club’s matches in a title

  • post/v1/games/{gameSlug}/clubs/{clubNumber}/matchesSession only

    Report 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/pendingMatches

    Results awaiting this club’s answer

  • post/v1/matches/{matchSlug}/confirmSession only

    Confirm 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}/declineSession only

    Dispute 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}/queueQueue

    Whether the queue is open, and how many clubs are searching

  • get/v1/games/{gameSlug}/clubs/{clubNumber}/queueSession only

    Your club’s own queue state

  • post/v1/games/{gameSlug}/clubs/{clubNumber}/queueSession only

    Join the queue

  • delete/v1/games/{gameSlug}/clubs/{clubNumber}/queueSession only

    Leave the queue

  • get/v1/games/{gameSlug}/clubs/{clubNumber}/fixturesQueue

    A club’s fixtures

  • get/v1/fixtures/{fixtureId}Queue

    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}/acceptSession only

    Accept a proposed fixture

  • post/v1/fixtures/{fixtureId}/declineSession only

    Decline 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}/messagesSession only

    Match room chat

    Polled, never socketed — the bearer token stays server-side and the process count is capped.

  • post/v1/fixtures/{fixtureId}/messagesSession only

    Post to the match room

  • get/v1/fixtures/{fixtureId}/scoresheetSession only

    The 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}/visitsSession only

    Enter 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/previewSession only

    What 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/draftSession only

    Save 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/lastSession only

    Correct 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/fileSession only

    Send 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}/signalsSession only

    Match 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}/signalsSession only

    Send a camera signal

  • post/v1/fixtures/{fixtureId}/camera-linksSession only

    Make 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}No key needed

    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}/signalsNo key needed

    Camera signalling, for a phone

  • post/v1/camera-links/{token}/signalsNo key needed

    Send 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/syncNo key needed

    Create 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/meSession only

    Your 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/meSession only

    Update your bio, timezone or notification preferences

    A body carrying `displayName` is ignored rather than refused, for the reason above.

  • get/v1/users/me/clubsSession only

    Clubs you play for or administer

  • get/v1/users/me/foundingSession only

    Whether 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/accountsSession only

    Claim 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}Session only

    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-keysSession only

    Your 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-keysSession only

    Mint 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}Session only

    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/roadmapNo key needed

    The 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}No key needed

    One roadmap item

  • delete/v1/roadmap/{itemNumber}Session only

    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/ideasNo key needed

    What 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/ideasSession only

    Suggest 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/searchNo key needed

    Find 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}/voteSession only

    Vote 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}/voteSession only

    Take 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/calendarNo key needed

    Events 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}No key needed

    One event