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

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: 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 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, 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=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:

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.