Rate limits
Your plan's per-minute and daily limits, the headers that track them, and how caching affects live data.
Each plan comes with two limits: how many requests you can make in a minute, and how many in a day. Every response tells you where you stand, so your code never has to guess.
Plans
| Plan | Requests / day | Per minute | Live data refreshed (normally) | Odds |
|---|---|---|---|---|
| Free | 50 | 10 | 5 min | — |
| Pro | 1,000 | 60 | 15 s | Yes |
| Business | 10,000 | 300 | 15 s | Yes |
Daily limits run per UTC day and reset at midnight UTC. Odds endpoints need a paid plan (Pro or Business). You can see and change your plan on the plans page.
The two limits
Per minute. Stops short bursts from flooding the API. Once you've used your plan's allowance, further requests get 429 RATE_LIMITED until the minute is up.
Per day. Caps your total for the UTC day. Once it's used up, requests get 429 USAGE_LIMIT_EXCEEDED until midnight UTC, unless you upgrade.
Both limits belong to your account, not to a key. All your API keys draw from the same allowance, so creating more keys doesn't add capacity.
Response headers
Every response to a request with a valid key carries these headers, whether it succeeded or not:
| Header | What it tells you |
|---|---|
X-RateLimit-Limit |
Requests allowed per minute on your plan. |
X-RateLimit-Remaining |
Requests left in the current minute. At 0, the next one is refused. |
X-RateLimit-Reset |
When the current minute's window ends, as a Unix timestamp in seconds. |
X-Daily-Quota-Limit |
Requests allowed per UTC day on your plan. |
X-Daily-Quota-Remaining |
Requests left today. |
X-Daily-Quota-Reset |
When the daily quota resets (the next midnight UTC), as a Unix timestamp in seconds. |
X-Cache |
HIT if the answer came from our cache, MISS if it was fetched fresh, STALE if it's an older copy served because a fresh one couldn't be fetched right now. |
X-Cache-Age |
On STALE answers only: how old the copy is, in seconds. |
Retry-After |
Only on 429 and 503: how many seconds to wait before trying again. |
Check them with curl's -i flag:
curl -i "https://api.soccer-data.co/v1/competitions?per_page=1" \
-H "X-Api-Key: YOUR_API_KEY"
const res = await fetch("https://api.soccer-data.co/v1/competitions?per_page=1", {
headers: { "X-Api-Key": process.env.SOCCER_DATA_KEY },
});
console.log("This minute:", res.headers.get("X-RateLimit-Remaining"));
console.log("Today:", res.headers.get("X-Daily-Quota-Remaining"));
Your usage page shows the same numbers over time, broken down by endpoint.
Retry-After
Retry-After means different things depending on the error:
RATE_LIMITED: the seconds until the minute window frees up, at most 60. Wait that long, then retry.USAGE_LIMIT_EXCEEDED: the seconds until midnight UTC, which can be many hours. Don't retry in a loop; stop and tell someone, or upgrade.SERVICE_UNAVAILABLE: a short pause before trying again. These responses don't count against your daily quota.
What counts as a request
A call counts against your daily quota when it reaches the data, whatever the data says back. That includes:
- successful answers, including ones served from our cache (
X-Cache: HITorSTALE); - errors that come from the data itself: a
400for a value it can't use, such as a malformed date, a season from another competition or a page past the last one; a404for an id that doesn't exist or data a match doesn't have; or a409for a match in the wrong state; - each page of a paged list;
- calls made from the API tester in your dashboard.
These don't count against your daily quota:
- our own checks on the request, before it reaches the data:
400 UNKNOWN_PARAMETERfor a parameter the endpoint doesn't take, and400 BAD_REQUESTfor a parameter sent as a list (such asteam_id[]=…) instead of one value; 401for a missing or invalid key;403when an endpoint isn't in your plan;429when you're over a limit;404for a path that isn't an endpoint at all;- server-side failures, such as
502and503; /health, which needs no key at all.
The per-minute limit is simpler: it counts every call you make with a valid key, whatever the outcome. Calls already refused with RATE_LIMITED don't add to it, and neither does /health.
Caching and live data
Data that changes during a match, such as live scores, live stats, the match list and league tables, is cached on our side for a short time. How long depends on your plan:
| Plan | Live answers are normally at most |
|---|---|
| Free | 5 minutes old |
| Pro and Business | 15 seconds old |
If a fresh answer can't be fetched, you may get an older copy instead, marked X-Cache: STALE: see When an answer is stale.
Asking again within that window gives you the same answer, with X-Cache: HIT, and still counts as a request. So polling a live endpoint more often than your plan's refresh only spends requests. Every 15 to 30 seconds is a good pace on a paid plan, and every 5 minutes on Free.
Data that doesn't move during a match, such as competitions, squads and finished-match stats, is cached for longer.
When an answer is stale
If we can't fetch a fresh answer right now, because a data source is slow or down, or we're under heavy load, you get the last good copy instead of an error, with X-Cache: STALE and its age in seconds in X-Cache-Age. A stale copy can be up to 15 minutes old for data that changes during a match, and up to 6 hours old for data that doesn't. For live data, check that age before showing a score as current. A stale answer counts like any other. If there's no copy recent enough to fall back on, you get 503 SERVICE_UNAVAILABLE with a Retry-After header, and it doesn't count.
Spending fewer requests
- Cache on your server. Call the API from your backend and keep the answers. One stored response can serve all your users. This is also the only safe setup, because calling from the browser exposes your key.
- Keep slow-moving data longer. Competitions, seasons, squads and finished matches rarely change. Store them for hours, or for good, rather than fetching them on every page view.
- Poll only while it matters. Follow a live match while it's in play and stop at full time. There are no webhooks, so polling is the way to get updates; just keep it targeted.
- Use big pages and tight filters.
per_page=100plus filters liketeam_id,date_fromanddate_toget more done per request. - Check flags first.
xg_available,shotmap_availableandodds_availablesave you calls that would only return404. - Be careful with AI agents. A coding assistant stuck in a retry loop can use a day's requests in minutes. Make one test call, cache while you build, and ask it to respect
Retry-After.
Staying under the limit
Rather than waiting for a 429, watch X-RateLimit-Remaining. When it gets to 0, pause until the time in X-RateLimit-Reset. And when a 429 does arrive, retry only RATE_LIMITED, and only a few times:
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function request(path, retries = 3) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(`https://api.soccer-data.co/v1${path}`, {
headers: { "X-Api-Key": process.env.SOCCER_DATA_KEY },
});
if (res.ok) return res.json();
const { error } = await res.json();
if (error.code === "RATE_LIMITED" && attempt < retries) {
await sleep(Number(res.headers.get("Retry-After") ?? 1) * 1000);
continue;
}
throw new Error(`${res.status} ${error.code}: ${error.message}`);
}
}
const { data } = await request("/competitions/competition_705uv6jm8myn");
console.log(data.name);
import os, time, requests
def request(path, retries=3):
for attempt in range(retries + 1):
res = requests.get(
f"https://api.soccer-data.co/v1{path}",
headers={"X-Api-Key": os.environ["SOCCER_DATA_KEY"]},
)
if res.ok:
return res.json()
error = res.json()["error"]
if error["code"] == "RATE_LIMITED" and attempt < retries:
time.sleep(int(res.headers.get("Retry-After", 1)))
continue
raise RuntimeError(f'{res.status_code} {error["code"]}: {error["message"]}')
print(request("/competitions/competition_705uv6jm8myn")["data"]["name"])
USAGE_LIMIT_EXCEEDED isn't retried here on purpose: it won't clear until midnight UTC. Errors has a fuller helper that also retries 503.
# Rate limits
Your plan's per-minute and daily limits, the headers that track them, and how caching affects live data.
Each plan comes with two limits: how many requests you can make in a minute, and how many in a day. Every response tells you where you stand, so your code never has to guess.
## Plans
| Plan | Requests / day | Per minute | Live data refreshed (normally) | Odds |
|---|---:|---:|---:|---|
| Free | 50 | 10 | 5 min | — |
| Pro | 1,000 | 60 | 15 s | Yes |
| Business | 10,000 | 300 | 15 s | Yes |
Daily limits run per UTC day and reset at midnight UTC. Odds endpoints need a paid plan (Pro or Business). You can see and change your plan on the [plans page](/dashboard/subscription).
## The two limits
**Per minute.** Stops short bursts from flooding the API. Once you've used your plan's allowance, further requests get `429 RATE_LIMITED` until the minute is up.
**Per day.** Caps your total for the UTC day. Once it's used up, requests get `429 USAGE_LIMIT_EXCEEDED` until midnight UTC, unless you upgrade.
Both limits belong to your account, not to a key. All your API keys draw from the same allowance, so creating more keys doesn't add capacity.
## Response headers
Every response to a request with a valid key carries these headers, whether it succeeded or not:
| Header | What it tells you |
|---|---|
| `X-RateLimit-Limit` | Requests allowed per minute on your plan. |
| `X-RateLimit-Remaining` | Requests left in the current minute. At `0`, the next one is refused. |
| `X-RateLimit-Reset` | When the current minute's window ends, as a Unix timestamp in seconds. |
| `X-Daily-Quota-Limit` | Requests allowed per UTC day on your plan. |
| `X-Daily-Quota-Remaining` | Requests left today. |
| `X-Daily-Quota-Reset` | When the daily quota resets (the next midnight UTC), as a Unix timestamp in seconds. |
| `X-Cache` | `HIT` if the answer came from our cache, `MISS` if it was fetched fresh, `STALE` if it's an older copy served because a fresh one couldn't be fetched right now. |
| `X-Cache-Age` | On `STALE` answers only: how old the copy is, in seconds. |
| `Retry-After` | Only on `429` and `503`: how many seconds to wait before trying again. |
Check them with curl's `-i` flag:
```bash
curl -i "https://api.soccer-data.co/v1/competitions?per_page=1" \
-H "X-Api-Key: YOUR_API_KEY"
```
```javascript
const res = await fetch("https://api.soccer-data.co/v1/competitions?per_page=1", {
headers: { "X-Api-Key": process.env.SOCCER_DATA_KEY },
});
console.log("This minute:", res.headers.get("X-RateLimit-Remaining"));
console.log("Today:", res.headers.get("X-Daily-Quota-Remaining"));
```
Your [usage page](/dashboard/usage) shows the same numbers over time, broken down by endpoint.
## Retry-After
`Retry-After` means different things depending on the error:
- **`RATE_LIMITED`:** the seconds until the minute window frees up, at most 60. Wait that long, then retry.
- **`USAGE_LIMIT_EXCEEDED`:** the seconds until midnight UTC, which can be many hours. Don't retry in a loop; stop and tell someone, or upgrade.
- **`SERVICE_UNAVAILABLE`:** a short pause before trying again. These responses don't count against your daily quota.
## What counts as a request
A call counts against your daily quota when it reaches the data, whatever the data says back. That includes:
- successful answers, including ones served from our cache (`X-Cache: HIT` or `STALE`);
- errors that come from the data itself: a `400` for a value it can't use, such as a malformed date, a season from another competition or a page past the last one; a `404` for an id that doesn't exist or data a match doesn't have; or a `409` for a match in the wrong state;
- each page of a paged list;
- calls made from the [API tester](/dashboard/tester) in your dashboard.
These don't count against your daily quota:
- our own checks on the request, before it reaches the data: `400 UNKNOWN_PARAMETER` for a parameter the endpoint doesn't take, and `400 BAD_REQUEST` for a parameter sent as a list (such as `team_id[]=…`) instead of one value;
- `401` for a missing or invalid key;
- `403` when an endpoint isn't in your plan;
- `429` when you're over a limit;
- `404` for a path that isn't an endpoint at all;
- server-side failures, such as `502` and `503`;
- [`/health`](/docs/reference/health), which needs no key at all.
The per-minute limit is simpler: it counts every call you make with a valid key, whatever the outcome. Calls already refused with `RATE_LIMITED` don't add to it, and neither does `/health`.
## Caching and live data
Data that changes during a match, such as live scores, live stats, the match list and league tables, is cached on our side for a short time. How long depends on your plan:
| Plan | Live answers are normally at most |
|---|---|
| Free | 5 minutes old |
| Pro and Business | 15 seconds old |
If a fresh answer can't be fetched, you may get an older copy instead, marked `X-Cache: STALE`: see [When an answer is stale](#when-an-answer-is-stale).
Asking again within that window gives you the same answer, with `X-Cache: HIT`, and still counts as a request. So polling a live endpoint more often than your plan's refresh only spends requests. Every 15 to 30 seconds is a good pace on a paid plan, and every 5 minutes on Free.
Data that doesn't move during a match, such as competitions, squads and finished-match stats, is cached for longer.
### When an answer is stale
If we can't fetch a fresh answer right now, because a data source is slow or down, or we're under heavy load, you get the last good copy instead of an error, with `X-Cache: STALE` and its age in seconds in `X-Cache-Age`. A stale copy can be up to 15 minutes old for data that changes during a match, and up to 6 hours old for data that doesn't. For live data, check that age before showing a score as current. A stale answer counts like any other. If there's no copy recent enough to fall back on, you get `503 SERVICE_UNAVAILABLE` with a `Retry-After` header, and it doesn't count.
## Spending fewer requests
- **Cache on your server.** Call the API from your backend and keep the answers. One stored response can serve all your users. This is also the only safe setup, because calling from the browser exposes your key.
- **Keep slow-moving data longer.** Competitions, seasons, squads and finished matches rarely change. Store them for hours, or for good, rather than fetching them on every page view.
- **Poll only while it matters.** Follow a live match while it's in play and stop at full time. There are no webhooks, so polling is the way to get updates; just keep it targeted.
- **Use big pages and tight filters.** `per_page=100` plus filters like `team_id`, `date_from` and `date_to` get more done per request.
- **Check flags first.** `xg_available`, `shotmap_available` and `odds_available` save you calls that would only return `404`.
- **Be careful with AI agents.** A coding assistant stuck in a retry loop can use a day's requests in minutes. Make one test call, cache while you build, and ask it to respect `Retry-After`.
## Staying under the limit
Rather than waiting for a `429`, watch `X-RateLimit-Remaining`. When it gets to `0`, pause until the time in `X-RateLimit-Reset`. And when a `429` does arrive, retry only `RATE_LIMITED`, and only a few times:
```javascript
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function request(path, retries = 3) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(`https://api.soccer-data.co/v1${path}`, {
headers: { "X-Api-Key": process.env.SOCCER_DATA_KEY },
});
if (res.ok) return res.json();
const { error } = await res.json();
if (error.code === "RATE_LIMITED" && attempt < retries) {
await sleep(Number(res.headers.get("Retry-After") ?? 1) * 1000);
continue;
}
throw new Error(`${res.status} ${error.code}: ${error.message}`);
}
}
const { data } = await request("/competitions/competition_705uv6jm8myn");
console.log(data.name);
```
```python
import os, time, requests
def request(path, retries=3):
for attempt in range(retries + 1):
res = requests.get(
f"https://api.soccer-data.co/v1{path}",
headers={"X-Api-Key": os.environ["SOCCER_DATA_KEY"]},
)
if res.ok:
return res.json()
error = res.json()["error"]
if error["code"] == "RATE_LIMITED" and attempt < retries:
time.sleep(int(res.headers.get("Retry-After", 1)))
continue
raise RuntimeError(f'{res.status_code} {error["code"]}: {error["message"]}')
print(request("/competitions/competition_705uv6jm8myn")["data"]["name"])
```
`USAGE_LIMIT_EXCEEDED` isn't retried here on purpose: it won't clear until midnight UTC. [Errors](/docs/concepts/errors) has a fuller helper that also retries `503`.