guide

How to pull CS2 match data from the API (filters + pagination)

Jul 10, 20265 min read

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 — read meta.next_cursor, pass it straight back, and stop when it comes back null. 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:

The matches list accepts filters you can combine: status (live or completed), team, tournament, a date range, and the paging parameters limit and cursor.

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"}.

The 400 bodies name the field to fix: a bad status returns invalid status filter, a non-UUID team returns team must be a valid id, and a wrongly formatted date returns 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.

Cursor pagination is a loop: request a page, read meta.next_cursor, pass that token back on the next request, and stop when it comes back 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.

Page ceilings: matches default to 100 and cap at 500, a team's form defaults to 10 and caps at 50, and odds history defaults to 200 and caps at 1000; a too-large limit is clamped down to the cap rather than erroring.

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.

Paginated lists include matches, teams, players, tournaments and odds history. Single-page lists include stats, rounds, vetoes, rankings, form and coverage; looping a cursor over these does nothing.

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, or completed.
  • 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 is scheduled_at, not start_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:

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.