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