Reference

Football API documentation

A read-only REST API for football (soccer) results. Every route is a GET, every answer is JSON, and every request carries your API key — except the index at GET /, which tells you where to get one.

Base URLhttps://admin.thefootballsignal.com/football-scores/v1

Quickstart

  1. Create a free account and confirm your email.
  2. Create a key in the console. It is shown once — copy it then.
  3. Send it in the X-API-Key header:
curl "https://admin.thefootballsignal.com/football-scores/v1/competitions/england-england-premier/matches?per_page=5" \
-H "X-API-Key: gs_live_xxx"
// Node 18+, Deno, Bun or any server runtime with fetch.
// GOALSCHEMA_KEY holds your gs_live_… key. Keep it server-side.
const res = await fetch(
"https://admin.thefootballsignal.com/football-scores/v1/competitions/england-england-premier/matches?per_page=5",
{ headers: { "X-API-Key": process.env.GOALSCHEMA_KEY } }
);
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error.code}`);
console.log(body.meta.total, body.data[0]);
import os
import requests
# GOALSCHEMA_KEY holds your gs_live_… key.
r = requests.get(
"https://admin.thefootballsignal.com/football-scores/v1/competitions/england-england-premier/matches",
params={"per_page": 5},
headers={"X-API-Key": os.environ["GOALSCHEMA_KEY"]},
timeout=30,
)
body = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {body['error']['code']}")
print(body["meta"]["total"], body["data"][0])

The answer, trimmed — a real response from the same API:

GET/matches?competition=england-england-premier&status=finished&per_page=2200 OK
{
    "data": [
        {
            "id": 3821517,
            "kickoff": "2026-11-01T16:30:00+00:00",
            "status": "finished",
            "competition": {
                "id": "england-england-premier",
                "name": "England Premier",
                "country": "England"
            },
            "home_team": {
                "id": "liverpool",
                "name": "Liverpool"
            },
            "away_team": {
                "id": "arsenal",
                "name": "Arsenal"
            },
            "score": {
                "halftime": {
                    "home": 0,
                    "away": 1
                },
                "fulltime": {
                    "home": 0,
                    "away": 1
                }
            }
        },
        "… 1 more"
    ],
    "meta": {
        "api": "archive",
        "version": "v1",
        "generated_at": "2026-11-02T10:52:02+00:00",
        "page": 1,
        "per_page": 2,
        "total": 5786,
        "last_page": 2893
    }
}

Authentication

Send your key in either header; use one.

X-API-Key: gs_live_xxx
Authorization: Bearer gs_live_xxx

A key is gs_live_ followed by 32 hex characters (gs_test_ for a test key). It is not accepted in the query string, so it never lands in a URL, a log line or a browser history.

Every route needs a key except GET /. A request without one answers 401 key_required; a key that is unknown or revoked answers 401 invalid_key — never a silent downgrade to some lesser tier.

The two headers differ in one way. X-API-Key is always read as a key, so a mistyped one answers invalid_key. A Bearer token is only read as a key when it has the gs_live_/gs_test_ shape, because other schemes use that header too; anything else there counts as no key and answers key_required. If a key you did send comes back as key_required, send it as X-API-Key to see why.

Call from your server. A key in browser JavaScript or in a mobile app bundle is a key anyone can read and spend, and the API does not open cross-origin requests to the key headers. Proxy through your own backend.

Get a free API key


Quotas and rate limits

Two limits apply to every key: requests per day, and requests per minute. Both count per account, not per key — a second key shares the first one's allowance. The day resets at 00:00 UTC.

PlanPer dayPer minutePrice
Free 1,000 60 $0 free key, with an account
Developer 25,000 300 $29 per month
Business agreed agreed Custom agreed by email

Every keyed response says where you stand:

HeaderMeaning
X-Quota-LimitYour daily quota.
X-Quota-RemainingRequests left today.
X-Quota-ResetSeconds until the quota resets at 00:00 UTC.
X-PlanThe plan the key resolved to: explorer (Free), developer or business.
X-RateLimit-LimitYour per-minute limit.
X-RateLimit-RemainingRequests left this minute.
Retry-AfterOn a 429 only: seconds to wait before trying again.

Responses carry Cache-Control: private, max-age=60 — private, because the quota headers on them are yours. Caching on your side is the cheapest way to stay inside a quota: past the correction window, a finished match does not change.


Errors

An error is the same envelope with error in place of data. error.code is stable — branch on it. error.message is for a human and may change.

401/matches — no key sent
{
    "error": {
        "code": "key_required",
        "message": "An API key is required. Get a free key at https://goalschema.com/console/register"
    },
    "meta": {
        "api": "archive",
        "version": "v1",
        "generated_at": "2026-10-19T12:00:00+00:00"
    }
}
CodeHTTPMeaning
key_required401No key was sent — or a Bearer token not shaped like one, which counts as none. Every route but GET / needs a key.
invalid_key401The key is unknown or revoked, or what was sent in X-API-Key is not a key.
daily_quota_exceeded429Today's quota is used up. Retry-After counts down to 00:00 UTC.
rate_limited429Over your per-minute limit. Wait Retry-After seconds.
not_found404No competition, team or match with that id — or no such route.
read_only405A method other than GET or HEAD. The API is read-only.
invalid_status422status is not one of finished, scheduled, unknown.
invalid_parameter422A parameter sent as a list (q[]=…), or a date, from or to that is not YYYY-MM-DD or ISO 8601. The message names the parameter.
page_too_deep422The page is past the deepest one served. Narrow the query with a filter or a date range instead of paging further.
stats_unavailable503/stats while its figures are being recomputed. Retry in a few minutes.
query_timeout503The query ran too long and was stopped. Narrow it — a competition, a team, a date range — and retry.

Every error, the 405 included, comes in this envelope, so you can always branch on error.code.


Pagination

Every list takes page and per_page — not limit and offset. per_page runs 1–200 with a default of 50; a larger value is reduced to 200 rather than rejected. meta says where you are:

{
    "meta": {
        "api": "archive",
        "version": "v1",
        "generated_at": "2026-10-19T12:00:00+00:00",
        "page": 1,
        "per_page": 50,
        "total": 5849,
        "last_page": 117
    }
}

On large lists total and last_page may come from a count cached for a short while, so they can trail the newest rows. Lists are ordered newest kickoff first (matches) or most-covered first (competitions, teams). To walk a long history, filter by competition, team or date range rather than paging deep — a very deep page answers 422 page_too_deep.


Times are UTC

Every kickoff is ISO-8601 in UTC (2026-10-18T15:00:00+00:00), and so is meta.generated_at. Date filters are read as UTC too: date=2026-10-18 is that UTC calendar day, from and to take a date or a full ISO-8601 time. A bare to date means 00:00 UTC that day — pass the next day, or use date, to include a whole day. Convert to local time at display, not before.


Conventions

Every successful response is { data, meta }. data is an object for a single item and a list for a collection; meta carries api, version and generated_at, plus the pagination on a list.

Ids. A match id is an integer, and it never changes. Competition and team ids are slugs derived from their names; if one has to change, it is announced in the changelog first. Resolve them from /competitions and /teams rather than composing them. Nulls. A missing value is null — never 0 and never an empty string standing in for one. Unknown filters are ignored; a date that cannot be read answers 422 invalid_parameter.


Versioning

The version is in the path, /v1, and in meta.version. Within v1, changes are additive: a new endpoint, a new parameter, a new field on an object. Write your client to ignore keys it does not know, and it keeps working when one lands.

Anything that would break an integration — a field removed or renamed, a value that changes shape, a competition or team id that has to change — is announced in the changelog first. The terms are the binding text on changes to the service.


Meta

What the archive holds, how far back it goes, and how fresh it is. GET / is the one route that answers without a key.

GET
/

Index: service name, version and the route list. No key needed.

GET
/health

Liveness, the match count, and when the archive last synced.

GET
/stats

Coverage: matches, competitions, teams, countries and the kickoff range.

GET
/countries

Every country with its competition and match counts.

GET
/docs

A plain-text reference, as Markdown.

Competitions

Ids are slugs — england-england-premier — because the source carries names and no ids. A slug is its source country followed by its source name, which usually starts with the country again, so the doubled word is correct. They are built from names, so a rename at the source can change one; a change is announced in the changelog first. Resolve them from /competitions rather than composing them.

GET
/competitions

All competitions, most-covered first.

GET
/competitions/{id}

One competition.

GET
/competitions/{id}/seasons

Calendar years covered, with a match count for each.

GET
/competitions/{id}/matches

Matches in one competition, newest first.

Teams

A team's country is inferred from the competitions it plays in, since the source gives teams no country of their own. Right for a club, deliberately approximate for a national side.

GET
/teams

All teams, most-covered first.

GET
/teams/{id}

One team.

GET
/teams/{id}/matches

Every match a team played, newest first.

Matches

One object per match, the same from every endpoint that returns one. Half-time and full-time scores on effectively every finished row; a fixture that has not been played yet is scheduled with null scores.

GET
/matches

Search matches across the archive, newest first.

GET
/matches/{id}

One match.


Parameters

NameTypeNotes
page int Page number. Default 1. A very deep page answers 422 page_too_deep — filter instead of paging through everything.
per_page int Page size, 1–200. Default 50. A larger value is reduced to 200, not rejected.
country string Exact country name, as the archive spells it. See /countries.
scope enum domestic · continental · international · friendly. Only domestic competitions have a country.
gender enum men or women.
age_group string U17 … U23. Omit for senior football.
q string Case-insensitive partial match on the name.
date date One UTC calendar day of kickoffs, YYYY-MM-DD.
from date Earliest kickoff, inclusive. A date or an ISO-8601 time, read as UTC.
to date Latest kickoff, inclusive. A bare date is 00:00 UTC that day, so pass the next day (or use date) to include a whole day.
competition string Competition id (slug).
team string Team id (slug). Matches where the team played home or away.
status enum finished · scheduled · unknown.
venue enum home or away. Omit for both.

Corrections

A result can still change for several days after kickoff: a score filled in late or corrected, a status that moves on, a kickoff that moves. The sync keeps re-reading the last 7 days of kickoffs from its third-party sources, and whatever they changed in that time changes here too.

If you keep a copy, re-read the last 8 days on each of your own syncs — from set eight days back (the sync's 7, plus one, so a correction made just after your last sync is not missed), narrowed by competition or team if you only keep some — rather than fetching only the matches that are new to you. Replace your copy of each match you get back; do not merge into it.

Coverage, honestly

This is a results archive compiled from third-party sources and updated several times a day: a finished match usually appears within a few hours of full time. It is run best-effort, with no SLA: a match can be late, missing or wrong.

Depth is uneven. Top flights run back to 2011 with a score on effectively every row; the long tail of youth tournaments and friendlies is thin. Seasons are calendar years, because the source has no season model we can stand behind. Check the coverage table before you build against a competition.

Get a free API key Open the playground