โš ๏ธ API v1 is deprecated and will sunset on 01 January 2027. New features, endpoints, and league codes are in v2. Migration guide โ†’

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.

BASE URLhttps://api.kickoffapi.com

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:

TERMINAL
$ 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" } }
JAVASCRIPT ยท JS client
// 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();
PYTHON ยท Python client
# 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:

200 OK ยท ONE FIXTURE ROW
{
  "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:

TERMINAL
$ 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:

TERMINAL
$ 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.

ParameterTypeDefaultMeaning
limitinteger200Rows per page, max 2000
cursorinteger0Offset to start from โ€” pass back meta.nextCursor
countintegerโ€”/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.

JAVASCRIPT
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.)

REQUEST HEADER
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

PlanRequests / dayRequests / minuteLive stream & webhooks
Free (Hobby) โ€” $010030No
Starter โ€” $9/mo7,500120No
Pro โ€” $29/mo75,000300No
Ultra โ€” $49/mo250,000600Yes
EnterpriseOn request, with an SLAOn requestYes

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

HeaderWindowMeaning
X-RateLimit-LimitPer minuteRequests allowed per minute on this key
X-RateLimit-RemainingPer minuteRequests left in the current minute (resets in โ‰ค 60s)
X-RateLimit-ResetPer minuteUnix seconds when the minute window resets
X-RateLimit-Daily-LimitPer dayThe daily allowance from your plan
X-RateLimit-Daily-RemainingPer dayRequests left today, in UTC
X-RateLimit-Daily-ResetPer dayUnix seconds of the next UTC midnight
Retry-AfterOn every 429Seconds 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.

StatuscodeWhat it meansHow to fix it
401MISSING_API_KEYNo 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
401INVALID_API_KEYThe 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
401API_KEY_DISABLEDThe 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
403predictive_lockedA free key called /api/v2/insights/* or /api/v2/fixtures/{id}/projection โ€” the in-house models are a paid capabilityUpgrade, or use the free surface: scores, fixtures, standings, leaderboards are never gated
403PUSH_REQUIRES_ULTRAThe live stream is included with Ultra and EnterprisePoll /api/v2/fixtures?live=all instead, or move up a plan
429RATE_LIMIT_PER_MINUTEMore requests in one minute than the plan allowsSleep for Retry-After seconds (never more than 60) and retry. No daily quota was spent
429PLAN_LIMIT_REACHEDThe daily or monthly allowance for the plan is used up โ€” the message names whichWait 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
429NO_ACTIVE_SUBSCRIPTIONNo active subscription was found, so the key is being served on the free tier and has hit itRenew 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
429OVERAGE_CAP_REACHEDPast 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
429CREDITS_EXHAUSTED
FREE_ALLOWANCE_AND_CREDITS_EXHAUSTED
Past the allowance with no prepaid credits leftBuy credits at the topUp URL in the body, or subscribe โ€” a plan costs less per request than credits

Retry correctly

JAVASCRIPT
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.

SurfaceUse it whenPlan
GET /api/v2/streamEvents must reach you within a second or two, and you can hold a connection openUltra, Enterprise
POST /api/webhooksYour consumer is a queue, a serverless function, or anything that should not hold a socketUltra, Enterprise

Event types: kickoff, goal, goal_cancelled, card, substitution, half_time, second_half, full_time, score_correction, odds_change.

TERMINAL
$ 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.

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.

ToolLanguage / format
Model Context Protocol (MCP)Stdio CLI & hosted SSE server for AI agentsGuide ยท README ยท source
JS / TypeScript clientnpm-style module, zero dependencies, ships .d.tsREADME ยท source ยท types
Python clientsingle-file, requestsREADME ยท source
PHP clientsingle-file, PHP 7.4+ with ext-curlREADME ยท source
Dart clientsingle-file, dart:ioREADME ยท source
C# clientsingle-file, HttpClient, works in UnityREADME ยท source
Kotlin clientsingle-file, java.net.httpREADME ยท source
Swift clientsingle-file, URLSession + async/awaitREADME ยท source
Postman collectionimportable .json, every v2 pathdownload

Full SDK index โ†’ ยท Plain-text API reference (docs.md)