Rate limits
A per-key token bucket governs request rates; a hard monthly quota governs volume.
Two independent caps apply to every request:
- A rate limit — a token bucket smooths short-term throughput: 20 requests per second sustained, with a 100-request burst. Applied per API key.
- A monthly quota — the standard plan includes 50,000 requests per calendar month (UTC), enforced as a hard cap: requests past it are rejected until the month resets. Applied per account, shared across all of that account's keys — creating a second key does not grant a second allowance.
They bind independently: you can be well inside your monthly quota and still get a 429 for
bursting, and vice versa.
How the bucket behaves. The bucket holds 100 tokens and refills at 20 per second. Every request
takes one. A burst of up to 100 back-to-back requests goes straight through from a full bucket;
sustained traffic above 20/sec drains it and starts returning 429 until it refills. A short pause
restores capacity — five seconds of silence refills the bucket completely.
In practice the monthly quota binds long before the rate limit does: 20 requests per second is 1.7 million a day, and your monthly allowance is 50,000. Treat the rate limit as a guard against runaway loops, and the quota as the number to actually design around — see how often to poll.
All paid keys see the same endpoints and data — the plan changes throughput and included volume, not features. A sandbox key reads only its sample.
Legacy trials
Subscriptions no longer start with a trial. A trial that had already started runs to its end date with the three limits it began with, counted from the start of the trial rather than per calendar month:
| Limit | Value |
|---|---|
| Total trial requests | 5,000 |
| Any single endpoint | 1,000 |
The bulk match export (/matches/bulk) | 20 |
A request past the total returns 429 with trial_quota_exceeded. A request past one endpoint's
ceiling returns 429 with trial_endpoint_quota_exceeded instead, because the overall allowance
still has requests left — another endpoint keeps working. Both messages link to the plan page, where
subscribing ends the trial immediately, starts the paid period and lifts the account to the full
50,000 with no per-endpoint or bulk ceilings; on the paid plan a bulk request counts as one ordinary
request.
Sandbox keys
A free sandbox key reads only a fixed sample of past matches, with its own limits:
| Limit | Value |
|---|---|
| Rate | 5 requests per second, with a burst of 20 |
| Requests | 2,000 in total, for the life of the key |
A request outside the sample answers 403 with sandbox_scope and is not counted. Once the 2,000
are used, every request answers 429 with sandbox_quota_exceeded. They never come back, so that
429 has no Retry-After, and a sandbox key's responses carry no X-Quota-Reset. A sandbox key
cannot open a WebSocket.
What counts
Every authenticated REST request draws one token and one unit of monthly quota. When your bucket is empty, further requests are rejected until it refills:
// 429 Too Many Requests
{
"error": {
"code": "rate_limited",
"message": "rate limit exceeded",
"request_id": "01a0..."
}
}Back off and retry after a short delay. Spikes are smoothed by the bucket's burst capacity; steady
throughput above your refill rate is what triggers 429s.
When your monthly quota is used up, requests are rejected until the next calendar month starts:
// 429 Too Many Requests
{
"error": {
"code": "quota_exceeded",
"message": "monthly quota exceeded",
"request_id": "01a0..."
}
}How often to poll
Your whole monthly allowance is 50,000 requests across roughly 720 hours — about 69 requests per hour, for everything you do. Budget against that number, not against the per-second rate limit, which you will almost never reach.
The single most useful thing to know is that the fixture list and the odds move at completely different speeds. Measured over the last 7 days of production data:
| What you'd poll | How often it actually changes |
|---|---|
/v1/cs2/matches — fixtures, scores, status | ~5.9 writes/hour |
/v1/cs2/odds — the market line | ~665 writes/hour |
That's a 113× difference, so polling them on the same timer spends half of every poll on an endpoint that hasn't changed. Two 30-day examples:
| Strategy | Requests/month | Share of plan |
|---|---|---|
| Both endpoints every 5 minutes | 17,280 | 35% |
| Odds every 5 minutes, fixtures hourly | 9,360 | 19% |
The second is the same odds freshness for a bit over half the cost, and it leaves four-fifths of the plan for per-match detail calls. The timer that doesn't fit comfortably is a fast one: polling a single endpoint every minute is 43,200 requests a month, 86% of the plan on its own, and two endpoints at that rate would exceed it.
Some ways to spend less:
- Open a WebSocket instead of polling for changes. It costs no request quota at all — not the ticket, not the connection, not the messages — and it pushes score and odds changes for as long as it stays open. For anything change-driven this is not merely cheaper than a timer, it is free. See Live data & WebSocket.
- If you must poll, poll the change feed.
?changed_since=<rfc3339>&sort=changed_atreturns only the matches that actually moved. Page to the end, keep the newestchanged_atyou saw, and send it back next time — overlapping by ten minutes and de-duplicating onid. A quiet hour then costs one request and an empty page. Match history tells you what each of those changed from, for the few ids that moved. - Backfill with
/matches/bulk, never the list endpoint. Walking the archive a match at a time is the most expensive access pattern this API has — one real key spent 6,788 requests in three days that way. See backfilling history. - Poll fixtures on a slow timer — hourly is ample at ~6 writes/hour, and a daily schedule refresh is fine for most uses.
- Filter server-side.
?status=live,?date_from=/?date_to=and?tournament=cost the same one request as an unfiltered call but save you paging through rows you'll discard. - Ask for detail only where it exists. Each match row carries
data_available, and?has=rounds,depthfilters the list to matches that actually have those sub-resources — so you never spend a request discovering there was nothing to fetch.
Response headers
Every metered response tells you where you stand — no need to count client-side:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Your token bucket's capacity — the burst allowance (100 on the standard plan). |
X-RateLimit-Remaining | Whole tokens left in the bucket after this request. |
X-Quota-Limit | Your request quota (50,000/month on the standard plan; 5,000 during a legacy trial; 2,000 in total on a sandbox key). |
X-Quota-Remaining | Requests left in the current window, across every key on the account. |
X-Quota-Reset | When the window ends (RFC 3339, UTC — the start of next month; during a legacy trial, the trial's end). Not sent for a sandbox key, which never resets. |
Every 429 except sandbox_quota_exceeded also carries Retry-After — the number of seconds to
wait before retrying (a second or two for a rate-limit 429; up to the end of the window for a
quota 429).
On a legacy trial, a trial_endpoint_quota_exceeded 429 additionally carries X-Quota-Endpoint-Limit (that
endpoint's trial ceiling) and X-Quota-Endpoint-Remaining (always 0). Those two are sent on that
response only.
Calling from a browser? These are custom headers, so a cross-origin fetch can only read them
because the API names them in Access-Control-Expose-Headers. It does — nine in all: the five
above, X-Request-Id, the two X-Quota-Endpoint-* headers and Retry-After. See CORS and browser clients.
Checking your usage
The headers above are authoritative per request; your dashboard shows the same request volume and remaining allowance for each key. Rate-limit and quota enforcement live in the API itself; the dashboard reads the same usage events the API records per request.
WebSocket connections
The WebSocket channel is metered differently from REST — a persistent connection isn't a stream of discrete requests, so the token-bucket model doesn't map cleanly onto it. It's a tier-gated feature, capped by concurrent connections rather than metered per message:
- Concurrent-connection cap. On the standard plan a key may hold 5 concurrent connections
(the cap is tier-based). Opening one past the cap is refused with an
errorframe (code: "connection_limit") followed by a close — not a queue. - Nothing about the WebSocket consumes request quota. Neither
POST /v1/{game}/ws-tokennor opening the connection draws on your request allowance, and the messages pushed over an open connection are not billed either — an open subscription accrues no per-message or per-ping charges for its lifetime. Tickets are short-lived (60 seconds), so a client that reconnects mints a new one each time; that reconnection is free. The concurrent-connection cap above is the only limit that applies. - The ticket mint is still rate-limited. It draws a token from the per-second bucket like any
other call, so a tight reconnect loop will still see
429s — reconnect with backoff. - Disconnecting frees the slot immediately, including a keep-alive timeout close.
Reuse one connection across many matches (that's what
subscribeis for) rather than opening one per match, and reconnect with backoff rather than in a tight loop.