guide
"How to pull CS2 match data from the API (filters + pagination)"
Jul 10, 2026
Bottom line: The CS2 matches endpoint gives you scheduled, live, and completed games, and you shape the result with query parameters:
status,team,tournament, and a date range. When a result set is large, you walk it with an opaque cursor — readmeta.next_cursor, pass it straight back, and stop when it comes backnull. The trap is treating the cursor as something you can build or decode. You can't; pass it back byte-for-byte. Everything here runs against the EsportsOdds CS2 data API.
The matches list, and how to narrow it
If you followed the quickstart, you've already called GET /v1/cs2/matches and seen the enriched rows come back with team and tournament names attached. Now let's make it return only what you want.
The endpoint accepts several filters, and you can combine them:
Want only the games in progress?
curl -s "https://api.esportsodds.gg/v1/cs2/matches?status=live" \
-H "Authorization: Bearer $ESPORTSODDS_API_KEY"
Want a single team's recent completed games, most useful for a form widget?
curl -s "https://api.esportsodds.gg/v1/cs2/matches?status=completed&team=<team-uuid>&date_from=2026-06-01" \
-H "Authorization: Bearer $ESPORTSODDS_API_KEY"
The team and tournament filters take UUIDs, not names or slugs. To get a team's UUID from its slug, ask the teams list: GET /v1/cs2/teams?slug=natus-vincere returns the row, and you read id off it. That slug-to-ID step is the same pattern you'll use anywhere a filter or detail route wants an ID.
Paging through the results
Each list response carries a meta block:
{ "meta": { "count": 200, "next_cursor": "eyJ0cyI6IjIwMjYtMDcuLi4ifQ" } }
count is how many rows are in this page. next_cursor is a token for the next page — or null if you've reached the end. The loop is always the same: call, read the cursor, feed it back, repeat until it's null.
Here's a complete, reusable pager in Python. It handles any list endpoint on the API, not just matches:
import os
import requests
BASE = "https://api.esportsodds.gg"
SESSION = requests.Session()
SESSION.headers["Authorization"] = f"Bearer {os.environ['ESPORTSODDS_API_KEY']}"
def paginate(path, params=None):
"""Yield every row across all pages of a list endpoint."""
params = dict(params or {})
while True:
resp = SESSION.get(f"{BASE}{path}", params=params, timeout=15)
resp.raise_for_status()
payload = resp.json()
yield from payload["data"]
cursor = payload["meta"].get("next_cursor")
if not cursor: # null (or absent) means the last page
return
params["cursor"] = cursor # pass the token back verbatim
# Every completed match this month, across however many pages it takes:
matches = list(paginate("/v1/cs2/matches", {
"status": "completed",
"date_from": "2026-07-01",
"limit": 200,
}))
print(f"pulled {len(matches)} matches")
Two things make this robust. It sets limit=200 to pull big pages (fewer round trips), and it passes the cursor back exactly as received. The cursor is opaque: it encodes the sort position internally, and any attempt to parse, increment, or reconstruct it will break. Treat it as a black box and pagination just works.
Not every list paginates
Here's a distinction that saves confusion: some CS2 lists issue a cursor and page; others return their whole result in one response and ignore a cursor entirely.
The large, open-ended collections paginate: matches, teams, players, tournaments, and a match's odds history. The bounded ones come back complete in a single call: a match's stats, rounds, and vetoes; the rankings board; a team's form; the coverage summary. Their size is capped by the thing itself (a match has a fixed number of rounds), so running the pager above against one is harmless. It just returns everything on the first pass and stops, and you don't need to reach for it there.
Reading match fields
Once you have the rows, the fields you'll use most are:
status—scheduled,live, orcompleted.score_a/score_b— the series score. These are null until the match has a result, so guard for it.scheduled_at— an ISO-8601 timestamp (the field isscheduled_at, notstart_time).team_a_name/team_b_name/tournament_name— present on list rows, so you rarely need a second call.
for m in matches:
a, b = m.get("team_a_name", "TBD"), m.get("team_b_name", "TBD")
if m["score_a"] is not None:
print(f"{a} {m['score_a']}–{m['score_b']} {b}")
else:
print(f"{a} vs {b} — {m['scheduled_at']}")
Next steps
With a filtered, fully-paged list of matches in hand, you're ready to go deeper:
- Analyse the stats in Python — pull
/matches/{id}/statsinto a DataFrame. - Generate a match preview — combine head-to-head, form, and rankings for two teams.
- Handle rate limits — before you loop over hundreds of matches, make your client back off politely.
The endpoint reference lists every filter each resource accepts. Filter narrow, page with the cursor, and let next_cursor: null tell you when you're done.