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
| Approach | Requests |
|---|---|
| Match, both teams, tournament, odds, three detail layers — all separately | 8 |
?include=teams,tournament,odds plus three detail-layer calls | 4 |
?include=teams,tournament,odds,maps,stats,rounds | 1 |
The same plus every player's name — ...,lineups,players | 1 |
For a page rendering 20 matches that's the difference between 160 requests and 20 — 0.1% of your month instead of 0.8%.