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 with team and tournament names. 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? Add ?status=live. Want a single team's recent completed games for a form widget? Stack filters with &:
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 recurs anywhere a filter or detail route wants an ID.
When a filter is malformed
A bad filter value doesn't fail silently: the endpoint returns a 400 whose body names the field to fix. A status outside scheduled/live/completed returns {"error": "invalid status filter"}; a team or tournament that isn't a UUID returns {"error": "team must be a valid id"}; a date that isn't YYYY-MM-DD returns {"error": "date_from must be YYYY-MM-DD"}.
Paging through the results
Each list response carries a meta block:
{ "meta": { "count": 200, "next_cursor": "eyJ0cyI6IjIwMjYtMDcuLi4ifQ" } }
count is this page's row count. next_cursor is the token for the next page, or null at the end. The loop never changes: call, read the cursor, feed it back, repeat until 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")
Three things make this robust. It's a generator, so it streams rows without holding every page in memory; it reuses one Session so connections stay warm across pages; and it passes the cursor back exactly as received. The cursor is opaque: any attempt to parse, increment, or reconstruct it will break, so treat it as a black box.
Each list clamps limit to its own ceiling, so asking for more returns the maximum, not an error. /matches defaults to 100 (max 500); a team's /form to 10 (max 50); a match's odds history at /odds?match= to 200 (max 1000). Pick a limit near the ceiling to cut round trips.
Not every list paginates
One distinction saves confusion: some CS2 lists issue a cursor and page; others return their whole result in one response and ignore a cursor.
The large, open-ended collections paginate: matches, teams, players, tournaments, and a match's odds history (the derived market line, built as the methodology describes). 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, so running the pager against one is harmless: it returns everything on the first pass and stops.
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 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.
- Handle rate limits — make your client back off before you loop over hundreds of matches.
Filter narrow, request big pages, and let next_cursor: null tell you when you're done. Pull it once, cleanly, and every guide downstream has something to build on.
Full API reference: pagination and filtering in the developer docs.