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 5% of one month.

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

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.

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

During a free trial the bulk endpoint has its own ceiling of 20 requests — a 500-match sample, enough to judge the shape and depth of the data before you pay. The ceiling goes away the moment you subscribe. See rate limits.

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.

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