Errors
Every error code, what causes it, and which ones are worth retrying.
When a request fails, you get an HTTP status and a JSON body that always has the same shape. This page lists the codes and shows how to handle them.
The error shape
{
"error": {
"code": "NOT_FOUND",
"message": "Match not found",
"status_code": 404
}
}
| Field | What it's for |
|---|---|
code |
A stable, uppercase name for the problem. Use it in your logic. |
message |
A plain-English explanation, often saying exactly what to change. Show it in logs, but don't match on its wording. |
status_code |
The HTTP status again, for convenience. |
Branch on code rather than on the HTTP status alone. Several codes share a status: RATE_LIMITED and USAGE_LIMIT_EXCEEDED are both 429, but need very different handling.
All error codes
| Status | Code | Meaning |
|---|---|---|
| 400 | BAD_REQUEST |
A parameter has the wrong format. |
| 400 | UNKNOWN_PARAMETER |
A query parameter the endpoint doesn't take. |
| 400 | SEASON_COMPETITION_MISMATCH |
The season doesn't belong to that competition. |
| 400 | PAGE_OUT_OF_RANGE |
The page is past the last one. |
| 401 | UNAUTHORIZED |
The key is missing, wrong or revoked. |
| 403 | PLAN_UPGRADE_REQUIRED |
The endpoint needs a higher plan (odds need a paid plan). |
| 404 | NOT_FOUND |
No such endpoint, or no such id. |
| 404 | STATS_NOT_AVAILABLE |
This data isn't covered for that match. |
| 409 | MATCH_IS_LIVE |
Final stats aren't ready while the match is in play; use the live endpoint. |
| 429 | RATE_LIMITED |
Too many requests this minute. Wait for Retry-After seconds. |
| 429 | USAGE_LIMIT_EXCEEDED |
Today's requests are used up. Resets at midnight UTC. |
| 502 | UPSTREAM_UNAVAILABLE |
A data source didn't answer in time. Retry; it isn't counted. |
| 503 | SERVICE_UNAVAILABLE |
Temporarily unavailable. Retry after Retry-After; it isn't counted. |
A few more codes can reach you from specific endpoints:
CONFLICT(409): a live endpoint was called for a match that isn't in play (the message reads "Match is not live"), or a full-match endpoint was called while the match is still live. Check the matchstatusand pick the matching endpoint. Live matches has the details.MATCH_IS_LIVE(409): full match stats were requested during the match. Use/matches/{match_id}/live-statsuntil full time.
What to do about each one
Retry after waiting
These clear up on their own. Wait, then send the same request again.
RATE_LIMITED(429): you've used this minute's requests. Wait the number of seconds in theRetry-Afterheader.SERVICE_UNAVAILABLE(503): something on our side is briefly unavailable. Wait forRetry-After, then try again. It doesn't count against your daily quota.UPSTREAM_UNAVAILABLE(502): a data source didn't answer in time. Try again after a short pause, a few seconds and longer each time. It doesn't count against your daily quota either.
Cap your retries, at three or so, so a lasting problem doesn't turn into an endless loop.
Don't retry
Sending the same request again won't change the answer. Fix the request, the key or the plan first.
USAGE_LIMIT_EXCEEDED(429): today's requests are used up. ItsRetry-Aftercounts the seconds to midnight UTC, when the quota resets, so retrying in a loop only collects more429s. Stop and report it, or upgrade.BAD_REQUEST(400): a parameter is missing or has the wrong format. The message names it, such as "season_id is required".UNKNOWN_PARAMETER(400): the endpoint doesn't take that parameter. The message suggests the closest valid parameter, when one is close, and lists every one the endpoint accepts, for example:This endpoint doesn't take "team". Did you mean "team_id"? It takes: competition_id, date_from, …SEASON_COMPETITION_MISMATCH(400): the season belongs to a different competition. Get the right seasons from/competitions/{competition_id}/seasons.PAGE_OUT_OF_RANGE(400): you asked/matchesfor a page past the last one. Stop atmeta.total_pages.UNAUTHORIZED(401): the key is missing, wrong or revoked. See Authentication.PLAN_UPGRADE_REQUIRED(403): the endpoint isn't in your plan. Odds need a paid plan.STATS_NOT_AVAILABLE(404): detailed stats were never collected for this match, and never will be.CONFLICTandMATCH_IS_LIVE(409): the match is in the wrong state for this endpoint. Switch endpoints instead of retrying the same one.
Not found
NOT_FOUND (404) covers three situations:
- An id we don't recognise. The id has a typo, was cut short, or didn't come from us. Ids are permanent, so a valid id never stops working. Copy ids from our responses, or find them with a
searchrequest. - A path that isn't an endpoint. Check it against the API reference.
- Data that doesn't exist for this request. For example, a heatmap for a player who never came on. Check the availability flags before calling optional endpoints.
In most apps, a 404 for a single item is best handled as "nothing here", not as a crash.
Handling errors in code
This helper turns error responses into exceptions that carry the code, waits and retries when that makes sense, and returns null for a missing match.
class SoccerDataError extends Error {
constructor(status, code, message) {
super(message);
this.status = status;
this.code = code;
}
}
const RETRYABLE = new Set(["RATE_LIMITED", "SERVICE_UNAVAILABLE", "UPSTREAM_UNAVAILABLE"]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function getJson(path, params = {}, attempts = 3) {
const url = `https://api.soccer-data.co/v1${path}?${new URLSearchParams(params)}`;
for (let attempt = 1; ; attempt++) {
const res = await fetch(url, { headers: { "X-Api-Key": process.env.SOCCER_DATA_KEY } });
if (res.ok) return res.json();
const body = await res.json().catch(() => ({}));
const code = body.error?.code ?? "UNKNOWN";
const message = body.error?.message ?? res.statusText;
if (RETRYABLE.has(code) && attempt < attempts) {
const wait = Number(res.headers.get("Retry-After")) || 2 ** attempt;
await sleep(wait * 1000);
continue;
}
throw new SoccerDataError(res.status, code, message);
}
}
async function getMatch(matchId) {
try {
return (await getJson(`/matches/${matchId}`)).data;
} catch (err) {
if (err instanceof SoccerDataError && err.code === "NOT_FOUND") return null;
throw err;
}
}
console.log(await getMatch("match_yoeml0e6ae37"));
import os, time, requests
RETRYABLE = {"RATE_LIMITED", "SERVICE_UNAVAILABLE", "UPSTREAM_UNAVAILABLE"}
class SoccerDataError(Exception):
def __init__(self, status, code, message):
super().__init__(message)
self.status = status
self.code = code
def get_json(path, params=None, attempts=3):
for attempt in range(1, attempts + 1):
res = requests.get(
f"https://api.soccer-data.co/v1{path}",
params=params,
headers={"X-Api-Key": os.environ["SOCCER_DATA_KEY"]},
)
if res.ok:
return res.json()
try:
error = res.json()["error"]
except (ValueError, KeyError):
error = {"code": "UNKNOWN", "message": res.reason}
if error["code"] in RETRYABLE and attempt < attempts:
time.sleep(int(res.headers.get("Retry-After", 2 ** attempt)))
continue
raise SoccerDataError(res.status_code, error["code"], error["message"])
def get_match(match_id):
try:
return get_json(f"/matches/{match_id}")["data"]
except SoccerDataError as err:
if err.code == "NOT_FOUND":
return None
raise
print(get_match("match_yoeml0e6ae37"))
Notice that USAGE_LIMIT_EXCEEDED isn't in the retry list: the helper raises it straight away. Rate limits has more on staying inside your limits.
# Errors
Every error code, what causes it, and which ones are worth retrying.
When a request fails, you get an HTTP status and a JSON body that always has the same shape. This page lists the codes and shows how to handle them.
## The error shape
```json
{
"error": {
"code": "NOT_FOUND",
"message": "Match not found",
"status_code": 404
}
}
```
| Field | What it's for |
|---|---|
| `code` | A stable, uppercase name for the problem. Use it in your logic. |
| `message` | A plain-English explanation, often saying exactly what to change. Show it in logs, but don't match on its wording. |
| `status_code` | The HTTP status again, for convenience. |
Branch on `code` rather than on the HTTP status alone. Several codes share a status: `RATE_LIMITED` and `USAGE_LIMIT_EXCEEDED` are both `429`, but need very different handling.
## All error codes
| Status | Code | Meaning |
|---|---|---|
| 400 | `BAD_REQUEST` | A parameter has the wrong format. |
| 400 | `UNKNOWN_PARAMETER` | A query parameter the endpoint doesn't take. |
| 400 | `SEASON_COMPETITION_MISMATCH` | The season doesn't belong to that competition. |
| 400 | `PAGE_OUT_OF_RANGE` | The page is past the last one. |
| 401 | `UNAUTHORIZED` | The key is missing, wrong or revoked. |
| 403 | `PLAN_UPGRADE_REQUIRED` | The endpoint needs a higher plan (odds need a paid plan). |
| 404 | `NOT_FOUND` | No such endpoint, or no such id. |
| 404 | `STATS_NOT_AVAILABLE` | This data isn't covered for that match. |
| 409 | `MATCH_IS_LIVE` | Final stats aren't ready while the match is in play; use the live endpoint. |
| 429 | `RATE_LIMITED` | Too many requests this minute. Wait for Retry-After seconds. |
| 429 | `USAGE_LIMIT_EXCEEDED` | Today's requests are used up. Resets at midnight UTC. |
| 502 | `UPSTREAM_UNAVAILABLE` | A data source didn't answer in time. Retry; it isn't counted. |
| 503 | `SERVICE_UNAVAILABLE` | Temporarily unavailable. Retry after Retry-After; it isn't counted. |
A few more codes can reach you from specific endpoints:
- **`CONFLICT` (409):** a live endpoint was called for a match that isn't in play (the message reads "Match is not live"), or a full-match endpoint was called while the match is still live. Check the match `status` and pick the matching endpoint. [Live matches](/docs/guides/live-matches) has the details.
- **`MATCH_IS_LIVE` (409):** full match stats were requested during the match. Use [`/matches/{match_id}/live-stats`](/docs/reference/matches/live-stats) until full time.
## What to do about each one
### Retry after waiting
These clear up on their own. Wait, then send the same request again.
- **`RATE_LIMITED` (429):** you've used this minute's requests. Wait the number of seconds in the `Retry-After` header.
- **`SERVICE_UNAVAILABLE` (503):** something on our side is briefly unavailable. Wait for `Retry-After`, then try again. It doesn't count against your daily quota.
- **`UPSTREAM_UNAVAILABLE` (502):** a data source didn't answer in time. Try again after a short pause, a few seconds and longer each time. It doesn't count against your daily quota either.
Cap your retries, at three or so, so a lasting problem doesn't turn into an endless loop.
### Don't retry
Sending the same request again won't change the answer. Fix the request, the key or the plan first.
- **`USAGE_LIMIT_EXCEEDED` (429):** today's requests are used up. Its `Retry-After` counts the seconds to midnight UTC, when the quota resets, so retrying in a loop only collects more `429`s. Stop and report it, or [upgrade](/dashboard/subscription).
- **`BAD_REQUEST` (400):** a parameter is missing or has the wrong format. The message names it, such as "season_id is required".
- **`UNKNOWN_PARAMETER` (400):** the endpoint doesn't take that parameter. The message suggests the closest valid parameter, when one is close, and lists every one the endpoint accepts, for example: `This endpoint doesn't take "team". Did you mean "team_id"? It takes: competition_id, date_from, …`
- **`SEASON_COMPETITION_MISMATCH` (400):** the season belongs to a different competition. Get the right seasons from [`/competitions/{competition_id}/seasons`](/docs/reference/competitions/seasons).
- **`PAGE_OUT_OF_RANGE` (400):** you asked `/matches` for a page past the last one. Stop at `meta.total_pages`.
- **`UNAUTHORIZED` (401):** the key is missing, wrong or revoked. See [Authentication](/docs/authentication).
- **`PLAN_UPGRADE_REQUIRED` (403):** the endpoint isn't in your plan. Odds need a paid plan.
- **`STATS_NOT_AVAILABLE` (404):** detailed stats were never collected for this match, and never will be.
- **`CONFLICT` and `MATCH_IS_LIVE` (409):** the match is in the wrong state for this endpoint. Switch endpoints instead of retrying the same one.
### Not found
`NOT_FOUND` (404) covers three situations:
- **An id we don't recognise.** The id has a typo, was cut short, or didn't come from us. Ids are permanent, so a valid id never stops working. Copy ids from our responses, or find them with a `search` request.
- **A path that isn't an endpoint.** Check it against the [API reference](/docs/reference).
- **Data that doesn't exist for this request.** For example, a heatmap for a player who never came on. Check the [availability flags](/docs/concepts/data-model#availability-flags) before calling optional endpoints.
In most apps, a `404` for a single item is best handled as "nothing here", not as a crash.
## Handling errors in code
This helper turns error responses into exceptions that carry the `code`, waits and retries when that makes sense, and returns `null` for a missing match.
```javascript
class SoccerDataError extends Error {
constructor(status, code, message) {
super(message);
this.status = status;
this.code = code;
}
}
const RETRYABLE = new Set(["RATE_LIMITED", "SERVICE_UNAVAILABLE", "UPSTREAM_UNAVAILABLE"]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function getJson(path, params = {}, attempts = 3) {
const url = `https://api.soccer-data.co/v1${path}?${new URLSearchParams(params)}`;
for (let attempt = 1; ; attempt++) {
const res = await fetch(url, { headers: { "X-Api-Key": process.env.SOCCER_DATA_KEY } });
if (res.ok) return res.json();
const body = await res.json().catch(() => ({}));
const code = body.error?.code ?? "UNKNOWN";
const message = body.error?.message ?? res.statusText;
if (RETRYABLE.has(code) && attempt < attempts) {
const wait = Number(res.headers.get("Retry-After")) || 2 ** attempt;
await sleep(wait * 1000);
continue;
}
throw new SoccerDataError(res.status, code, message);
}
}
async function getMatch(matchId) {
try {
return (await getJson(`/matches/${matchId}`)).data;
} catch (err) {
if (err instanceof SoccerDataError && err.code === "NOT_FOUND") return null;
throw err;
}
}
console.log(await getMatch("match_yoeml0e6ae37"));
```
```python
import os, time, requests
RETRYABLE = {"RATE_LIMITED", "SERVICE_UNAVAILABLE", "UPSTREAM_UNAVAILABLE"}
class SoccerDataError(Exception):
def __init__(self, status, code, message):
super().__init__(message)
self.status = status
self.code = code
def get_json(path, params=None, attempts=3):
for attempt in range(1, attempts + 1):
res = requests.get(
f"https://api.soccer-data.co/v1{path}",
params=params,
headers={"X-Api-Key": os.environ["SOCCER_DATA_KEY"]},
)
if res.ok:
return res.json()
try:
error = res.json()["error"]
except (ValueError, KeyError):
error = {"code": "UNKNOWN", "message": res.reason}
if error["code"] in RETRYABLE and attempt < attempts:
time.sleep(int(res.headers.get("Retry-After", 2 ** attempt)))
continue
raise SoccerDataError(res.status_code, error["code"], error["message"])
def get_match(match_id):
try:
return get_json(f"/matches/{match_id}")["data"]
except SoccerDataError as err:
if err.code == "NOT_FOUND":
return None
raise
print(get_match("match_yoeml0e6ae37"))
```
Notice that `USAGE_LIMIT_EXCEEDED` isn't in the retry list: the helper raises it straight away. [Rate limits](/docs/concepts/rate-limits) has more on staying inside your limits.