How-to

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 work

To 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

WhatIntervalRequests/month
Fixture windowEvery 6 hours~120
Live matchesWebSocket0 — the WebSocket costs no quota
OddsEvery 5 minutes, or WebSocket8,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.

On this page