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 — 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 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:

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?

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.

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")

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.

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 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:

  • statusscheduled, 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 of matches in hand, you're ready to go deeper:

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.