How-to

Fetch everything about one match

One request instead of five, and how to skip the sub-resources that don't exist.

A match view usually needs the fixture, both teams, the tournament and the odds. Done naively that's five requests. It can be one.

Use include=

curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
  "https://api.esportsodds.gg/v1/cs2/matches/019f3d18-c15f-7319-81a7-343e8a80a578?include=teams,tournament,odds"

The response gains team_a, team_b, tournament and odds alongside the usual match fields. Without include= those keys are absent, not null, so existing clients are unaffected.

Unknown expansion names are ignored rather than rejected — adding one later can't break you.

Embed the detail layers too

The CS2 detail layers embed the same way: maps, stats, vetoes, rounds, depth, lineups and streams. Each is identical to what its own endpoint returns.

streams is the odd one out: it is a live pointer, so it is empty for every match that isn't being played right now. Ask for it on the live slate, not on history.

m = get(f"/v1/cs2/matches/{match_id}"
        "?include=teams,tournament,odds,maps,stats,rounds")["data"]

print(f"{m['team_a']['name']} vs {m['team_b']['name']}{m['tournament']['name']}")
maps, stats, rounds = m["maps"], m["stats"], m["rounds"]

A layer you asked for is always present — an empty list when the match has none on record — so there is nothing to gate: it is one request either way. data_available still tells you why a list is empty, and depth is large (a few hundred KB), so leave it out unless you render it.

Get the player names without N more requests

lineups gives you player_ids. stats gives you a player_id per row. Neither carries a nickname, so a scoreboard or a lineup normally costs one /players/{id} request per player.

include=players resolves them all in the same request:

m = get(f"/v1/cs2/matches/{match_id}?include=stats,lineups,players")["data"]
by_id = {p["id"]: p for p in m["players"]}

for row in m["stats"]:
    if row["map_number"] is None:
        print(by_id[row["player_id"]]["nickname"], row["kills"], row["adr"])

It is the union of the two sets, deduplicated and ordered by nickname — the lineup snapshot can list a benched player who never appeared, and a stats row can name a stand-in no snapshot ever listed, so every player_id in either layer resolves. There is no standalone /matches/{id}/players route; this expansion is the only way to ask for it.

The per-layer endpoints (/matches/{id}/maps and friends) remain, and are the better choice when you only need one layer. If you do call them, read data_available first: requesting a layer that doesn't exist returns 200 with an empty list, and still costs a request.

Find matches that have what you need

If you're building a dataset rather than rendering one match, filter at the list level so you never fetch a match you'd discard:

curl -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
  "https://api.esportsodds.gg/v1/cs2/matches?status=completed&has=rounds,depth&limit=100"

has= takes any subset of maps,stats,map_stats,vetoes,rounds,depth,lineups,odds, AND-ed. To pull those matches with their layers in one go, use the bulk endpoint — see pull the historical archive.

Don't double-count the stats

/stats returns one row per player per map plus a whole-match aggregate row per player, with map_number: null on the aggregate:

per_map   = [s for s in stats if s["map_number"] is not None]
aggregate = [s for s in stats if s["map_number"] is None]

Summing everything counts each player twice. See player match stats.

Cost comparison

ApproachRequests
Match, both teams, tournament, odds, three detail layers — all separately8
?include=teams,tournament,odds plus three detail-layer calls4
?include=teams,tournament,odds,maps,stats,rounds1
The same plus every player's name — ...,lineups,players1

For a page rendering 20 matches that's the difference between 160 requests and 20 — 0.1% of your month instead of 0.8%.

On this page