guide

CS2 match data in JSON: one real match, field by field

Oct 12, 20268 min read

Bottom line: A completed CS2 match in our API is one match record plus the data hanging off it: map scores with halves, the veto, two rows per round, player stats per map and for the whole match, and deeper per-player breakdowns. One request with include= returns the match with its teams, maps, veto, rounds and player stats (about 109 KB for the three-map match below). Three things catch people out: a match record carries team ids, not names; kast means a count in a round row and a fraction in a player row; and a stats row with map_number: null is the whole-match total. Everything here comes from the CS2 data API, exactly as it answered on 12 October 2026.

The match: MOUZ 2-1 Spirit

Every example on this page is one real match: MOUZ against Spirit in the ESL Pro League Season 24 playoffs on 10 October 2026, won 2-1 by MOUZ (Dust2 13-7, Mirage 5-13, Nuke 13-11). Here is the match record, as GET /v1/cs2/matches/{id} returns it, with its three bookkeeping timestamps left out:

{
  "data": {
    "id": "01a12213-cbe2-7faf-aaaa-1e9551c76359",
    "game_id": "019f23d1-fb5b-7d24-935d-e4d68f6f565a",
    "tournament_id": "019f23d2-5a32-72ef-a7c4-4c511c936fc0",
    "team_a_id": "019f23d1-fb5c-738b-8ca9-2e78eaeb5b42",
    "team_b_id": "019f23d1-fb5c-7a98-97da-542fb409d0ad",
    "format": "bo3",
    "status": "completed",
    "stage": "ESL Pro League Season 24 Playoffs",
    "scheduled_at": "2026-10-10T17:45:00Z",
    "started_at": "2026-10-10T17:45:37Z",
    "ended_at": "2026-10-10T20:46:42Z",
    "score_a": 2,
    "score_b": 1,
    "winner_team_id": "019f23d1-fb5c-738b-8ca9-2e78eaeb5b42",
    "result_type": "played",
    "live_state": null,
    "data_available": {
      "maps": true, "stats": true, "map_stats": true, "vetoes": true,
      "rounds": true, "depth": true, "lineups": true, "odds": true
    }
  }
}

Two things to notice. The record names nobody: team_a_id and team_b_id are ids. The match list (GET /v1/cs2/matches) adds team_a_name, team_b_name, tournament_name and tournament_tier to each row, and the detail route does not.

Diagram: the GET /matches list rows include team names such as team_a_name, while a GET /matches/{id} detail row carries only team_a_id, to be resolved through /teams or an include.

The second is data_available. It says which of the match's sub-resources hold data before you ask for them, so you can skip calls that would come back empty. Coverage thins with tier: over the last 12 months, round data exists for 814 of 816 completed S-tier matches, 80% of B-tier ones and 63% of C-tier ones.

One request for the whole match

You can fetch each part from its own route (/maps, /vetoes, /rounds, /stats, /lineups, /depth), or name the ones you want in include= and get one response. The call below leaves out depth and lineups; add them to the list if you need them. That response counts as one request against your monthly allowance.

curl -s "https://api.esportsodds.gg/v1/cs2/matches/01a12213-cbe2-7faf-aaaa-1e9551c76359?include=teams,tournament,maps,vetoes,rounds,stats" \
  -H "Authorization: Bearer $ESPORTSODDS_API_KEY"

teams adds team_a and team_b objects with names, which fixes the first gotcha in the same call. tournament adds the event, including its tier. Each sub-resource arrives as the same array its own route returns. With the official Python client (pip install esportsodds):

from esportsodds import Client

MATCH = "01a12213-cbe2-7faf-aaaa-1e9551c76359"  # MOUZ vs Spirit, 10 October 2026

client = Client()  # reads ESPORTSODDS_API_KEY from the environment
match = client.matches.get(MATCH, include="teams,tournament,maps,vetoes,rounds,stats")

a, b = match["team_a"], match["team_b"]
print(f'{a["name"]} {match["score_a"]}-{match["score_b"]} {b["name"]}')
for m in match["maps"]:
    print(f'  map {m["map_number"]}: {m["map_name"]} {m["score_a"]}-{m["score_b"]}')

Diagram of the response envelope: a list response holds a data array plus meta with count and next_cursor; a single resource holds just a data object.

A single match comes back as one data object. Lists, including each sub-resource route on its own, come back as a data array plus meta with count and next_cursor. Each successful response also carries X-Quota-Remaining, so you can watch your allowance without a separate call. For many matches at once, /v1/cs2/matches/bulk takes the same include= and returns 25 matches per request; pulling CS2 match data covers its filters and cursors.

Maps: scores, halves and sides

{
  "map_number": 1,
  "map_name": "de_dust2",
  "score_a": 13, "score_b": 7,
  "first_half_a": 8, "first_half_b": 4,
  "second_half_a": 5, "second_half_b": 3,
  "overtime_a": null, "overtime_b": null,
  "first_half_side_a": "ct",
  "status": "played",
  "rounds_played": 20
}

The halves add up to the score: 8 + 5 = 13 and 4 + 3 = 7. overtime_a and overtime_b are null when there was no overtime, not zero. first_half_side_a tells you which side team A started on, so you can rebuild CT and T splits per team. Map names use the game's internal form (de_dust2, de_mirage, de_nuke).

The veto

Seven rows for a best-of-three, in order:

orderteamchoice_typemap_name
1Spiritbande_inferno
2MOUZbande_anubis
3Spiritpickde_dust2
4MOUZpickde_mirage
5Spiritbande_cache
6MOUZbande_ancient
7(none)deciderde_nuke

Each row carries a team_id, so swap in names from team_a and team_b. The decider row has team_id: null, because nobody chose it. Note the picks: Spirit picked Dust2 and lost it 7-13, and MOUZ picked Mirage and lost it 5-13. For patterns across many matches, see the CS2 map veto meta.

Rounds: two rows per round

The rounds array holds one row per team per round, from that team's point of view. This match has 124 rows: 62 rounds across three maps. Here is round 1 of Dust2, trimmed to the main fields, with the team ids shortened:

[
  {"map_number": 1, "round_number": 1, "team_id": "...5b42", "team_side": "ct", "won": false,
   "pistol_round": true, "economy_level": -1, "equipment_value": 4400, "enemy_equipment_value": 5550,
   "kills": 4, "deaths": 5, "first_kills": 1, "first_deaths": 0, "trade_kills": 2, "kast": 4,
   "bomb_plants": 0, "end_reason": "TerroristsWin", "round_duration_ms": 98062},
  {"map_number": 1, "round_number": 1, "team_id": "...d0ad", "team_side": "t", "won": true,
   "pistol_round": true, "economy_level": 0, "equipment_value": 5550, "enemy_equipment_value": 4400,
   "kills": 5, "deaths": 4, "first_kills": 0, "first_deaths": 1, "trade_kills": 1, "kast": 5,
   "bomb_plants": 1, "end_reason": "TerroristsWin", "round_duration_ms": 98062}
]

A few rules for reading them:

  • Count each round once. Filter to one team_id before you count rounds, or every total doubles. As a check, count won per team per map and you get the map score back: 13, 5 and 13 for MOUZ here.
  • kast in a round row is a count, from 0 to 5: how many of the team's players had a kill, an assist, survived or were traded that round.
  • economy_level is a signed buy class that can be negative for eco and forced rounds. When you need a hard threshold, use equipment_value in dollars, for example $18,000 or more for a team's full buy. The CS2 economy explained covers how buys work.
  • end_reason is an open list. Values seen so far are CTWin, TerroristsWin, BombDefused, TargetBombed and TargetSaved. Treat an unknown value as valid, and do not switch on it exhaustively.

Player stats: per map and for the whole match

The stats array has 40 rows here: ten players for each of the three maps, plus ten rows with map_number: null, which are the whole-match totals. One whole-match row, with its row id, match id and timestamps left out:

{
  "player_id": "019f23d2-5a33-7143-938d-55ec3b77cb81",
  "team_id": "019f23d1-fb5c-738b-8ca9-2e78eaeb5b42",
  "map_number": null,
  "kills": 47, "assists": 15, "deaths": 38,
  "adr": 82.47, "rating": 1.43, "kast": 0.8555555555555556,
  "headshots": 17, "first_kills": 7, "first_deaths": 6,
  "trade_kills": 12, "trade_deaths": 16, "clutches": 2,
  "multikills_2k": 5, "multikills_3k": 3, "multikills_4k": 1, "multikills_5k": 0,
  "damage": 5113, "utility_value": 7500
}

Which player stats fields are always present and which are nullable: kills, deaths and assists, adr and rating are always present; kast, headshots and clutches, and the multikill counts can be null.

Here kast is a fraction from 0 to 1, not a count. rating is our own computed rating, not a third party's published one, so compare it only with itself. If you add per-map rows together, sum kills, deaths and damage, then recompute ADR and KAST from the totals; never average the per-map rates. Or use the map_number: null row, which has done it for you. player_id resolves through GET /v1/cs2/players/{id}, or add players to include=.

Depth, lineups and history

depth holds five arrays of per-player detail: weapons (shots, hits, kills and damage per weapon and side, 243 rows here), grenades (89), hitgroups (141), duels (who killed whom and how often, 50) and flashes (who blinded whom, with a count and a duration in nanoseconds, 97). A match without depth returns all five as empty arrays.

lineups is the roster listed before the match, substitutes included. It is not a confirmed starting five: the players who actually played are the ones in stats.

/history lists the match's recorded revisions, newest first, each naming the fields that changed. This match has 16: its schedule, start time, status and map-by-map score changes as it was played, then seven data_available flips as rounds and depth arrived in the hours after the final map.

The short version

Ask for include=teams,tournament,maps,vetoes,rounds,stats and you have the match, its maps, veto, rounds and player stats in one request. Count rounds from one team's rows, read kast by context, and use the map_number: null rows for match totals.

Where to go next

For a first call and authentication, start with the CS2 API quickstart. To turn these arrays into tables, CS2 stats in Python loads them into pandas. For how the data is compiled and checked, see the methodology.