Building with AI? Give your coding tool the whole reference in one file: https://soccer-data.co/llms.txt
SOCCER DATA Docs
Sign in Get API key
// Core concepts

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 match status and 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-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 429s. 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 /matches for a page past the last one. Stop at meta.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.
  • 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.
  • 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.