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
includeyou leave out is absent and costs nothing to build.depthis by far the largest layer — about three quarters of a full page. - Send
Accept-Encoding: gzip(--compressedin curl; on by default inrequests,fetchand Go'shttp.Client). A full page is around 7 MB of JSON and about a tenth of that on the wire. meta.max_limitis 25. A largerlimitis clamped, and the meta tells you so.meta.includesechoes what was understood. A typo ininclude=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_availablesays 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:
returnCheckpoint 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:
returnchanged_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:
- Overlap by ten minutes and de-duplicate on
id. Ingestion commits in batches, so a row can become visible a few minutes after thechanged_atit carries. Resuming from the exact last value can skip it; an overlap cannot. - 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 moveschanged_at— so the match comes back to you with itsdata_availableflags 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. - Cancellations are included. The default match list hides cancelled and long-overdue
fixtures; a
changed_sincerequest does not, because a cancellation is exactly the change you need to hear about. Expectstatus: "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
| Job | Requests |
|---|---|
| The full completed-match archive, every layer | about 1,000 |
| A nightly re-pull of the last two days | 3–4 |
A changed_since sync every 15 minutes | about 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.