Build a fixtures cache
Keep a local schedule in sync for a few hundred requests a month.
The fixture list changes about 6 times an hour across the whole corpus. A cache refreshed on a slow timer stays effectively current for a fraction of your allowance.
The shape
Fetch a forward window, upsert by id, and re-read on a slow interval.
import datetime as dt
def refresh_fixtures(db, days_ahead=14, days_back=2):
today = dt.date.today()
params = {
"date_from": (today - dt.timedelta(days=days_back)).isoformat(),
"date_to": (today + dt.timedelta(days=days_ahead)).isoformat(),
"limit": 500,
}
for match in paginate("/v1/cs2/matches", **params):
db.upsert_match(match) # keyed on match["id"]Include a couple of days back: a match that finished after your last refresh needs its final score
and winner_team_id picked up.
What it costs
A 16-day window is a few hundred matches — one or two pages at limit=500. Hourly that's ~720–1,440
requests a month, 1.5 to 3% of Standard's 50,000. Every 6 hours it's under 0.5%.
Compare with polling /matches every 5 minutes: 8,640 requests, about 17% of Standard's 50,000, for a list that
changed six times an hour. Pro's 150,000 makes each of those shares a third as large.
Use the list row as-is
A list row already carries team_a_name, team_b_name, team_a_short, team_b_short and
tournament_name. You don't need to resolve teams separately to render a schedule — that's the main
reason to cache the list shape rather than detail rows.
It also carries data_available, so your cache knows which matches have round or depth data without
asking.
Detect what changed
changed_at moves only when a field you can see on the match changes — status, scores, winner,
times, teams, stage, format. (updated_at is not that: it advances on every refresh pass.) Store
changed_at and you can act only on real changes:
existing = db.get_match(match["id"])
if existing and existing["changed_at"] == match["changed_at"]:
continue # nothing changed; skip downstream workTo skip the unchanged rows entirely, pass your last-seen instant as ?changed_since= and the list
returns only matches that moved since. Since 2026-09-18 it also moves the first time a layer of a
match's data arrives — maps, stats, per-map stats, rounds, depth or lineups — so a finished match
comes back once more, with its data_available flags flipped, when there is something new to fetch.
A later correction to a layer you already hold does not come back; re-read those on your own
schedule.
A match you have stored can also stop resolving: when two records of one fixture are merged, one id
is retired and GET /matches/{id} returns 404. Re-find it by team and date rather than polling it —
see When two ids turn out to be one match.
Live matches need a different cadence
A live match's score changes far faster than the fixture list. Two options:
Poll just the live ones, which is a much smaller query:
live = get("/v1/cs2/matches?status=live&limit=100")["data"]At one call a minute that's 43,200/month, 86% of Standard's 50,000 on its own. Every 5 minutes it's 8,640.
Or open a WebSocket and subscribe to the ids you care about. Score and odds changes are pushed at no request cost: the ticket mint, the connection and every message are all free of quota. For live data this is almost always the right answer — see connect a WebSocket.
Suggested cadences
| What | Interval | Requests/month |
|---|---|---|
| Fixture window | Every 6 hours | ~120 |
| Live matches | WebSocket | 0 — the WebSocket costs no quota |
| Odds | Every 5 minutes, or WebSocket | 8,640 / 0 |
That's a complete integration in under a fifth of Standard's 50,000, or a fraction of a percent with odds on the WebSocket.