The wire, documented.
Live and historical football data for your applications. One REST API, predictable JSON, clear rate-limit headers โ and a sandbox on every endpoint. New integrations should start on v2, the current, complete API.
01Quickstart
Grab a free key, then your first call is one line. Native v2 fixtures, with cursor pagination โ the same call in cURL, JavaScript or Python:
$ curl -G "https://api.kickoffapi.com/api/v2/fixtures" \ -d "limit=10" \ -H "x-api-key: YOUR_KEY" # 200 OK ยท Unified Envelope { "data": [ โฆ ], "meta": { "count": 10, "cursor": 0, "nextCursor": "10", "source": "canonical" } }
// curl -O https://docs.kickoffapi.com/sdk/javascript/kickoffapi.js import { KickoffAPI } from "./kickoffapi.js"; const api = new KickoffAPI("YOUR_API_KEY"); // v2 by default: the unified { data, meta } envelope, native ids. const { data, meta } = await api.fixtures({ limit: 10 }); console.log(data.length, meta.nextCursor); // In-play right now โ same envelope, same client. const live = await api.liveScores();
# curl -O https://docs.kickoffapi.com/sdk/python/kickoffapi.py (needs: pip install requests)from kickoffapi import KickoffAPI api = KickoffAPI("YOUR_API_KEY") # v2 by default: the unified {"data": โฆ, "meta": {โฆ}} envelope, native ids. page = api.fixtures(limit=10) print(len(page["data"]), page["meta"]["nextCursor"]) # In-play right now โ same envelope, same client. live = api.live_scores()
With no filter at all the window starts at 00:00 UTC today and runs forward, so that call returns what is next. One finished row looks like this โ status.short is the code to branch on (NS, 1H, HT, 2H, FT, AET, PEN, PST, CANC), and every row that names its own store says so in source โ meta.source summarises the whole page as owned, canonical or mixed:
{
"id": "fx_99Xy",
"date": "2026-06-27T16:30:00.000Z",
"status": { "long": "FT", "short": "FT", "elapsed": null },
"league": { "id": "lg_8K3mZ0pQ", "name": "Premier League", "season": 2026 },
"home": { "id": "tm_a1B2c3", "name": "Arsenal", "logo": "https://cdn.kickoffapi.com/images/logos/42.png" },
"away": { "id": "tm_d4E5f6", "name": "Chelsea", "logo": "https://cdn.kickoffapi.com/images/logos/49.png" },
"score": { "home": 2, "away": 1, "halftime": { "home": 1, "away": 0 } },
"source": "owned"
}
Finding a league id
Anywhere a competition is named you can send the native id or the integer id v1 used, so looking one up is optional. When you do want the ids in your own database, it is one call — filter the catalogue, then use the id it returns:
$ curl -G "https://api.kickoffapi.com/api/v2/leagues" \ -d "country=England" -d "search=Premier" \ -H "x-api-key: YOUR_KEY" # 200 OK ยท the id to keep is the native one { "data": [ { "id": "lg_8K3mZ0pQ", "name": "Premier League", "country": "England", โฆ } ], "meta": { "count": 1, "cursor": 0, "nextCursor": null } }
That id goes straight into the next call — and so does the integer, which is what v1 used:
$ curl -G "https://api.kickoffapi.com/api/v2/standings" \ -d "league=lg_8K3mZ0pQ" -d "season=2025" \ -H "x-api-key: YOUR_KEY" # ?league=39 is the same call. An id v2 has never seen is not # an error either: 200 with an empty data array, never a 404.
Legacy client? The v1 reference answers in the { get, results, response } envelope — flat rows by default — and stays live until it sunsets on 01 January 2027. Still on the nested response format? GET /api/v1/fixtures?format=nested asks for it โ see the v1 response format.
04Pagination
Every list endpoint takes limit and cursor and answers with meta. The defaults are 200 rows per page, up to 2000.
| Parameter | Type | Default | Meaning |
|---|---|---|---|
limit | integer | 200 | Rows per page, max 2000 |
cursor | integer | 0 | Offset to start from โ pass back meta.nextCursor |
count | integer | โ | /api/v2/fixtures only: return at most this many rows (max 50) without moving the page size nextCursor is computed from |
meta.nextCursor is null unless the page came back exactly full. A full final page still reports a cursor, so stop when data comes back empty rather than trusting the cursor alone. Both id forms are accepted wherever a league or team is named โ league=39 and league=lg_8K3mZ0pQ resolve to the same competition, so there is no id-mapping step to write. An unknown league answers 200 with an empty list, not a 404.
let cursor = null; do { const url = new URL("https://api.kickoffapi.com/api/v2/fixtures"); url.searchParams.set("league", "39"); // lg_โฆ works too url.searchParams.set("season", "2025"); url.searchParams.set("limit", "200"); if (cursor) url.searchParams.set("cursor", cursor); const { data, meta } = await (await fetch(url, { headers: { "x-api-key": KEY } })).json(); handle(data); cursor = meta.nextCursor; } while (data.length > 0 && cursor); // an empty page ends it
02Authentication
Every request needs your API key in the x-api-key header. Keys map to your plan and its limits โ keep them server-side, never in client code, and rotate them anytime from your dashboard. (The one documented exception is a widget embed, which needs the key in page source and therefore only works with a key restricted to your own origin.)
x-api-key: ko_live_7f3aโฆe91c4b20d8
03Limits & plans
Two windows are enforced per API key: a per-minute one and a per-plan, per-day one. Both are reported in headers on every authenticated response, and each header says which window it describes.
What each plan gets
| Plan | Requests / day | Requests / minute | Live stream & webhooks |
|---|---|---|---|
| Free (Hobby) โ $0 | 100 | 30 | No |
| Starter โ $9/mo | 7,500 | 120 | No |
| Pro โ $29/mo | 75,000 | 300 | No |
| Ultra โ $49/mo | 250,000 | 600 | Yes |
| Enterprise | On request, with an SLA | On request | Yes |
The daily allowance resets at 00:00 UTC. It is a soft ceiling on a paid plan: past it, requests keep being served by drawing down prepaid credits, until the credits run out. There is also a monthly ceiling โ GET /api/v2/account/status reports both today's and this month's usage against their limits. Annual billing is two months free and every paid plan starts with a 7-day trial.
Rate-limit headers
| Header | Window | Meaning |
|---|---|---|
X-RateLimit-Limit | Per minute | Requests allowed per minute on this key |
X-RateLimit-Remaining | Per minute | Requests left in the current minute (resets in โค 60s) |
X-RateLimit-Reset | Per minute | Unix seconds when the minute window resets |
X-RateLimit-Daily-Limit | Per day | The daily allowance from your plan |
X-RateLimit-Daily-Remaining | Per day | Requests left today, in UTC |
X-RateLimit-Daily-Reset | Per day | Unix seconds of the next UTC midnight |
Retry-After | On every 429 | Seconds to wait before retrying โ to the next UTC midnight on a daily wall, to the 1st on a monthly one |
X-Request-Id | โ | Attach this when contacting support |
The three unprefixed headers are the per-minute window; the three -Daily- ones are the daily allowance. A browser can only read headers the server exposes, and all seven are in the CORS exposedHeaders list, so a dashboard on another origin sees all of them.
Errors, and what to do about them
Every error body is an object with an error string, and every 401, 403 and 429 adds a machine-readable code โ branch on that, not on the message. The three 401s below are the three different mistakes a key can be, and each one says which.
| Status | code | What it means | How to fix it |
|---|---|---|---|
| 401 | MISSING_API_KEY | No x-api-key header on the request. The message names the path that wanted one โ { "error": "Missing x-api-key header for path: /api/v2/fixtures", "code": "MISSING_API_KEY" } | Send the header on every call. /api/v2/stream is the one route that also accepts ?key=, because a browser EventSource cannot set headers |
| 401 | INVALID_API_KEY | The key is not one we hold โ a typo, a truncated paste, or a key deleted in the portal. { "error": "Invalid API key", "code": "INVALID_API_KEY" } | Re-copy the key from the portal and check for a stray space or a clipped tail. Nothing else about the key is revealed, deliberately |
| 401 | API_KEY_DISABLED | The key is real and switched off โ by you or by us. { "error": "This API key is disabled", "code": "API_KEY_DISABLED" } | Switch it back on in the portal, or rotate it. A key suspended by an administrator can only be re-enabled by one: the portal answers 403 disabled_by_admin |
| 403 | predictive_locked | A free key called /api/v2/insights/* or /api/v2/fixtures/{id}/projection โ the in-house models are a paid capability | Upgrade, or use the free surface: scores, fixtures, standings, leaderboards are never gated |
| 403 | PUSH_REQUIRES_ULTRA | The live stream is included with Ultra and Enterprise | Poll /api/v2/fixtures?live=all instead, or move up a plan |
| 429 | RATE_LIMIT_PER_MINUTE | More requests in one minute than the plan allows | Sleep for Retry-After seconds (never more than 60) and retry. No daily quota was spent |
| 429 | PLAN_LIMIT_REACHED | The daily or monthly allowance for the plan is used up โ the message names which | Wait for Retry-After (it names the wall that fired, so the 1st when the body says Monthly), top up credits, or upgrade โ suggestedPlan in the body is the plan sized to the traffic you are actually sending |
| 429 | NO_ACTIVE_SUBSCRIPTION | No active subscription was found, so the key is being served on the free tier and has hit it | Renew the subscription. The key keeps working; a subscription that ended within the last few days is still served, with an x-ko-subscription-grace: true header |
| 429 | OVERAGE_CAP_REACHED | Past the plan allowance and past the overage cap above it (2ร the allowance on a paid plan, 5,000 requests on free) | Upgrade, or top up credits to clear the wall |
| 429 | CREDITS_EXHAUSTEDFREE_ALLOWANCE_AND_CREDITS_EXHAUSTED | Past the allowance with no prepaid credits left | Buy credits at the topUp URL in the body, or subscribe โ a plan costs less per request than credits |
Retry correctly
const res = await fetch(url, { headers: { "x-api-key": KEY } }); if (res.status === 429) { // Retry-After is on EVERY 429: โค60 for the per-minute window, // up to 86,400 for a daily wall, to the 1st for a monthly one. const seconds = Number(res.headers.get("Retry-After") || 60); await new Promise((r) => setTimeout(r, seconds * 1000)); }
Also 400 for a missing or malformed query parameter, 404 for an unknown id, 422 when a model cannot be built yet from too little history, and 500 for a server error โ retry 500 with exponential backoff and quote the X-Request-Id.
05Real-time (Ultra & Enterprise)
Polling /api/v2/fixtures?live=all works on every plan. Ultra and Enterprise can also be pushed: a live Server-Sent Events stream you hold open, or signed webhooks KickoffAPI POSTs for you. Both carry the identical event object, so one handler can serve both.
| Surface | Use it when | Plan |
|---|---|---|
GET /api/v2/stream | Events must reach you within a second or two, and you can hold a connection open | Ultra, Enterprise |
POST /api/webhooks | Your consumer is a queue, a serverless function, or anything that should not hold a socket | Ultra, Enterprise |
Event types: kickoff, goal, goal_cancelled, card, substitution, half_time, second_half, full_time, score_correction, odds_change.
$ curl -N -H "x-api-key: YOUR_KEY" \ "https://api.kickoffapi.com/api/v2/stream?leagues=39&events=goal,card,full_time" # -N stops curl buffering it. Optional filters: # ?leagues=39,140 ?teams=57,33 ?events=goal,card # A browser EventSource cannot set headers, so this route also # accepts ?key=YOUR_KEY โ the only route that does. # Reconnect with Last-Event-ID to replay what you missed. event: goal id: 1780000042123-0 data: {"id":"ev_1780000042123-0","type":"goal", โฆ} : ping
A : ping comment every 15 seconds keeps the connection alive; ignore it. Note the two ids: the frame's id: line (1780000042123-0) is what you resume from with Last-Event-ID, and the id inside the event object is the same id prefixed ev_ โ that is the one to deduplicate on. 20 concurrent streams on Ultra, 100 on Enterprise โ more is 429 STREAM_CONNECTIONS_LIMIT. Any other plan gets 403 PUSH_REQUIRES_ULTRA and should poll ?live=all instead. Webhook deliveries carry KickoffAPI-Signature: t=โฆ,v1=โฆ (HMAC-SHA256 of {t}.{body}) and are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours.
Full reference, including the EventSource example and the webhook verifier: Live stream & webhooks.
06API reference
Full endpoint-by-endpoint documentation, with live "try it" curl generation, lives on its own page per version. Both OpenAPI specs are served from this host as well โ openapi-v2.yaml and openapi.yaml โ so a codegen run can point straight at either.
Native ids, { data, meta } envelope, xG models, Dixon-Coles projections, AI previews.
Flat rows by default, { get, results, response } envelope, nested shape on request. Sunsets 01 January 2027 โ see the migration guide.
Stdio CLI and hosted SSE for Claude Desktop, Cursor, and autonomous AI agents.
Standings, live scores, fixtures and predictive widgets as two lines of HTML.
A live SSE stream of goals, cards and status changes, or signed webhooks. Ultra and Enterprise.
07SDKs & tools
Thin clients for both versions, ready-made request collection, and official MCP server for AI agents. They all read the same rate-limit headers described above, and expose the plan limits so a dashboard does not have to hardcode them.
| Tool | Language / format | |
|---|---|---|
| Model Context Protocol (MCP) | Stdio CLI & hosted SSE server for AI agents | Guide ยท README ยท source |
| JS / TypeScript client | npm-style module, zero dependencies, ships .d.ts | README ยท source ยท types |
| Python client | single-file, requests | README ยท source |
| PHP client | single-file, PHP 7.4+ with ext-curl | README ยท source |
| Dart client | single-file, dart:io | README ยท source |
| C# client | single-file, HttpClient, works in Unity | README ยท source |
| Kotlin client | single-file, java.net.http | README ยท source |
| Swift client | single-file, URLSession + async/await | README ยท source |
| Postman collection | importable .json, every v2 path | download |