guide

CS2 player stats API: figures, percentiles, maps and weapons

Oct 13, 20267 min read

Bottom line: Six routes describe a CS2 player: /performance for 12-month figures with percentiles, then /maps, /matches, /teams, /weapons, and /stats for all-time totals. Every one takes the player's slug in place of the UUID, so /v1/cs2/players/zywoo/performance works with no lookup first. The one rule to learn: a figure from fewer than 10 recorded maps comes back null, and so does its percentile. Examples below run against the EsportsOdds CS2 data API.

The six player routes

One player, six read routes: the slug or UUID opens performance with percentiles, per-map figures, recent matches up to 25, team history as stints, eight weapons plus other, and all-time career totals.

All six are GET routes under /v1/cs2/players/{id}, and {id} is the player's UUID or slug. A slug the player was renamed away from still resolves, and the slug field in the response gives the current one. If all you have is a handle, GET /v1/cs2/players?slug=zywoo returns the matching row.

RouteReturnsWindow
/performanceten figures, each with a percentilelast 12 months
/mapsmaps played, ADR, KAST and K/D per maplast 12 months
/matchesrecent completed matches with the player's own linenewest first, up to 25
/teamsteam history as stintsevery completed match
/weaponsthe top eight weapons, an other row, and hit groupslast 12 months
/statscareer totals and averagesevery completed match

Figures with percentiles

/performance returns ten metrics, each shaped { key, label, unit, value, n, percentile }. Here is ZywOo's response, trimmed to three of the ten:

{
  "data": {
    "slug": "zywoo",
    "nickname": "ZywOo",
    "role": "AWP",
    "level": "established",
    "window": { "months": 12, "through": "2026-10-11T15:00:00Z" },
    "population": { "n": 1086, "level_definition": "..." },
    "metrics": [
      { "key": "kd", "label": "Kills per death", "unit": "ratio", "value": 1.51, "n": 194, "percentile": 99.9 },
      { "key": "adr", "label": "Damage per round", "unit": "per_round", "value": 87.1, "n": 194, "percentile": 99.2 },
      { "key": "headshot_share", "label": "Headshot share of kills", "unit": "percent", "value": 41.7, "n": 194, "percentile": 19.3 }
    ]
  }
}

Every figure is built the same way.

How a performance figure is built: take the recorded maps from the last 12 months, divide sums over those maps, return null below 10 maps, then compute the share of players with a strictly lower value.

Only recorded maps count: a map played to a result, in a completed Counter-Strike 2 match that was not a walkover, where the player's line is complete and attributed to one of the two teams. A rate is a ratio of sums across those maps (ADR and KAST weighted by rounds), never an average of per-map figures, and n says how many maps it uses. The percentile is the share of the population with a strictly lower value, floored to one decimal, so it never reads 100. The population is every player with a player page, 1,086 of them in this response, and level (established, listed or none) says where this player stands against the rule in population.level_definition.

Percentiles describe a profile; they don't grade it. ZywOo's headshot share sits at the 19.3rd percentile, and /weapons shows why: 155 of his 1,377 AWP kills were headshots. The definition object carries the exact wording of every rule, so quote it in your UI rather than paraphrasing.

Per-map figures

/maps lists every competitive map the player has at least one recorded map on in the window, most played first:

[
  { "map_name": "de_dust2", "active_pool": true, "maps": 44, "adr": 89.5, "kast": 81.2, "kd": 1.68, "n_adr": 44, "n_kast": 44 },
  { "map_name": "de_cache", "active_pool": true, "maps": 8, "adr": null, "kast": null, "kd": null, "n_adr": 8, "n_kast": 8 }
]

The second row is the 10-map floor at work: eight maps, so every figure is null. active_pool says whether a map is in the current Active Duty pool, which lets you hide retired maps without keeping your own list (de_overpass and de_train are false in this response).

Recent matches and team history

/matches returns completed matches newest first, with the tournament and its tier and the player's kills, deaths and ADR for the whole match. limit defaults to 10 and stops at 25; a larger value returns 25, and there is no cursor. One row, with the team ids trimmed:

{
  "match_id": "01a1279b-1147-72ee-b660-745c54b5cc35",
  "date": "2026-10-11T15:02:10Z",
  "team": { "name": "Vitality", "slug": "vitality" },
  "opponent": { "name": "MOUZ", "slug": "mouz-cs2" },
  "tournament": { "name": "ESL Pro League Season 24", "tier": "S" },
  "format": "bo5",
  "score_for": 3,
  "score_against": 0,
  "kills": 64,
  "deaths": 33,
  "adr": 90.6
}

Walkovers are left out. When a player's line names no team, team, opponent and both scores are null.

/teams turns the same lines into stints, each a run of consecutive matches for one team. A run shorter than min_matches (3) is a stand-in appearance and is left out unless it is the current one, and the newest stint is current only while its last match falls within 90 days of the newest result in the data. ZywOo has one stint: Vitality, 219 matches and 516 maps, current: true. A stint starts at the first match our data attributes to that team, so treat it as a playing record, not a contract date.

Weapons and hit groups

/weapons sums weapon data over the player's matches in the last 12 months. It is recorded per match, never per map, so matches (81 here) counts matches, and window.through dates the newest of them. The eight weapons with the most kills are listed and the rest fold into one other row; hit_groups splits hits, damage and kills by body area.

Build a player card in Python

Three reads give you a compact card: the figures, the per-map split and the top weapons.

Building a player card: read performance, maps and weapons for one slug, then render them together on one card.

import os
import requests

BASE = "https://api.esportsodds.gg/v1/cs2"
HEADERS = {"Authorization": f"Bearer {os.environ['ESPORTSODDS_API_KEY']}"}


def get(path: str) -> dict:
    resp = requests.get(f"{BASE}{path}", headers=HEADERS, timeout=15)
    resp.raise_for_status()
    return resp.json()["data"]


def player_card(slug: str) -> None:
    perf = get(f"/players/{slug}/performance")
    maps = get(f"/players/{slug}/maps")
    weapons = get(f"/players/{slug}/weapons")

    print(f"{perf['nickname']} ({perf['role'] or 'role unknown'}), "
          f"last {perf['window']['months']} months to {perf['window']['through'][:10]}")
    for m in perf["metrics"]:
        if m["value"] is None:
            continue  # fewer than 10 recorded maps: no figure, no percentile
        print(f"  {m['label']:<26}{m['value']:>7}   p{m['percentile']}")
    for row in maps["maps"]:
        if row["kd"] is not None:
            print(f"  {row['map_name']:<12}{row['maps']:>4} maps   ADR {row['adr']}   K/D {row['kd']}")
    top = [w for w in weapons["weapons"] if w["slug"] != "other"][:3]
    print("  top weapons: " + ", ".join(f"{w['name']} {w['kills']}" for w in top))


player_card("zywoo")

Run against the live API, it prints:

ZywOo (AWP), last 12 months to 2026-10-11
  Maps                          194   p89.1
  Matches                        81   p85.4
  Kills per death              1.51   p99.9
  Damage per round             87.1   p99.2
  KAST                         78.3   p99.7
  Headshot share of kills      41.7   p19.3
  Opening duels won            63.4   p95.3
  Rounds with a multi-kill     21.9   p99.5
  Clutches per map             0.46   p97.3
  Trade share of kills         18.7   p31.4
  de_dust2      44 maps   ADR 89.5   K/D 1.68
  de_mirage     34 maps   ADR 86.7   K/D 1.53
  de_nuke       32 maps   ADR 86.7   K/D 1.38
  de_inferno    31 maps   ADR 85.4   K/D 1.39
  de_overpass   20 maps   ADR 89.7   K/D 1.69
  de_anubis     18 maps   ADR 77   K/D 1.22
  top weapons: AWP 1377, AK-47 876, M4A4 208

Skipping a null is the right move: it is an honest absence, never a zero, and printing it as 0 would put a figure on the card that the data does not support.

Gotchas

  • KAST is 0 to 100 here. On these routes kast is a percentage; on a stat line from /matches/{id}/stats it is a 0 to 1 fraction. If you mix the two, see Analysing CS2 stats in Python for the per-match shape.
  • Percentiles move with the population. They compare against every player with a page, and that set changes as players cross the map thresholds, so store the value and n alongside any percentile you cache.
  • /matches is short by design. It stops at 25 rows. For a deeper history, page the team's matches (/matches?team=) and read each match's stats.
  • Player routes need a subscription key. The sandbox key reads a fixed sample of past matches, not the player routes.

Where to go next

The full field reference is on the player stats docs page. To turn career numbers into a projection, the fantasy projections guide builds on /stats. And if you want to see these 12-month figures before writing any code, the public player pages show them for every player with a page.

Start free with a sandbox key: a fixed sample of past matches, no card.

One slug, three requests, and a profile that says exactly how many maps stand behind each number.