How-to

Pull the historical archive

Load every completed match with its maps, stats, rounds and depth in about a thousand requests, then keep it current.

Use the bulk endpoint. It returns up to 25 matches per request with their sub-resources embedded, and each request counts once against your quota whatever it carries. Walking the archive through the per-match routes instead costs up to seven requests per match — tens of thousands of matches would be several months of quota. Through bulk, the whole completed-match archive is on the order of a thousand requests: about 2% of one Standard month.

How long the pull takes depends on your plan. Pro: in one go. Standard from 30 October 2026: about five months at 250 bulk-export requests a month.

On Pro, bulk-export requests are unlimited, so the pull runs in one go. A Standard account that first subscribed on or after 30 October 2026 may make 250 bulk-export requests a month, so the same pull takes about five months, resuming from the checkpoint each month (see the script below). A Standard account that first subscribed before that date has no bulk cap. See bulk export.

One page

curl --compressed -H "Authorization: Bearer $ESPORTSODDS_API_KEY" \
  "https://api.esportsodds.gg/v1/cs2/matches/bulk?status=completed&include=maps,stats,vetoes,rounds,depth,lineups"

Each row is the normal match list row plus data_available and one key per include you asked for. Every embedded array is identical to what the matching per-match route returns, so a parser you already wrote for /matches/{id}/stats works unchanged on row["stats"].

  • Ask only for what you need. An include you leave out is absent and costs nothing to build. depth is by far the largest layer — about three quarters of a full page.
  • Send Accept-Encoding: gzip (--compressed in curl; on by default in requests, fetch and Go's http.Client). A full page is around 7 MB of JSON and about a tenth of that on the wire.
  • meta.max_limit is 25. A larger limit is clamped, and the meta tells you so.
  • meta.includes echoes what was understood. A typo in include= is ignored, not rejected — check this list once while you're developing.
  • An empty array means "none on record", not "not requested" — an unrequested include has no key at all. data_available says the same thing per match.

The whole archive, resumably

import os, time, requests

API = "https://api.esportsodds.gg/v1/cs2/matches/bulk"
HEADERS = {"Authorization": f"Bearer {os.environ['ESPORTSODDS_API_KEY']}"}

def pull_archive(db):
    cursor = db.get_checkpoint("archive")            # None on the first run
    while True:
        params = {
            "status": "completed",
            "sort": "scheduled_at",                  # oldest first: a stable walk while new results land
            "include": "maps,stats,vetoes,rounds,depth,lineups",
        }
        if cursor:
            params["cursor"] = cursor
        r = requests.get(API, headers=HEADERS, params=params, timeout=120)

        if r.status_code == 503:                     # both bulk slots busy — not billed
            time.sleep(int(r.headers.get("Retry-After", "2")))
            continue
        if r.status_code == 429 and r.json()["error"]["code"] == "bulk_quota_exceeded":
            return                                   # this month's bulk requests are spent; the checkpoint is saved
        r.raise_for_status()
        page = r.json()

        for match in page["data"]:
            db.upsert_match(match)                   # keyed on match["id"]

        cursor = page["meta"]["next_cursor"]
        db.set_checkpoint("archive", cursor)
        if not cursor:
            return

Checkpoint the cursor, not an offset, and upsert on id: a restart then resumes where it stopped and a re-run is harmless. A cursor is bound to the sort it was issued under, so keep the sort fixed for the life of a checkpoint. On a Standard subscription with the 250-a-month cap the loop stops with bulk_quota_exceeded when the month's bulk requests are used; run it again after X-Quota-Reset and it carries on from the checkpoint.

Page one at a time. At most two bulk pages are built at once across the whole service. A third concurrent request gets 503 with Retry-After: 2 — it is not counted against your quota, but parallel workers will mostly just collide with each other.

To narrow the walk, bulk takes every filter the match list does: tournament, team, date_from / date_to, and has= — has=rounds,depth skips matches that would embed empty arrays, so every request you spend returns the layers you came for.

Using Python?

The official Python client, installed with pip install esportsodds, does both walks for you. client.matches.iter_bulk(...) fetches one 25-match page per request, and client.matches.iter_changes(since) follows the change feed with the ten-minute overlap and the de-duplication built in.

Keep it current

Once you hold the archive, don't re-walk it. Ask only for what changed:

def sync_changes(db):
    since = db.get_checkpoint("changed_since")       # newest changed_at you have stored
    since = (parse(since) - timedelta(minutes=10)).isoformat()   # overlap — see below
    cursor = None
    while True:
        params = {"changed_since": since, "sort": "changed_at",
                  "include": "maps,stats,vetoes,rounds,depth,lineups"}
        if cursor:
            params["cursor"] = cursor
        page = get_bulk(params)
        for match in page["data"]:
            db.upsert_match(match)
            db.bump_checkpoint("changed_since", match["changed_at"])
        cursor = page["meta"]["next_cursor"]
        if not cursor:
            return

changed_at moves when a match-level field changes — status, scores, winner, the scheduled, started or ended times, stage, format, the teams or the tournament. Three things to design around:

  1. Overlap by ten minutes and de-duplicate on id. Ingestion commits in batches, so a row can become visible a few minutes after the changed_at it carries. Resuming from the exact last value can skip it; an overlap cannot.
  2. It sees a sub-resource arriving, not a sub-resource changing. Player stats and depth for a match land after it flips to completed, and since 2026-09-18 the first arrival of each layer moves changed_at — so the match comes back to you with its data_available flags flipped, and you can fetch the layer then. A later correction to a layer you already hold does not come back; if you need those, re-pull the last two days of completed matches nightly, which is three or four requests.
  3. Cancellations are included. The default match list hides cancelled and long-overdue fixtures; a changed_since request does not, because a cancellation is exactly the change you need to hear about. Expect status: "cancelled" rows and handle them.

Change tracking began on 2026-09-18. Rows that have not changed since all carry that date, so the first changed_since you send should be the time you finished your archive pull.

What it costs

JobRequests
The full completed-match archive, every layerabout 1,000
A nightly re-pull of the last two days3–4
A changed_since sync every 15 minutesabout 100 a day, most returning a near-empty page

Every row above is a bulk-export request. At 250 a month, a capped Standard subscription has room for the nightly re-pull (about 100 a month) but not for a changed_since sync through bulk every 15 minutes (about 3,000 a month). On that subscription, follow changes with the plain match list (?changed_since=<rfc3339>&sort=changed_at, no include), which is not capped, and fetch the layers of a match that moved from GET /v1/cs2/matches/{id}?include=.... Pro has no bulk cap, so the sync above is fine there.

A full archive pull ends a first subscription's 14-day money-back guarantee, which applies only while the account has made fewer than 1,000 requests and no more than 20 bulk-export requests since its first payment (the refund policy has the full terms). If you are still deciding, judge the data from a few bulk pages first. A legacy trial keeps its 20-request bulk ceiling until it ends.

Odds history is separate

Bulk carries match data, not price series. /odds?match=<id> is per match and cursor-paginated, so a match with a long series is several requests on its own. If you need the outcome rather than the path, fetch the closing lines: one page per match, taking the rows flagged is_closing. Filter the walk with has=odds so you only visit matches that have a line you can retrieve. The market line starts on 2 July 2026, so no earlier match has one: see coverage by year.

Rate limit vs quota

Bulk aside, you will hit the monthly quota long before the per-second rate limit (20 rps sustained). Handle 429 rate_limited and 503 unavailable by waiting for Retry-After, and stop entirely on quota_exceeded, which retrying cannot fix. See errors and retries.

On this page