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.
Quickstart
- Create a free account and confirm your email.
- Create a key in the console. It is shown once — copy it then.
- Send it in the
X-API-Keyheader:
The answer, trimmed — a real response from the same API:
{
"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.
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.
| Plan | Per day | Per minute | Price |
|---|---|---|---|
| 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:
| Header | Meaning |
|---|---|
| X-Quota-Limit | Your daily quota. |
| X-Quota-Remaining | Requests left today. |
| X-Quota-Reset | Seconds until the quota resets at 00:00 UTC. |
| X-Plan | The plan the key resolved to: explorer (Free), developer or business. |
| X-RateLimit-Limit | Your per-minute limit. |
| X-RateLimit-Remaining | Requests left this minute. |
| Retry-After | On 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.
{
"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"
}
}
| Code | HTTP | Meaning |
|---|---|---|
| key_required | 401 | No key was sent — or a Bearer token not shaped like one, which counts as none. Every route but GET / needs a key. |
| invalid_key | 401 | The key is unknown or revoked, or what was sent in X-API-Key is not a key. |
| daily_quota_exceeded | 429 | Today's quota is used up. Retry-After counts down to 00:00 UTC. |
| rate_limited | 429 | Over your per-minute limit. Wait Retry-After seconds. |
| not_found | 404 | No competition, team or match with that id — or no such route. |
| read_only | 405 | A method other than GET or HEAD. The API is read-only. |
| invalid_status | 422 | status is not one of finished, scheduled, unknown. |
| invalid_parameter | 422 | A 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_deep | 422 | The page is past the deepest one served. Narrow the query with a filter or a date range instead of paging further. |
| stats_unavailable | 503 | /stats while its figures are being recomputed. Retry in a few minutes. |
| query_timeout | 503 | The 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.
Index: service name, version and the route list. No key needed.
Liveness, the match count, and when the archive last synced.
Coverage: matches, competitions, teams, countries and the kickoff range.
Every country with its competition and match counts.
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.
One competition.
Calendar years covered, with a match count for each.
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.
One team.
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.
One match.
Parameters
| Name | Type | Notes |
|---|---|---|
| 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.