guide

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

Jul 10, 20266 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. (status takes scheduled, live, completed or cancelled; called-off fixtures are left out of the default list, so ask for them with ?status=cancelled.) 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 error.code is invalid_parameter and whose error.message names the field to fix and what it accepts. A status outside scheduled/live/completed/cancelled gets the message status must be one of: scheduled, live, completed, cancelled; a team or tournament that isn't a UUID gets team must be a team id (a UUID), not a name (or the same for tournament), followed by the list route to look the id up on; a date that isn't YYYY-MM-DD gets date_from must be YYYY-MM-DD (or date_to must be YYYY-MM-DD). Here is the last one in full:

{
  "error": {
    "code": "invalid_parameter",
    "message": "date_from must be YYYY-MM-DD",
    "request_id": "019f3d18-c15f-7319-81a7-343e8a80a578"
  }
}

All of them share one code, so branch on that to know the request itself was malformed, and show the message to whoever has to fix it. The wording of the message is for people, not a contract, so don't match on it.

The 400 bodies share one error code, invalid_parameter, and the message names the field to fix and what it accepts: a bad status returns the four valid statuses, a non-UUID team returns team must be a team id (a UUID), 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 (a cursor the API can't decode is refused with a 400 whose error.code is invalid_cursor), 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, completed, or cancelled (a called-off fixture, listed only when you ask for it with ?status=cancelled).
  • 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.

Start free with a sandbox key: a fixed sample of past matches, no card.