CODEBREAKERS stats

Public API reference

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.

Contents

Getting started

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.

Authentication

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.

Scopes

ScopeGrants
statsEvery game statistic: players, squads, matches, placements, maps, classes, tournaments. All keys have this.
identityAdditionally 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.

Rate limits

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:

HeaderMeaning
X-RateLimit-LimitRequests allowed in the current minute.
X-RateLimit-RemainingHow many of those are left.
X-RateLimit-ResetSeconds until the window rolls over.
Retry-AfterSent 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

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_...` ..."
}
StatusCodeMeaning
400key_in_queryThe key was sent in the URL. Use a header.
400unknown_group_byNot a valid grouping. The message lists the valid ones.
400unknown_sortNot a valid sort key. The message lists the valid ones.
400missing_queryA search endpoint was called without ?q=.
400bad_team_id / bad_match_idThat resource is addressed by a numeric id.
401key_requiredNo key was sent.
401invalid_keyThe key is not recognised.
401key_revokedThe key was valid but has been withdrawn.
403ip_not_allowedThe key is address-locked and you are not calling from a permitted address.
404not_foundNo such player, team, match or tournament.
404unknown_endpointNo such route. GET /v1 lists them.
405method_not_allowedThe API is read-only; use GET.
429rate_limitedSlow down. See Retry-After.
500internal_errorSomething broke on our side; it has been logged.

Conventions

Timestamps

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).

Pagination

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": [ ... ] }

Filters

These apply to /v1/stats, /v1/stats.csv, /v1/players and /v1/teams, and can be combined freely.

ParameterEffect
tournamentOne tournament code, e.g. CB-42WA. Case-insensitive. Omit for all-time totals.
player_idRestrict to one player (numeric id).
team_idRestrict to one squad.
match_idRestrict to one match.
classL, M or H.
mapExact map name, as returned by /v1/maps.
from, toBound played_at, in epoch milliseconds.
min_matchesDrop groups with fewer than this many matches. Use it on leaderboards so a single lucky game does not top a rate-based ranking.
exclude_disconnected1 to drop the rows of players who left a match early. Off by default - see caveats.

Sorting

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

Identifiers

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.

The stats engine

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_byOne row perNames its group as
playerplayer (the default)player_id, embark_id
teamsquadteam_id, team
classclass (Light / Medium / Heavy)class
mapmapmap
tournamenttournamenttournament, tournament_name
matchmatchmatch_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.

Endpoint reference

Meta

GET /v1Machine-readable index of every endpoint.
GET /v1/statusService version, corpus counts (tournaments, teams, players, matches), the timestamp of the most recent match, and what your key may do.

Aggregates

GET /v1/statsThe engine above. group_by + all filters + sorting + pagination.
GET /v1/stats.csvThe same rows as CSV.
GET /v1/mvpMVP ranking, all-time or for one ?tournament=. See how it is scored.

Tournaments

GET /v1/tournamentsEvery 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}/standingsSquads ranked. sort defaults to wins.
GET /v1/tournaments/{code}/mvpMVP ranking for that tournament.
GET /v1/tournaments/{code}/matchesIts matches, newest first. Paginated.
GET /v1/tournaments/{code}/matches/{pod}One match by its bracket pod id, in full.

Players

GET /v1/playersPlayer 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}/matchesTheir complete match log, paginated, optionally filtered to one ?tournament=.

Teams

GET /v1/teamsSquad 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}/matchesEvery match the squad played, with its combined stat line and placement for each.

Matches

GET /v1/matchesMatch 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}/editsThe correction log - which fields a moderator changed after the fact, from what to what.

Reference lists

GET /v1/mapsEvery map seen, with match counts. These are the exact strings the map filter expects.
GET /v1/classesClasses seen, with appearance counts.

Field glossary

Per-player, per-match

FieldMeaning
embark_idThe player's in-game name including its #tag. The stable identity.
classL Light, M Medium, H Heavy. null when the scoreboard did not show one (see disconnected).
eliminationsKills credited on the end-of-match scoreboard.
assistsElimination assists.
deathsTimes eliminated.
revivesTeammates revived.
combatThe game's combat score for that match. Thousands, not a rate.
supportThe game's support score.
objectiveThe game's objective score.
disconnected1 if the player left before the end. Their partial line still counts unless you pass exclude_disconnected=1.

Per-squad, per-match

FieldMeaning
placementFinishing position within the bracket match. 1 is the win. This is the number that counts.
placement_observedThe 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.
cashThe squad's cash-out total for the match.
in_tournamentfalse for a squad or player who appeared on the scoreboard but is not in the bracket. Filter these out for tournament-only analysis.
squad_labelThe squad's on-screen name, kept even when it could not be matched to a registered team.

Per-match

FieldMeaning
tournament_codee.g. CB-42WA.
pod_id, labelThe bracket slot and its human label, e.g. groups-0 / "Group A".
mapThe map actually played (falls back to the scheduled one if the screenshot did not show it).
map_scheduled, map_observedThose two inputs separately.
on_streamWhether the match was on the broadcast.
played_atWhen the result was recorded, epoch ms.
confidence, notesHow sure the reader was about this scoreboard, and anything it flagged.
submitted_by, applied_byWho sent the screenshot and who applied the result. Requires the identity scope.

Derived on aggregates

FieldMeaning
matchesMatch count for this grouping - see the note on what that means.
kdEliminations / deaths. With zero deaths it falls back to raw eliminations rather than dividing by zero.
kda(Eliminations + assists) / deaths, same fallback.
winsMatches finished in placement 1.
win_rateWins as a percentage of matches.
avg_placementMean finishing position. Lower is better.
*_per_matchThe summed metric divided by the match count for that grouping.

How the numbers are derived

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.

Data caveats

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.

Examples

Top 10 by K/D in one tournament, minimum 5 matches

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"

One player's whole career

curl -H "Authorization: Bearer $CB_KEY" \
  "https://api.stats.playrservers.com/v1/players/SHADOW%237360"

Which map does a squad do best on?

curl -H "Authorization: Bearer $CB_KEY" \
  "https://api.stats.playrservers.com/v1/stats?group_by=map&team_id=17&sort=win_rate"

Node

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`);

Python

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")

Straight into a spreadsheet

curl -H "Authorization: Bearer $CB_KEY" \
  "https://api.stats.playrservers.com/v1/stats.csv?group_by=team&tournament=CB-42WA" \
  -o teams.csv