Read-only HTTP access to every statistic the CodeBreakers tournament bot has recorded: players, squads, matches, maps, classes and tournaments, as totals or sliced any way you like. Requires an API key.
The API is versioned and lives under /v1:
https://api.stats.playrservers.com/v1
Every request needs a key. Ask a CodeBreakers organiser for one; keys are issued per consumer, so please do not share yours - usage is attributed to it, and a key seen coming from several places gets flagged as leaked.
curl -H "Authorization: Bearer cbk_YOUR_KEY" \
https://api.stats.playrservers.com/v1/status
GET /v1 returns a machine-readable index of every endpoint, so you can discover the
surface without this page.
Everything here is read-only. There is no endpoint, and no scope, that can change a stat. The API cannot submit results, edit matches or manage the site.
Send your key in a header. Either of these works:
Authorization: Bearer cbk_YOUR_KEY
X-API-Key: cbk_YOUR_KEY
Never put the key in the URL. A request with ?api_key= is rejected with
400 key_in_query rather than accepted: query strings are written to proxy logs, browser
history and referrer headers, so a key sent that way should be treated as burned.
For the same reason, do not call this API from browser JavaScript on a public page - the key would be visible to anyone who opens devtools. Call it from your server and pass the results on.
Keys are stored as a SHA-256 hash. Nobody, including the CodeBreakers organisers, can read your key back out after it is issued. If you lose it, ask for it to be revoked and reissued.
A key may optionally be locked to one or more IP addresses or CIDR ranges. If yours is,
a request from anywhere else fails with 403 ip_not_allowed - the whole request, not a
reduced version of it. Ask for a lock if your integration runs from a fixed address; it is the single
most effective protection against a leaked key being useful to anyone else.
| Scope | Grants |
|---|---|
stats | Every game statistic: players, squads, matches, placements, maps, classes, tournaments. All keys have this. |
identity | Additionally reveals the Discord identities attached to the data: discord_username on players and rosters, and submitted_by / applied_by / edited_by on matches and corrections. |
Without the identity scope those fields are returned as the literal string
"restricted". That is deliberately not null: null is a real
answer meaning "no Discord identity has ever been recorded for this player", and you need to be able
to tell the two apart.
// key WITHOUT the identity scope
{ "embark_id": "SHADOW#7360", "discord_username": "restricted" }
// key WITH the identity scope
{ "embark_id": "SHADOW#7360", "discord_username": "shadow" }
GET /v1/status tells you which scopes your own key carries.
Keys default to 120 requests per minute; yours may have been issued with a different budget. Every successful response carries the current state of your budget:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed in the current minute. |
X-RateLimit-Remaining | How many of those are left. |
X-RateLimit-Reset | Seconds until the window rolls over. |
Retry-After | Sent only on a 429. Wait this many seconds. |
The window is a fixed minute, not a rolling one. If you are pulling a lot of data, prefer one
request with a large limit over many small ones, and use
/v1/stats rather than fetching each player individually.
Responses are sent with Cache-Control: private, no-store because the body depends on your
key. Cache them yourself if you need to; the underlying data only changes when a tournament match is
recorded, so caching for a minute or two costs you nothing.
Errors are JSON, with a stable machine-readable error code and a human
message. Branch on error, never on message - the
wording may be improved at any time.
{
"ok": false,
"error": "key_required",
"message": "this API requires a key. Send it as `Authorization: Bearer cbk_...` ..."
}
| Status | Code | Meaning |
|---|---|---|
| 400 | key_in_query | The key was sent in the URL. Use a header. |
| 400 | unknown_group_by | Not a valid grouping. The message lists the valid ones. |
| 400 | unknown_sort | Not a valid sort key. The message lists the valid ones. |
| 400 | missing_query | A search endpoint was called without ?q=. |
| 400 | bad_team_id / bad_match_id | That resource is addressed by a numeric id. |
| 401 | key_required | No key was sent. |
| 401 | invalid_key | The key is not recognised. |
| 401 | key_revoked | The key was valid but has been withdrawn. |
| 403 | ip_not_allowed | The key is address-locked and you are not calling from a permitted address. |
| 404 | not_found | No such player, team, match or tournament. |
| 404 | unknown_endpoint | No such route. GET /v1 lists them. |
| 405 | method_not_allowed | The API is read-only; use GET. |
| 429 | rate_limited | Slow down. See Retry-After. |
| 500 | internal_error | Something broke on our side; it has been logged. |
Every time value (played_at, first_seen_at, edited_at, ...) is a
Unix timestamp in milliseconds, UTC. In JavaScript: new Date(played_at). In
Python: datetime.fromtimestamp(played_at / 1000, tz=timezone.utc).
List endpoints accept limit and offset, and report the unpaginated
total alongside the rows. limit is capped at 500.
GET /v1/players?limit=100&offset=200
{ "ok": true, "total": 138, "limit": 100, "offset": 200, "players": [ ... ] }
These apply to /v1/stats, /v1/stats.csv, /v1/players and
/v1/teams, and can be combined freely.
| Parameter | Effect |
|---|---|
tournament | One tournament code, e.g. CB-42WA. Case-insensitive. Omit for all-time totals. |
player_id | Restrict to one player (numeric id). |
team_id | Restrict to one squad. |
match_id | Restrict to one match. |
class | L, M or H. |
map | Exact map name, as returned by /v1/maps. |
from, to | Bound played_at, in epoch milliseconds. |
min_matches | Drop groups with fewer than this many matches. Use it on leaderboards so a single lucky game does not top a rate-based ranking. |
exclude_disconnected | 1 to drop the rows of players who left a match early. Off by default - see caveats. |
sort takes any of the keys below, and dir takes asc or
desc (default desc). An unrecognised key is an error, not a silent fallback.
matches eliminations assists deaths revives combat support objective
wins avg_placement kd label
elims_per_match assists_per_match deaths_per_match revives_per_match
combat_per_match support_per_match objective_per_match
Store Embark IDs, not numeric player ids. Numeric ids are database row numbers.
They are stable day to day, but they are reassigned if the stats database is ever rebuilt - so a
cached player_id: 42 can end up pointing at a different human. SHADOW#7360
survives that.
A player can be addressed three ways, and all three return the same record:
GET /v1/players/SHADOW%237360 # Embark ID, "#" percent-encoded as %23
GET /v1/players/SHADOW-7360 # "-" in place of "#", friendlier in a path
GET /v1/players/71 # numeric id
The #tag is part of the identity: SHADOW#7360 and SHADOW#1111
are two different people and are never merged. If you pass a bare name with no tag it resolves only
when exactly one player matches; otherwise you get 404 not_found.
Teams and matches are addressed by numeric id, but a match also has a stable bracket identifier - its tournament code plus its pod id:
GET /v1/matches/318 # numeric id
GET /v1/tournaments/CB-42WA/matches/groups-0 # bracket identifier
Team names are only unique within a tournament, and squads change names between events.
Every team object carries its tournament_code so you can re-resolve one later.
GET /v1/stats is the endpoint behind "any stat, sliced any way". It groups the entire
corpus by one dimension, applies your filters, and returns summed totals plus every rate derived
from them.
GET /v1/stats?group_by=player&tournament=CB-42WA&sort=kd&min_matches=5&limit=25
group_by | One row per | Names its group as |
|---|---|---|
player | player (the default) | player_id, embark_id |
team | squad | team_id, team |
class | class (Light / Medium / Heavy) | class |
map | map | map |
tournament | tournament | tournament, tournament_name |
match | match | match_id, match_label |
Every row also carries the generic pair key and label, which hold the same
values. /v1/stats.csv returns the identical rows as CSV, for a spreadsheet.
What "matches" counts depends on the grouping, and this is intentional.
For player, class, map and tournament, a row is one
player's appearance in one match. For team and match, many player rows belong
to a single participation, so distinct matches are counted instead - otherwise a squad that played one
three-player game would report "3 matches, 3 won" and every per-match average would be divided by the
size of the squad.
GET /v1 | Machine-readable index of every endpoint. |
GET /v1/status | Service version, corpus counts (tournaments, teams, players, matches), the timestamp of the most recent match, and what your key may do. |
GET /v1/stats | The engine above. group_by + all filters + sorting + pagination. |
GET /v1/stats.csv | The same rows as CSV. |
GET /v1/mvp | MVP ranking, all-time or for one ?tournament=. See how it is scored. |
GET /v1/tournaments | Every tournament, with its match and team counts and when it ran. |
GET /v1/tournaments/{code} | One tournament: totals, every team with its roster, standings, map splits and class splits. |
GET /v1/tournaments/{code}/standings | Squads ranked. sort defaults to wins. |
GET /v1/tournaments/{code}/mvp | MVP ranking for that tournament. |
GET /v1/tournaments/{code}/matches | Its matches, newest first. Paginated. |
GET /v1/tournaments/{code}/matches/{pod} | One match by its bracket pod id, in full. |
GET /v1/players | Player leaderboard. All filters, sorting and pagination apply. |
GET /v1/players/search?q= | Substring match on Embark ID (and on Discord username, if your key has identity). Returns ids, match counts and the teams they have played for. |
GET /v1/players/{ref} | Full profile: the player, every team they have been on, career totals, splits by tournament / class / map, and their recent match log. |
GET /v1/players/{ref}/matches | Their complete match log, paginated, optionally filtered to one ?tournament=. |
GET /v1/teams | Squad leaderboard. |
GET /v1/teams/search?q= | Matches the team name or any roster member's Embark ID - "which squad was SHADOW on?" is the more common question. Narrow with &tournament=. |
GET /v1/teams/{id} | Roster, squad totals, and a per-player breakdown of that squad. |
GET /v1/teams/{id}/matches | Every match the squad played, with its combined stat line and placement for each. |
GET /v1/matches | Match list across every tournament, newest first. Narrow with ?tournament=. |
GET /v1/matches/{id} | The full scoreboard: every squad, its placement and cash, and every player's complete stat line. |
GET /v1/matches/{id}/edits | The correction log - which fields a moderator changed after the fact, from what to what. |
GET /v1/maps | Every map seen, with match counts. These are the exact strings the map filter expects. |
GET /v1/classes | Classes seen, with appearance counts. |
| Field | Meaning |
|---|---|
embark_id | The player's in-game name including its #tag. The stable identity. |
class | L Light, M Medium, H Heavy. null when the scoreboard did not show one (see disconnected). |
eliminations | Kills credited on the end-of-match scoreboard. |
assists | Elimination assists. |
deaths | Times eliminated. |
revives | Teammates revived. |
combat | The game's combat score for that match. Thousands, not a rate. |
support | The game's support score. |
objective | The game's objective score. |
disconnected | 1 if the player left before the end. Their partial line still counts unless you pass exclude_disconnected=1. |
| Field | Meaning |
|---|---|
placement | Finishing position within the bracket match. 1 is the win. This is the number that counts. |
placement_observed | The raw position badge read off the screenshot. Differs from placement when the match shared a public lobby with squads that were not in the tournament. |
cash | The squad's cash-out total for the match. |
in_tournament | false for a squad or player who appeared on the scoreboard but is not in the bracket. Filter these out for tournament-only analysis. |
squad_label | The squad's on-screen name, kept even when it could not be matched to a registered team. |
| Field | Meaning |
|---|---|
tournament_code | e.g. CB-42WA. |
pod_id, label | The bracket slot and its human label, e.g. groups-0 / "Group A". |
map | The map actually played (falls back to the scheduled one if the screenshot did not show it). |
map_scheduled, map_observed | Those two inputs separately. |
on_stream | Whether the match was on the broadcast. |
played_at | When the result was recorded, epoch ms. |
confidence, notes | How sure the reader was about this scoreboard, and anything it flagged. |
submitted_by, applied_by | Who sent the screenshot and who applied the result. Requires the identity scope. |
| Field | Meaning |
|---|---|
matches | Match count for this grouping - see the note on what that means. |
kd | Eliminations / deaths. With zero deaths it falls back to raw eliminations rather than dividing by zero. |
kda | (Eliminations + assists) / deaths, same fallback. |
wins | Matches finished in placement 1. |
win_rate | Wins as a percentage of matches. |
avg_placement | Mean finishing position. Lower is better. |
*_per_match | The summed metric divided by the match count for that grouping. |
No ratio is ever stored. The database holds sums only; K/D, per-match averages and win rates are computed at query time. That is why a moderator correcting one mis-read elimination instantly fixes every derived number that depended on it, everywhere - and it is why you should not cache a rate much longer than you cache the sums it came from.
MVP scoring. Combat, support and objective run in the thousands while K/D sits near 1,
so averaging them raw would make the award mean "combat" and nothing else. Each metric is instead
expressed as a percentage of the best qualified player in the field, and those percentages are
weighted: combat 40, K/D 30, support 15, objective 15. Qualifying means having played at
least half as many matches as the deepest run in the bracket, rounded up. Placement is deliberately
excluded - a player knocked out in groups who averaged huge games can still take it. If nobody scored in
a metric at all, it is dropped and its weight shared among the rest, so the leader always reaches 100.
The response spells out weights, min_matches, metrics_used and each
player's per-metric parts.
Worth knowing before you build something on this data.
A match is the smallest unit that exists. One end-of-game scoreboard is the finest grain in the entire system. There is no per-round, per-life or timeline data anywhere, and none can be added without a completely different capture method.
/v1/matches/{id}/edits exposes - if a number matters, check whether it has been amended.placement, not placement_observed. Tournament matches
sometimes share a public lobby with squads who are not in the bracket, so the on-screen position badge
is not the tournament result. placement is the position among recognised squads.in_tournament: false if they were not in the bracket. They are excluded from
aggregates automatically, but they are present in /v1/matches/{id}.exclude_disconnected=1 for
full-match-only numbers. The one place they are always excluded is the class split - a
greyed-out scoreboard row carries no class letter, so there is nothing to attribute.team_id within an event and on roster Embark IDs across events.null name and are known only by their code.
Fall back to the code for display.curl -H "Authorization: Bearer $CB_KEY" \
"https://api.stats.playrservers.com/v1/stats?group_by=player&tournament=CB-42WA\
&sort=kd&min_matches=5&limit=10"
curl -H "Authorization: Bearer $CB_KEY" \
"https://api.stats.playrservers.com/v1/players/SHADOW%237360"
curl -H "Authorization: Bearer $CB_KEY" \
"https://api.stats.playrservers.com/v1/stats?group_by=map&team_id=17&sort=win_rate"
const BASE = "https://api.stats.playrservers.com/v1";
async function cb(path, params = {}) {
const url = new URL(BASE + path);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.CB_KEY}` },
});
const body = await res.json();
if (!res.ok) {
// Branch on the code, never on the message.
if (body.error === "rate_limited") {
const wait = Number(res.headers.get("Retry-After") || 5);
await new Promise((r) => setTimeout(r, wait * 1000));
return cb(path, params);
}
throw new Error(`${body.error}: ${body.message}`);
}
return body;
}
const { rows } = await cb("/stats", {
group_by: "player", tournament: "CB-42WA", sort: "kd", min_matches: 5, limit: 10,
});
for (const p of rows) console.log(p.embark_id, p.kd, `${p.matches} matches`);
import os, requests
BASE = "https://api.stats.playrservers.com/v1"
S = requests.Session()
S.headers["Authorization"] = f"Bearer {os.environ['CB_KEY']}"
def cb(path, **params):
r = S.get(BASE + path, params=params, timeout=20)
body = r.json()
if not r.ok:
raise RuntimeError(f"{body['error']}: {body['message']}")
return body
# Every player, paged through in chunks of 500.
offset, everyone = 0, []
while True:
page = cb("/stats", group_by="player", limit=500, offset=offset)
everyone += page["rows"]
offset += len(page["rows"])
if offset >= page["total"] or not page["rows"]:
break
print(len(everyone), "players")
curl -H "Authorization: Bearer $CB_KEY" \
"https://api.stats.playrservers.com/v1/stats.csv?group_by=team&tournament=CB-42WA" \
-o teams.csv