KickoffAPI v2, documented.
Native football data API with KickoffAPI's own stable identifiers (lg_..., tm_..., fx_..., pl_...), unified { data, meta } response envelopes, Dixon-Coles match projections, xG models, AI previews, and cursor pagination.
01Quickstart
Grab a free key, then your first call is one line. Fetch native v2 fixtures with cursor pagination:
$ curl -G "https://api.kickoffapi.com/api/v2/fixtures" \ -d "limit=10" \ -H "x-api-key: YOUR_KEY" # 200 OK · Unified Envelope { "data": [ … 10 fixture rows … ], "meta": { "count": 10, "cursor": 0, "nextCursor": "10", "source": "owned", "ownedRows": 10 } }
One row from that data array:
{
"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"
}
Read the shape before you read the values:
- Every response is
{ data, meta }.meta.countis how many rows came back,meta.nextCursoris the next offset ornull— see Pagination. status.shortis the code to branch on —NS,1H,HT,2H,FT,AET,PEN,PST,CANC.status.longcarries the same code on most rows, so"long": "FT"is not a typo.- A row that names its own store carries
source, andmeta.sourcealways summarises the page (owned,canonicalormixed) withmeta.ownedRows. A canonical row carries no per-rowsource, so read the page-level one. This is provenance, not an error code:licensedmeans the row came from the commercial feed we license to fill gaps we do not own yet. - Not finding a league is not an error —
?league=39and?league=lg_8K3mZ0pQboth resolve to the same competition, and an unknown id answers200with an emptydata. - With no filter at all, that call returns what is next: the window starts at 00:00 UTC today. Add
?league=39&season=2025for history.
02Authentication
Every request requires your API key in the x-api-key HTTP header. API keys map to your subscription tier and daily quota allowance.
x-api-key: ko_live_7f3a…e91c4b20d8
03Limits & Errors
Two windows are enforced per API key — a per-minute one and a per-plan daily one — and each set of headers describes one of them. Every 429 carries a Retry-After header.
Limits & plans
| 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 and is a soft ceiling on a paid plan: past it, requests keep being served by drawing down prepaid credits until those run out. A monthly ceiling applies too — GET /api/v2/account/status reports both, since no monthly header exists. The free plan is never gated from the core data: scores, fixtures, standings and leaderboards are on every plan.
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 — never your day's quota |
X-RateLimit-Reset | Per minute | Unix seconds when the minute window resets (≤ 60s away) |
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 |
X-Request-Id | — | Unique request ID for tracing |
Status codes
| Status | Meaning | Action |
|---|---|---|
| 200 | OK | Success. The body is { data, meta }, or an SSE stream on /stream |
| 400 | Bad Request | Missing or malformed query parameter — the body names which |
| 401 | Unauthorized | Missing or invalid x-api-key header. The message names the path |
| 403 | Forbidden | The capability is not on the plan. predictive_locked on /insights/* and /fixtures/{id}/projection; PUSH_REQUIRES_ULTRA on /stream |
| 404 | Not Found | No such id. An unknown league is not a 404 — it answers 200 with an empty list |
| 422 | Unprocessable | Valid request, not enough finished history to model that league-season yet |
| 429 | Rate Limited | Read Retry-After and the code in the body — five different walls, see below |
| 500 | Server Error | Retry with exponential backoff; quote the X-Request-Id |
The five 429s and the two 403s
Every error body is { "error": "…" }. Refusals add a machine-readable code — branch on that, not on the message.
{ "error": "Rate limit exceeded",
"code": "RATE_LIMIT_PER_MINUTE",
"limit": 30,
"retryAfter": 42 }
Fix: wait Retry-After seconds (never more than 60) and retry. Daily quota was not spent. This is the one to handle with a queue or a token bucket, not an alarm.
{ "error": "Daily rate limit exceeded for your active plan",
"code": "PLAN_LIMIT_REACHED",
"limit": 7500,
"used": 7500,
"upgrade": "https://kickoffapi.com/pricing.html",
"suggestedPlan": { "name": "Pro", "requestsPerDay": 75000, "pricePerMonth": 29 } }
Fix: wait for X-RateLimit-Daily-Reset, or buy credits, or move to suggestedPlan — it is sized from the traffic you are actually sending, not from the plan you are on. code: NO_ACTIVE_SUBSCRIPTION means no active subscription was found and the key is being served on the free tier: renew instead of upgrading, and expect limit to be the free plan's number. A subscription that ended within the grace window is still served, with an x-ko-subscription-grace: true header.
{ "error": "Daily limit exceeded. Overage cap reached.",
"code": "OVERAGE_CAP_REACHED",
"overage": true,
"dailyLimit": 7500,
"creditDailyCap": 15000,
"overageUsed": 7499,
"upgrade": "https://kickoffapi.com/pricing.html" }
Fix: the cap is 2× the plan allowance on a paid plan and 5,000 requests on the free tier. Either way you are far past what the plan is for, so upgrading is the fix that keeps paying; a top-up clears the wall today. creditDailyCap in the body is the cap that fired.
{ "error": "Plan limit reached and no prepaid credits remaining. Top up to continue.",
"code": "CREDITS_EXHAUSTED",
"creditsRemaining": 0,
"overage": true,
"overageCount": 7501,
"topUp": "https://app.kickoffapi.com/portal/",
"upgrade": "https://kickoffapi.com/pricing.html" }
Fix: buy credits at the topUp URL, or subscribe — a plan costs less per request than credits. On the free plan the code is FREE_ALLOWANCE_AND_CREDITS_EXHAUSTED and the body says both.
{ "error": "Stream connection limit reached (20 concurrent). Close an existing connection and retry.",
"code": "STREAM_CONNECTIONS_LIMIT",
"limit": 20 }
Fix: close an existing connection — the cap is 20 concurrent on Ultra, 100 on Enterprise. Sharing one stream between consumers is the usual answer.
{ "error": "Predictive insights (projections, xG, season simulation, power ratings) require a paid KickoffAPI plan.",
"code": "predictive_locked",
"upgrade": "https://kickoffapi.com/pricing.html" }
Fix: a free key can call everything else — fixtures, live scores, standings, leaderboards, events, lineups, statistics, odds on paid plans. Only /api/v2/insights/* and /api/v2/fixtures/{id}/projection answer 403.
Retry correctly
const res = await fetch(url, { headers: { "x-api-key": KEY } }); if (res.status === 429) { const body = await res.clone().json().catch(() => ({})); const seconds = Number(res.headers.get("Retry-After") || 60); if (body.code === "RATE_LIMIT_PER_MINUTE") await sleep(seconds * 1000); // fine, come straight back else queueForMidnight(res.headers.get("X-RateLimit-Daily-Reset")); // quota wall: not a retry loop }
04Pagination
Every list endpoint takes limit and cursor and answers with meta.
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 200 | Rows per page. Maximum 2000 — larger values are clamped, not rejected |
cursor | integer | 0 | Offset to start from. Pass back meta.nextCursor verbatim |
count | integer | — | /api/v2/fixtures only. Return at most this many rows (clamped to 50) without changing the page size nextCursor is computed from — for a cheap page when you only need a few rows |
meta.nextCursor is a string (the next offset) or null. It is null unless the page came back exactly full, so a final page that happens to land on a multiple of limit reports a cursor and the next call returns an empty array. Stop on an empty data. The same defaults apply to every list endpoint; the exceptions are team rankings (default 20, max 100) and the two card leaderboards (max 200 rows).
05Endpoint Reference
All endpoints return a standardized envelope: { data, meta }. Native IDs use prefixes: lg_ (leagues), tm_ (teams), fx_ (fixtures), pl_ (players), vn_ (venues). You never have to convert an id to call v2. Wherever a competition is named, the native id and the integer id v1 used are interchangeable — ?league=39 and ?league=lg_8K3mZ0pQ are the same competition, so there is no id-mapping step in a migration. Two cards are the exception and say so on the card: /insights/strength matches the native id only, and /topassists parses the integer only. And an id v2 has never seen is not an error: the answer is 200 with an empty data, not a 404. /resolve and /idmap/leagues exist if you want the mapping stored in your own database.
Translate Single Entity ID
Translates legacy integer IDs to KickoffAPI native string IDs (koId).
| Parameter | Type | Required | Description |
|---|---|---|---|
| entity | string | Yes | league | team | fixture | player |
| legacyId | integer | Yes | Legacy integer ID (e.g., 42 for Arsenal) |
{
"data": { "entity": "team", "legacyId": 42, "id": "tm_a1B2c3" },
"meta": {}
}
Bulk League ID Mapping
Returns a complete mapping table translating legacy integer league IDs to native KickoffAPI IDs (lg_...).
{
"data": [
{ "legacyId": 39, "id": "lg_8K3mZ0pQ", "name": "Premier League" },
{ "legacyId": 140, "id": "lg_9X2nY1rT", "name": "La Liga" }
],
"meta": { "count": 2 }
}
List Countries
Retrieve all supported countries with ISO country codes and flag icons.
| Parameter | Type | Required | Description |
|---|---|---|---|
| limit | integer | No | Results per page (default: 200, max: 2000) |
| cursor | integer | No | Pagination offset (default: 0) |
{
"data": [
{ "id": "cnt_1", "name": "England", "code": "GB", "flag": "https://cdn.kickoffapi.com/images/flags/gb.svg" }
],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
List Leagues
Retrieve football leagues and cup competitions filtered by country or name.
| Parameter | Type | Required | Description |
|---|---|---|---|
| country | string | No | Filter by country name (e.g. England) |
| search | string | No | Substring match on the competition name |
| limit | integer | No | Results per page (default: 200, max: 2000) |
| cursor | integer | No | Pagination offset |
{
"data": [
{ "id": "lg_8K3mZ0pQ", "name": "Premier League", "type": "League", "country": "England", "logo": "https://cdn.kickoffapi.com/images/leagues/39.png" }
],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
List Teams
Fetch team profiles and club logos by country.
| Parameter | Type | Required | Description |
|---|---|---|---|
| country | string | No | Filter by country name |
| limit | integer | No | Results per page (default: 200, max: 2000) |
| cursor | integer | No | Pagination offset |
{
"data": [
{ "id": "tm_a1B2c3", "name": "Arsenal", "country": "England", "logo": "https://cdn.kickoffapi.com/images/teams/42.png" }
],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
List Players
Fetch player profiles with native string identifiers (pl_...).
| Parameter | Type | Required | Description |
|---|---|---|---|
| limit | integer | No | Results per page (default: 200, max: 2000) |
| cursor | integer | No | Pagination offset |
{
"data": [
{ "id": "pl_x9Y8z7", "name": "Bukayo Saka", "firstname": "Bukayo", "lastname": "Saka", "nationality": "England" }
],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
Team Squads (Wikidata CC0 Roster)
Fetch self-owned CC0 Wikidata squad rosters for a team by native ID.
{
"data": [
{ "id": "pl_saka", "name": "Bukayo Saka", "position": "Attacker" },
{ "id": "pl_rice", "name": "Declan Rice", "position": "Midfielder" }
],
"meta": { "team": "tm_a1B2c3", "name": "Arsenal", "count": 2, "source": "wikidata-cc0" }
}
Player Career
Every club one person has played for, and the years they were there — assembled from the
Wikidata and StatsBomb archives we own, so no crosswalk to another feed is needed. A person
with one membership and no resolvable club returns clubs: [] with a
meta.note saying so, rather than presenting one club as a career.
| Parameter | Type | Required | Description |
|---|---|---|---|
:id | string | Yes | Native player id, e.g. pl_x9Y8z7 |
{
"data": {
"id": "pl_x9Y8z7",
"name": "Bukayo Saka",
"dob": "2001-09-05",
"nationality": "England",
"clubs": [
{ "team": { "id": "tm_a1B2c3", "name": "Arsenal", "logo": "https://cdn.kickoffapi.com/images/logos/42.png" },
"from": "2018-07-01", "to": null, "number": 7, "position": "Attacker", "source": "statsbomb" },
{ "team": { "id": "tm_d4E5f6", "name": "Arsenal U21", "logo": null },
"from": "2017-08-01", "to": "2018-06-30", "number": 41, "position": "Midfielder", "source": "wikidata-cc0" }
]
},
"meta": { "source": "owned", "count": 2, "attribution": ["StatsBomb", "Wikidata CC0"] }
}
to is null for a spell still running. 404 when the person is not in either archive; meta.attribution names the licences whose terms this response carries.
Player Predictions
Per-match scoring and booking rates for one player, derived from StatsBomb match events.
Watch the envelope: this operation returns its predictions
object as a sibling of data, not inside it — every other v2 operation nests its
payload in data. Read predictions from the top level, and read
data for who the player is.
| Parameter | Type | Required | Description |
|---|---|---|---|
:id | string | Yes | Native player id |
league | string | No | Competition id — lg_… or the integer form |
season | integer | No | Season year. Defaults to 2024 |
opponent | string | No | Narrow the projection to one upcoming opponent |
{
"data": { "id": "pl_x9Y8z7", "name": "Bukayo Saka" },
"predictions": {
"anytimeScorer": 0.612,
"expectedXg": 0.284,
"goalsPerMatch": 0.41,
"conversionRate": 0.22,
"cardProb": 0.09,
"matches": 28
},
"meta": { "source": "statsbomb-derived", "note": "based on StatsBomb match events. More data = better predictions." }
}
matches is how many matches the figures are built from — a low number means a weak projection, not a confident one. When the player is known but there is not enough event data yet, this answers 200 with anytimeScorer, expectedXg and cardProb all null and a meta.note saying why; 404 means there is no event data for the player at all.
Venues
Retrieve football stadiums, locations, capacities, and venue images.
| Parameter | Type | Required | Description |
|---|---|---|---|
| search | string | No | Search venue by name (case-insensitive) |
| limit | integer | No | Results per page (default: 200, max: 2000) |
| cursor | integer | No | Pagination offset |
{
"data": [
{ "id": "vn_77K2p", "name": "Emirates Stadium", "city": "London", "capacity": 60704, "image": "https://cdn.kickoffapi.com/images/venues/494.png" }
],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
Fixtures & Live Scores
Query upcoming schedules, live scores, and historical match results using native IDs.
| Parameter | Type | Required | Description |
|---|---|---|---|
| league | string | No | Native league ID (lg_8K3mZ0pQ) or the integer ID v1 used (39) — both resolve, no mapping step |
| season | integer | No | Season year (e.g. 2026) |
| date | date | No | Filter by date (YYYY-MM-DD) |
| team | string | No | Native team ID (e.g., tm_a1B2c3) |
| from / to | date | No | Date range filtering (YYYY-MM-DD) |
| live | string | No | all (or 1/true) for in-play only, most recently kicked off first |
| count | integer | No | Return at most this many rows (max 50) without changing the page size nextCursor is computed from |
| limit | integer | No | Results per page (default: 200, max: 2000) |
| cursor | integer | No | Pagination offset — pass back meta.nextCursor |
{
"data": [
{
"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"
}
],
"meta": { "count": 1, "cursor": 0, "nextCursor": null, "source": "owned", "ownedRows": 1 }
}
Single Fixture Detail
Retrieve full fixture object with embedded stored AI preview & recap text.
{
"data": {
"id": "fx_99Xy",
"date": "2026-06-27T16:30:00.000Z",
"status": { "long": "Match Finished", "short": "FT", "elapsed": 90 },
"league": { "id": "lg_8K3mZ0pQ", "name": "Premier League", "season": 2026 },
"home": { "id": "tm_a1B2c3", "name": "Arsenal", "logo": "..." },
"away": { "id": "tm_d4E5f6", "name": "Chelsea", "logo": "..." },
"score": { "home": 2, "away": 1, "halftime": { "home": 1, "away": 0 } },
"content": { "preview": "Arsenal host Chelsea in a pivotal...", "recap": "Arsenal claimed a 2-1 victory..." }
},
"meta": { "content": { "preview": "kickoff-ai-v1", "recap": "kickoff-ai-v1" }, "source": "kickoffapi-derived (grounded)" }
}
Fixture Events
Minute-by-minute events (goals, cards, substitutions) for a specific match.
{
"data": [
{ "time": { "elapsed": 34, "extra": null }, "team": { "id": "tm_a1B2c3", "name": "Arsenal" }, "player": { "id": "pl_saka", "name": "B. Saka" }, "type": "Goal", "detail": "Normal Goal" }
],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
Fixture Lineups
Starting XI, tactical formation, substitutes, and head coach for a match.
{
"data": [
{ "team": { "id": "tm_a1B2c3", "name": "Arsenal" }, "formation": "4-3-3", "startXI": [ ... ], "substitutes": [ ... ] }
],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
Fixture Team Statistics
Match statistics (shots on goal, possession %, passes, fouls, corners, offsides) for both teams.
{
"data": [
{ "team": { "id": "tm_a1B2c3", "name": "Arsenal" }, "statistics": [ { "type": "Ball Possession", "value": "58%" } ] }
],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
Fixture Player Statistics
Individual player statistics and ratings for a single match.
{
"data": [
{ "player": { "id": "pl_saka", "name": "B. Saka" }, "rating": "8.2", "goals": 1, "assists": 1 }
],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
Detailed Event Feed (with xG)
The event timeline for one fixture with the per-shot expected-goals values attached, read
from the owned event store. Use it when you need xG on a shot;
/fixtures/:id/events is the lighter per-fixture list.
fixture is required — this is not a global firehose; for the
live push feed see /stream or webhooks.
| Parameter | Type | Required | Description |
|---|---|---|---|
fixture | string | Yes | Fixture id — fx_… or the integer form |
{
"data": [
{ "time": 34, "type": "Goal", "detail": "Normal Goal",
"player": { "name": "B. Saka" }, "assist": { "name": "M. Odegaard" },
"team": { "id": 42 }, "xG": 0.31 },
{ "time": 61, "type": "Shot", "detail": "Blocked",
"player": { "name": "K. Havertz" }, "assist": null,
"team": { "id": 42 }, "xG": 0.08 }
],
"meta": { "count": 2, "cursor": 0, "nextCursor": null, "source": "statsbomb" }
}
team.id is the integer club id here, not the native tm_… form, and
assist is null when there was none. meta.source names
the archive the rows came from. This endpoint is not paginated: it returns up
to 200 events for the fixture, so meta.nextCursor is always null.
Head-to-Head (H2H)
Historical match records between two specific teams using native team IDs.
| Parameter | Type | Required | Description |
|---|---|---|---|
| h2h | string | Yes* | Format: tm_1-tm_2 (e.g. tm_a1B2c3-tm_d4E5f6) |
| team1 / team2 | string | Yes* | Alternative: separate team1 and team2 parameters |
| limit | integer | No | How many past meetings to return (default: 200, max: 2000) |
{
"data": [ { "id": "fx_99Xy", "date": "2026-06-27T16:30:00Z", "score": { "home": 2, "away": 1 } } ],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
Match Projections (Dixon-Coles v2)
In-house Dixon-Coles time-weighted match outcome probability projection model.
{
"data": {
"fixture": "fx_99Xy",
"homeWin": 0.54,
"draw": 0.26,
"awayWin": 0.20,
"expectedGoals": { "home": 1.75, "away": 1.05 }
},
"meta": { "model": "dixon-coles-v2", "source": "kickoffapi-derived" }
}
Derived Betting Markets
The full score-probability matrix for one fixture, collapsed into the markets a bettor asks for — 1X2, both-teams-to-score, over/under lines, double chance, a total-goals band and correct scores. Every number is our probability, derived from finished results; there is no bookmaker price in this response. Pair it with /odds or /value-bets to turn a probability into an edge.
{
"data": {
"fixture": "fx_99Xy",
"outcome": { "home": 0.541, "draw": 0.262, "away": 0.197 },
"btts": { "yes": 0.512, "no": 0.488 },
"overUnder": { "over05": 0.783, "over15": 0.548, "over25": 0.472, "over35": 0.268 },
"doubleChance": { "homeOrDraw": 0.803, "homeOrAway": 0.738, "drawOrAway": 0.459 },
"totalGoals": { "zeroOne": 0.217, "twoThree": 0.443, "fourFive": 0.262, "sixPlus": 0.078 },
"correctScore": [
{ "home": 1, "away": 0, "probability": 0.118 },
{ "home": 1, "away": 1, "probability": 0.104 },
{ "home": 2, "away": 1, "probability": 0.062 }
]
},
"meta": { "model": "dixon-coles-v2", "source": "kickoffapi-derived" }
}
Each group sums to 1. correctScore is the most likely scorelines, not all 121.
422 when the league and season do not hold enough finished matches to fit the
model, or when either club is missing from it — a narrow league/season pair is the usual
cause.
Synthetic Fair & Market Odds
In-house Dixon-Coles and Poisson predictive betting odds (1X2 match winner, double chance,
both-teams-to-score, and over/under goals) computed with customizable margin overround
(?margin=0 for 100% fair payout odds, or default ?margin=0.05 for 5% bookmaker overround).
{
"data": {
"fixture": "fx_99Xy",
"model": "dixon-coles-v2",
"margin": 0.05,
"overroundPercent": 5,
"markets": {
"matchWinner": { "home": 2.15, "draw": 3.40, "away": 3.25 },
"doubleChance": { "homeOrDraw": 1.33, "homeOrAway": 1.30, "drawOrAway": 1.67 },
"bothTeamsToScore": { "yes": 1.80, "no": 1.95 },
"overUnder": { "over05": 1.05, "under05": 9.50, "over15": 1.30, "under15": 3.40, "over25": 1.90, "under25": 1.90, "over35": 3.20, "under35": 1.33 }
}
},
"meta": { "model": "dixon-coles-v2", "source": "kickoffapi-derived", "independent": true }
}
Fixture Insight (Head-to-Head Context)
One fixture as an owned row plus its recent head-to-head context: how many meetings the two
clubs have had, and the last three of them. Paid plans only — a free key
gets 403 predictive_locked; see Limits & Errors.
| Parameter | Type | Required | Description |
|---|---|---|---|
fixture | string | Yes | Fixture id — fx_… or the integer form |
{
"data": [
{
"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" },
"away": { "id": "tm_d4E5f6", "name": "Chelsea" },
"score": { "home": 2, "away": 1, "halftime": { "home": 1, "away": 0 } },
"h2hCount": 10,
"h2hLast": [
{ "date": "2025-12-21T00:00:00.000Z", "home": 2, "away": 2 },
{ "date": "2025-03-09T00:00:00.000Z", "home": 2, "away": 1 }
]
}
],
"meta": { "count": 1, "cursor": 0, "nextCursor": null, "source": "owned" }
}
h2hCount is how many meetings are on record (capped at the 10 most recent);
h2hLast shows at most 3 of them, newest first, with
home/away as that fixture's sides rather than the requested
pair's. An unknown id answers 200 with an empty data.
Model Accuracy (Backtest)
How our two probability models actually performed on one competition and season, measured by
backtesting them against every finished match. Lower is better for
rps, brier and logloss; higher is better for
accuracy and btts.
Paid plans only — a free key gets 403 predictive_locked.
| Parameter | Type | Required | Description |
|---|---|---|---|
league | string | Yes | Competition id — lg_… or the integer form |
season | integer | No | Season year. Defaults to the current calendar year |
{
"data": {
"league": { "id": "39", "name": "Premier League" },
"season": 2025,
"tested": 340,
"models": {
"dixonColes": { "rps": 0.1641, "brier": 0.0913, "logloss": 1.0204, "accuracy": 0.5441, "btts": 0.5562, "over25": 0.5311, "calibration": 0.0031 },
"poisson": { "rps": 0.1702, "brier": 0.0944, "logloss": 1.0488, "accuracy": 0.5324, "btts": 0.5510, "over25": 0.5276 }
},
"comparison": { "rpsImprovement": 0.0061, "brierImprovement": 0.0031 },
"xgModel": { "name": "KickoffAPI Shot-xG v1", "pitchFrame": "StatsBomb 120x80 (goal at x=120, y=40)" }
},
"meta": { "source": "kickoffapi-derived", "cached": true }
}
dixonColes is the model behind projections,
markets and value bets;
poisson is the simpler baseline it is measured against, and
dixonColes is null when that competition and season do not hold
enough finished matches. calibration is the home-favouritism drift — near zero is
the goal. comparison is how much dixonColes beat
poisson by (positive numbers mean the better model won) and is
null unless both ran.
Team Form
Recent match form, streak sequence, goals scored/conceded, and clean sheets over last N matches.
| Parameter | Type | Required | Description |
|---|---|---|---|
| season | integer | Yes | Season year (e.g. 2026) |
| n | integer | No | Number of recent matches (default: 5, max: 20) |
{
"data": {
"team": "tm_a1B2c3",
"name": "Arsenal",
"season": 2026,
"matches": 5,
"points": 13,
"sequence": "WWWDW",
"goalsScored": 12,
"goalsConceded": 3,
"cleanSheets": 3
},
"meta": { "source": "kickoffapi-derived" }
}
Team Results (Last N)
A club's last N finished matches across every competition, each already labelled with the result from that club's point of view — no need to work out home/away yourself.
| Parameter | Type | Required | Description |
|---|---|---|---|
:id | string | Yes | Native team id, e.g. tm_a1B2c3 |
n | integer | No | How many matches. Default 10, clamped to 50 |
{
"data": [
{ "id": "fx_99Xy", "date": "2026-06-27T16:30:00.000Z",
"league": { "id": "lg_8K3mZ0pQ", "name": "Premier League" },
"home": { "id": "tm_a1B2c3", "name": "Arsenal" },
"away": { "id": "tm_d4E5f6", "name": "Chelsea" },
"score": { "home": 2, "away": 1 }, "result": "W" }
],
"meta": { "team": "tm_a1B2c3", "name": "Arsenal", "count": 1, "source": "kickoffapi-derived" }
}
result is W, D or L from the requested club's side, whether it was home or away. Only FT matches appear.
Team Streaks
How far the club's current win, unbeaten, loss and clean-sheet runs stretch. Each figure is counted from the most recent finished match backwards and stops at the first match that breaks it, so all four are 0 or a real run.
| Parameter | Type | Required | Description |
|---|---|---|---|
:id | string | Yes | Native team id |
{
"data": {
"team": "tm_a1B2c3",
"name": "Arsenal",
"current": { "win": 2, "unbeaten": 5, "loss": 0, "cleanSheet": 1 }
},
"meta": { "source": "kickoffapi-derived", "lastN": 30 }
}
unbeaten and cleanSheet overlap with the others by design —
unbeaten: 5, loss: 0 is the same run, described twice. The figures are counted
over the club's 30 most recent finished matches (meta.lastN), so a
run longer than that reads as capped rather than as wrong.
Team Season Statistics
One club's season totals for one competition. Both league and
season are required: a season total is meaningless without saying which
competition it was in.
| Parameter | Type | Required | Description |
|---|---|---|---|
:id | string | Yes | Native team id |
league | string | Yes | Competition id — lg_… or the integer form |
season | integer | Yes | Season year |
data is the stored season-statistics record for that club, competition and
season, returned as stored. It is the same body
GET /api/v1/team-statistics returns, so a parser built for one works on the
other unchanged. The record is free-form rather than a fixed schema: it is keyed
{ team, league, season } and the totals inside it grow as coverage does, so
read what you need and ignore the rest rather than validating the whole object. Read
meta for the club, competition and season the record belongs to.
{
"data": "<the stored season-statistics record — see the note above>",
"meta": { "team": "Arsenal", "league": "Premier League", "season": 2025, "source": "kickoffapi" }
}
A club/league/season with no record is fetched once on demand and then answers
404 if it still does not exist — so a cold combination costs a slower first
call rather than an error. For per-match results use
/teams/:id/results; for form and scoring rates use
/teams/:id/form.
League Form Guide
Every club in one competition and season with its last five results as a run of letters, plus the win/draw/loss split and goals for and against behind it. Sorted by points-equivalent, so the leaders are first.
| Parameter | Type | Required | Description |
|---|---|---|---|
:id | string | Yes | Competition id — lg_… or the integer form |
season | integer | Yes | Season year. No default |
{
"data": [
{ "team": { "id": "tm_a1B2c3", "name": "Arsenal" }, "form": "WWDWL", "w": 3, "d": 1, "l": 1, "gf": 8, "ga": 3 },
{ "team": { "id": "tm_d4E5f6", "name": "Chelsea" }, "form": "WDWDD", "w": 2, "d": 2, "l": 1, "gf": 6, "ga": 4 }
],
"meta": { "league": "39", "season": 2025, "count": 2, "source": "kickoffapi-derived" }
}
form reads oldest to newest and is capped at five letters, so a club with fewer than five finished matches has a shorter run. A missing season is 400, not a default season.
League Goals & BTTS Rates
Season scoring rates for one competition: average goals per match, how often each over/under line landed, and how often both teams scored. The percentages are computed over that season's finished matches only.
| Parameter | Type | Required | Description |
|---|---|---|---|
:id | string | Yes | Competition id — lg_… or the integer form |
season | integer | Yes | Season year. No default |
{
"data": {
"league": "39", "season": 2025, "matches": 340,
"avgGoals": 2.74,
"over05Pct": 88.2,
"over15Pct": 64.7,
"over25Pct": 51.5,
"over35Pct": 26.8,
"bttsPct": 54.4
},
"meta": { "source": "kickoffapi-derived" }
}
over25Pct: 51.5 means 51.5% of that season's finished matches had more than 2.5 goals — useful as a prior next to the per-fixture model in /markets.
League Attack & Defense Strength
Poisson strength parameters (attack and defense ratings) for all teams in a league season.
| Parameter | Type | Required | Description |
|---|---|---|---|
| league | string | Yes | Native league ID (e.g. lg_8K3mZ0pQ) — this one matches the native id only, so an integer answers 400 |
| season | integer | Yes | Season year |
{
"data": [
{ "team": { "id": "tm_a1B2c3", "name": "Arsenal" }, "attack": 1.45, "defense": 0.65 }
],
"meta": { "league": "lg_8K3mZ0pQ", "season": 2026, "model": "poisson-strength" }
}
Monte Carlo Season Simulation
Simulate remaining season matches using Dixon-Coles Monte Carlo engine to project final standing ranks and points distributions.
| Parameter | Type | Required | Description |
|---|---|---|---|
| league | string | Yes | Native league ID (lg_8K3mZ0pQ) or the integer ID v1 used (39) — both resolve, no mapping step |
| season | integer | Yes | Season year |
| sims | integer | No | Number of simulations (default: 1000) |
{
"data": [
{ "team": { "id": "tm_a1B2c3", "name": "Arsenal" }, "titleProb": 0.48, "top4Prob": 0.95, "projectedPoints": 87.4 }
],
"meta": { "model": "dixon-coles-v2 monte-carlo", "source": "kickoffapi-derived" }
}
AI Match Previews
Generate or retrieve grounded AI match preview analysis generated by KickoffAPI's proprietary content engine.
{
"data": {
"id": "fx_99Xy",
"preview": "Arsenal host Chelsea at Emirates Stadium in a critical London derby..."
},
"meta": { "model": "kickoff-ai-v1", "cached": true, "source": "kickoffapi-derived (grounded)" }
}
Stored Match Content (Preview & Recap)
Pure database read endpoint returning pre-generated match previews and post-match recaps without triggering LLM latency.
{
"data": {
"id": "fx_99Xy",
"preview": "Arsenal host Chelsea in a high-stakes fixture...",
"recap": "Arsenal earned three points with a dramatic 2-1 victory..."
},
"meta": { "models": { "preview": "kickoff-ai-v1", "recap": "kickoff-ai-v1" }, "source": "kickoffapi-derived (grounded)" }
}
xG Model Metadata
Retrieve information about KickoffAPI's in-house Expected Goals (xG) model, frame coordinates (StatsBomb 120x80), and features.
{
"data": {
"name": "KickoffAPI Shot-xG v1",
"pitchFrame": "StatsBomb 120x80 (goal at x=120, y=40)",
"features": ["distance", "angle", "bodyPart", "situation"]
},
"meta": { "note": "KickoffAPI-owned xG model." }
}
Calculate Shot xG
Calculate Expected Goal value for a single shot via GET parameters or a batch array of shots via POST JSON payload.
| Parameter | Type | Required | Description |
|---|---|---|---|
| x | number | Yes | Shot X coordinate (0-120; goal line at 120) |
| y | number | Yes | Shot Y coordinate (0-80; center goal at 40) |
| bodyPart | string | No | foot | header | other |
| situation | string | No | open_play | free_kick | penalty | corner |
{
"data": {
"xg": 0.38,
"distance": 12.0,
"angleDegrees": 32.5,
"inputs": { "x": 108, "y": 40, "bodyPart": "foot", "situation": "open_play" }
},
"meta": { "source": "kickoffapi-derived" }
}
League Standings
Fetch league standings table for a specific league and season year.
| Parameter | Type | Required | Description |
|---|---|---|---|
| league | string | Yes | Native league ID (lg_8K3mZ0pQ) or the integer ID v1 used (39) — both resolve, no mapping step |
| season | integer | Yes | Season year (e.g. 2026) |
{
"data": [
{ "rank": 1, "team": { "id": "tm_a1B2c3", "name": "Arsenal", "logo": "..." }, "points": 86, "goalsDiff": 45, "played": 38, "win": 27, "draw": 5, "lose": 6, "form": "WWWDW" }
],
"meta": { "league": "lg_8K3mZ0pQ", "season": 2026, "count": 1 }
}
Top Scorers
Retrieve leading goal scorers in a league season.
| Parameter | Type | Required | Description |
|---|---|---|---|
| league | string | Yes | Native league ID (lg_8K3mZ0pQ) or the integer ID v1 used (39) — both resolve, no mapping step |
| season | integer | Yes | Season year |
{
"data": [
{ "rank": 1, "player": { "id": "pl_haaland", "name": "Erling Haaland" }, "team": { "id": "tm_manc", "name": "Man City" }, "goals": 28 }
],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
Top Assists
Retrieve assist leaders in a league season.
| Parameter | Type | Required | Description |
|---|---|---|---|
| league | integer | Yes | The integer league ID (39) — this endpoint parses an integer, so a lg_… id is ignored and the answer is not narrowed to that competition |
| season | integer | Yes | Season year |
{
"data": [
{ "rank": 1, "player": { "id": "pl_saka", "name": "Bukayo Saka" }, "team": { "id": "tm_a1B2c3", "name": "Arsenal" }, "assists": 14 }
],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
Top Yellow Cards
Players with the most yellow cards in a league season.
| Parameter | Type | Required | Description |
|---|---|---|---|
| league | string | Yes | Native league ID (lg_8K3mZ0pQ) or the integer ID v1 used (39) — both resolve, no mapping step |
| season | integer | Yes | Season year |
| limit | integer | No | Rows to return (default 200, capped at 200 by this endpoint) |
| cursor | integer | No | Pagination offset |
{
"data": [
{ "player": { "id": "pl_casemiro", "name": "Casemiro" }, "team": { "id": "tm_manu", "name": "Man United" }, "yellowCards": 11, "cards": 11, "appearances": 27 }
],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
Top Red Cards
Players with the most red cards in a league season.
| Parameter | Type | Required | Description |
|---|---|---|---|
| league | string | Yes | Native league ID (lg_8K3mZ0pQ) or the integer ID v1 used (39) — both resolve, no mapping step |
| season | integer | Yes | Season year |
| limit | integer | No | Rows to return (page default 200, capped at 200 by this endpoint) |
{
"data": [
{ "player": { "id": "pl_dier", "name": "Eric Dier" }, "team": { "id": "tm_tot", "name": "Tottenham" }, "redCards": 2, "cards": 2, "appearances": 24 }
],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
Sidelined
Players ruled out through injury or suspension. One of player, team or fixture is required.
| Parameter | Type | Required | Description |
|---|---|---|---|
| player | string | No | Native player ID |
| team | string | No | Native team ID — asked through that club’s squad |
| fixture | integer | No | Fixture ID |
| limit | integer | No | Rows to return (default 200) |
{
"data": [
{ "player": { "id": "pl_rodri", "name": "Rodri" }, "type": "Knee Injury", "start": "2026-09-22", "end": null, "fixtureId": null }
],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
Timezones
Every IANA timezone name accepted elsewhere in the API. UTC is listed first.
| Parameter | Type | Required | Description |
|---|---|---|---|
| — | — | — | No parameters |
{
"data": ["UTC", "Africa/Abidjan", "Africa/Accra", "America/New_York", "Europe/London"],
"meta": { "count": 419, "cursor": 0, "nextCursor": null }
}
Elo Team Rankings
Every club we hold a rating for, strongest first. These are the ratings the Elo head-to-head is built on, so a caller who wants a single number for "how good is this club" should read it from here rather than deriving one.
| Parameter | Type | Required | Description |
|---|---|---|---|
country | string | No | Full country name (England) or three-letter code (ENG) — both work, case-insensitively. Omit for every club |
limit | integer | No | How many clubs. Default 20, clamped to 100 |
{
"data": [
{ "id": "tm_a1B2c3", "name": "Arsenal", "elo": 2114, "country": "ENG" },
{ "id": "tm_d4E5f6", "name": "Chelsea", "elo": 2087, "country": "ENG" }
],
"meta": { "count": 412, "country": "England", "source": "clubelo.com" }
}
meta.count is how many clubs matched before the limit was applied, so it
tells you whether to ask for more. One club appears once even when several of our teams point
at it. country in the rows stays as the rating feed's own three-letter code.
Elo Head-to-Head Prediction
The win probability for one fixture from the two clubs' Elo ratings, with the standard 65-point home advantage applied. A deliberately simple, explainable model — unlike the Dixon-Coles projection in /projection, which reads form and goals. Good as a sanity check and as a baseline to beat.
| Parameter | Type | Required | Description |
|---|---|---|---|
team1 | string | Yes | Home club id, tm_… |
team2 | string | Yes | Away club id, tm_… |
{
"data": {
"home": { "id": "tm_a1B2c3", "name": "Arsenal", "elo": 2114 },
"away": { "id": "tm_d4E5f6", "name": "Chelsea", "elo": 2087 },
"prediction": { "homeWin": 0.555, "awayWin": 0.445, "homeAdvantage": 65 }
},
"meta": { "model": "elo-kickoffapi", "source": "clubelo.com" }
}
Only a two-way outcome is modelled: homeWin and awayWin sum to 1
and there is no draw, so do not read these as 1X2 probabilities.
homeAdvantage is the points added to the home rating, echoed so you can see the
assumption. 404 when either club has no rating yet — the ids come from
/teams/rankings.
Value Bets
Upcoming fixtures in one competition where our model disagrees with the market price by more than 3 points. Each edge names the model probability, the implied probability from the price and the difference — an edge is not advice; it is a disagreement worth looking at.
| Parameter | Type | Required | Description |
|---|---|---|---|
league | string | Yes | Competition id — lg_… or the integer form |
season | integer | No | Season year. Defaults to the current calendar year |
{
"data": [
{ "fixture": "fx_99Xy",
"edges": [
{ "market": "1X2", "outcome": "Arsenal", "ourProb": 0.612, "impliedProb": 0.545, "edge": 0.067, "bookmaker": "Bet365", "odds": 1.75 },
{ "market": "Over 2.5", "outcome": "yes", "ourProb": 0.588, "edge": 0.088, "note": "model vs fair line (0.5)" }
] }
],
"meta": { "model": "dixon-coles-v2", "source": "kickoffapi-derived", "count": 1 }
}
Only 1X2 edges are compared against a bookmaker, and they appear only where we
hold prices for that fixture — an empty data array most often means no prices,
not no opportunities. BTTS and Over 2.5 edges are model-versus-fair-line
and carry note instead of bookmaker/odds, so
impliedProb is absent on those. 422 when the competition and season
do not hold enough finished matches to fit the model.
Pre-Match Odds
Retrieve pre-match betting odds across bookmakers and betting markets.
| Parameter | Type | Required | Description |
|---|---|---|---|
| fixture | string | One of the two | One fixture — native fx_… or the legacy integer id |
| league | string | One of the two | Prices for that competition's 20 most recent fixtures. With neither fixture nor league the endpoint answers 400 |
| bookmaker | integer | No | Filter by bookmaker ID |
| limit | integer | No | Results per page (default: 200, max: 2000) |
| cursor | integer | No | Pagination offset |
{
"data": [
{ "id": "odd_1", "bookmaker": { "id": 8, "name": "Bet365" }, "betType": { "id": 1, "name": "Match Winner" }, "values": [ { "value": "Home", "odd": "1.85" } ] }
],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
Live In-Play Odds
The most recent in-play price lines, newest first. This endpoint reads no filters —
narrow with /api/v2/odds?fixture= if you need one match.
| Parameter | Type | Required | Description |
|---|---|---|---|
| limit | integer | No | Results per page (default: 200, max: 2000) |
| cursor | integer | No | Pagination offset |
{
"data": [ { "id": "odd_live_1", "bookmaker": { "id": 8, "name": "Bet365" }, "values": [ { "value": "Home", "odd": "2.10" } ] } ],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
Reference: Bookmakers
List of supported sports betting bookmakers and logos.
{
"data": [
{ "id": 8, "name": "Bet365", "image": "https://cdn.kickoffapi.com/images/bookmakers/8.png" }
],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
Reference: Bet Types
List of supported betting market categories (Match Winner, Over/Under, Both Teams to Score, etc.).
{
"data": [
{ "id": 1, "name": "Match Winner" },
{ "id": 5, "name": "Goals Over/Under" }
],
"meta": { "count": 2, "cursor": 0, "nextCursor": null }
}
Player Transfers
Historical and active player transfers between teams.
| Parameter | Type | Required | Description |
|---|---|---|---|
| player | string | No | Native player ID |
| team | string | No | Native team ID |
| limit | integer | No | Results per page (default: 200, max: 2000) |
| cursor | integer | No | Pagination offset |
{
"data": [ { "id": "tr_1", "date": "2026-07-01", "type": "Transfer", "player": { "id": "pl_rice" } } ],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
Injuries & Sidelined Players
Player injury and suspension records per match or team.
{
"data": [ { "id": "inj_1", "reason": "Hamstring Injury", "player": { "id": "pl_x" } } ],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
Coaches
Head coach profile details and team career history.
{
"data": [ { "id": "ch_arteta", "name": "Mikel Arteta", "nationality": "Spain" } ],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
Player Trophies
Player trophy awards and career silverware achievements.
{
"data": [ { "id": "tr_1", "league": "Premier League", "season": "2023/2024", "place": "Winner" } ],
"meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
Data Provenance by League
Where each kind of row for one competition actually comes from. Our own coverage is reported
as owned; a competition we fill from the commercial reference feed reports that
feed's name instead. Per data type, not per league, because the three do not move together —
a competition can have its reference data from one place, its results from another and its
table from a third.
| Parameter | Type | Required | Description |
|---|---|---|---|
:id | string | Yes | Competition id — lg_… or the integer form |
{
"data": {
"leagueId": "39",
"legacyId": 39,
"owned": true,
"reference": "openfootball",
"fixtures": "openfootball",
"standings": "openfootball"
},
"meta": { "killSwitch": null }
}
owned: false is why a competition answers 422 on the model endpoints
— we are relaying, not modelling. meta.killSwitch is the operator override in
force, if any, so a support conversation about "where did this row come from" has a
checkable answer.
Account Status & Quota
Query your current API key subscription tier and daily/monthly request usage counts — the same numbers as the rate-limit headers, plus the monthly ceiling that no header carries.
| Field | Description |
|---|---|
account.id | usr_… for an account key, key_… for a keyless one |
account.plan | Plan name for the key. Read from a lookup cached for an hour, so right after a plan change — and for a key with no resolvable subscription — it can report hobby. Branch on usage.*.limit, not on this string |
usage.daily | Requests served today against the plan's daily allowance |
usage.monthly | Requests served this calendar month against the plan's monthly allowance |
{
"data": {
"account": { "id": "usr_1a2b3c", "plan": "Pro" },
"usage": {
"daily": { "used": 1420, "limit": 75000 },
"monthly": { "used": 18450, "limit": 2250000 }
}
},
"meta": { "source": "kickoffapi-auth" }
}
Today's overage — requests past the plan allowance — and the cap above it. See Limits & Errors for what happens at the cap.
GDPR Export & Account Deletion
Two self-service data-rights operations, callable with the same x-api-key as
everything else. There is no third: deletion is always a request plus an
emailed confirmation, never a call a leaked key can make happen.
| Operation | Method & path | What it does |
|---|---|---|
| Export everything | POST /api/v2/account/export | Returns the account, its API keys, subscriptions, invoices, current usage and the last ten data-rights actions, in one body. One time — fetch it and store it; there is no way to ask again |
| Ask to be deleted | POST /api/v2/account/delete-request | Sends a confirmation link to the account email. Nothing is deleted yet — the token is valid for one hour |
| Confirm deletion | GET|POST /api/v2/account/delete-confirm?token=… | The page the link opens, and the endpoint it submits to. This is the only step that erases anything |
{
"data": {
"exportDate": "2026-10-03T19:20:42.000Z",
"account": { "id": "usr_1a2b3c", "email": "you@example.com", "role": "CLIENT", "hasUsedTrial": true, "emailVerified": true, "createdAt": "2026-01-04T10:00:00.000Z" },
"apiKeys": [ { "id": "key_1", "customerName": "Production", "rateLimit": 300, "isEnabled": true, "allowedOrigins": ["https://app.example.com"], "lastUsedAt": "2026-10-03T18:02:11.000Z", "apiVersion": "native", "createdAt": "2026-02-01T09:00:00.000Z" } ],
"subscriptions": [ { "id": "sub_1", "status": "active", "createdAt": "2026-02-01T09:01:00.000Z" } ],
"invoices": [ { "id": "inv_1", "amountDue": 2900, "amountPaid": 2900, "status": "paid", "invoicePdf": null, "createdAt": "2026-02-01T09:02:00.000Z" } ],
"usage": { "daily": 1420, "monthly": 18450, "overage": 0 },
"recentGdprActions": []
},
"meta": { "source": "kickoffapi-gdpr", "note": "This is a one-time export. Download and store securely." }
}
{
"data": { "message": "Deletion confirmation email sent. Check your inbox and click the link to permanently delete your account." },
"meta": { "source": "kickoffapi-gdpr", "expiresIn": "1 hour" }
}
Both need a key belonging to a registered account — a system or shared key
answers 400, because there is no "you" to export. Deletion stops billing first,
so a subscription cannot keep charging an account that no longer exists; the email is the
only place the one-hour token is issued, and it is never returned by the API.
Live Event Stream (SSE)
A long-lived text/event-stream of every live event the engine sees, as they
happen. Ultra and Enterprise — any other plan gets
403 PUSH_REQUIRES_ULTRA and should read
/fixtures?live=all instead.
Authenticating
x-api-key as on every endpoint. A browser EventSource cannot set
headers, so this one route also accepts the key as a query parameter:
?key=YOUR_KEY. It is the only route that does, and a key in a URL belongs in
server-side code, not in page source.
| Parameter | Type | Required | Description |
|---|---|---|---|
| key | string | No | API key, for a browser EventSource that cannot set headers |
| leagues | string | No | Comma-separated competition ids, e.g. 39,140. Omit for all |
| teams | string | No | Comma-separated club ids, either side of a fixture. Omit for all |
| events | string | No | Comma-separated event types, e.g. goal,card,full_time. Omit for all |
| Last-Event-ID | header | No | Resume after this stream id — everything after it is replayed, up to 500 events, before live events flow |
kickoff, goal, goal_cancelled, card,
substitution, half_time, second_half,
full_time, score_correction, odds_change.
The same ten are the webhook filter values.
event: goal
id: 1780000042123-0
data: {"id":"ev_1780000042123-0","type":"goal","createdAt":"2026-10-03T19:20:42.000Z",
"fixture":{"id":1234567,"koId":"fx_99Xy","status":"1H","minute":34},
"league":{"id":39,"name":"Premier League","season":2026},
"teams":{"home":{"id":57,"name":"Manchester United"},
"away":{"id":33,"name":"Brighton"}},
"score":{"home":1,"away":0},
"detail":{"team":{"id":57,"name":"Manchester United"},
"player":{"id":548,"name":"Bruno Fernandes"}}}
: ping
The SSE event: name is the event type, id: is the stream id to
resume from, and data: is the event object — the identical JSON a webhook body
carries, so one handler can serve both. A : ping comment every 15 seconds keeps
the connection alive through proxies; ignore it.
There are two ids in a frame and they are not the same string: the
id: line is the bare stream id (1780000042123-0) and is exactly
what you send back as Last-Event-ID, while the id inside
the event object carries an ev_ prefix
(ev_1780000042123-0). Dedupe on event.id; resume on
Last-Event-ID.
const url = `https://api.kickoffapi.com/api/v2/stream?leagues=39&events=goal,card,full_time&key=${encodeURIComponent(KEY)}`; const es = new EventSource(url); // sends Last-Event-ID for you on reconnect for (const type of ["kickoff", "goal", "card", "substitution", "half_time", "second_half", "full_time", "goal_cancelled", "score_correction", "odds_change"]) { es.addEventListener(type, (e) => onEvent(type, JSON.parse(e.data))); } es.onerror = () => { /* EventSource reconnects by itself, resuming from the last id it saw */ };
onEvent(type, event) is your handler — the same one the webhook path calls. Because EventSource reconnects and resumes from the last id it saw, your handler should be idempotent per event.id.
$ curl -N -H "x-api-key: YOUR_KEY" \ "https://api.kickoffapi.com/api/v2/stream?leagues=39&events=goal,card,full_time" # -N disables curl's own buffering, so frames arrive as they happen. # Ctrl-C closes it; the connection frees itself the moment you disconnect. event: goal id: 1780000042123-0 data: {"id":"ev_1780000042123-0","type":"goal", …} : ping
| Response | When |
|---|---|
200 | The stream. It stays open until you disconnect |
401 | No key in the header or ?key=, or the key is disabled |
403 | PUSH_REQUIRES_ULTRA — the plan does not include push |
429 | STREAM_CONNECTIONS_LIMIT — 20 concurrent streams on Ultra, 100 on Enterprise |
Opening the connection counts as one request against your per-minute and daily allowances, and it stays open against the per-account concurrent cap — share one stream between consumers rather than opening one per user.
Webhooks
Where the stream needs a connection you hold open, a webhook needs none: KickoffAPI POSTs each event to a URL you register. Same plan gate as the stream — Ultra and Enterprise — and the same event JSON, so one handler serves both. Endpoints are managed from the portal or by the routes below; they are session-authenticated, so there is no API key involved.
{ "url": "https://example.com/hooks/kickoff",
"events": ["goal", "card", "full_time"],
"leagues": [39],
"teams": [] }
POST /api/webhooks creates it and answers 201 with the endpoint
plus its secret — once. Store it now: only a sealed copy is
kept, so it cannot be read back. GET /api/webhooks lists your endpoints
(without the secret), PATCH /api/webhooks/{id} changes the URL, filters or
active flag, DELETE /api/webhooks/{id} removes it,
POST /api/webhooks/{id}/rotate-secret issues a new secret and
GET /api/webhooks/{id}/deliveries lists the last 100 attempts.
POST /api/webhooks/{id}/test delivers a synthetic event through the real
signing and delivery path, which is the only honest way to check an endpoint.
10 endpoints on Ultra, 50 on Enterprise; exceeding it is 403 webhook_limit.
const crypto = require("node:crypto"); function verify(secret, rawBody, header, toleranceSeconds = 300) { const m = /^t=(\d{9,12}),v1=([0-9a-f]{64})$/.exec(header || ""); if (!m) return false; // refuse, never parse leniently const [, t, v1] = m; if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false; // replay window const expected = crypto.createHmac("sha256", secret).update("`${t}.${rawBody}`").digest("hex"); return crypto.timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(expected, "hex")); } app.post("/hooks/kickoff", (req, res) => { if (!verify(process.env.KICKOFF_WEBHOOK_SECRET, req.rawBody, req.get("KickoffAPI-Signature"))) { return res.status(401).end(); // unsigned = unauthenticated } handle(JSON.parse(req.rawBody)); // identical to the stream's event object res.status(204).end(); });
The header is KickoffAPI-Signature: t=<unix>,v1=<hex>, where
v1 is HMAC-SHA256(secret, "{t}.{body}") over the
exact raw body — verify before parsing, and compare in constant time.
Each request also carries KickoffAPI-Event-Id and
KickoffAPI-Event-Type for logging and dedup. Deliveries are HTTPS only,
to a publicly resolvable host, with a 10-second ceiling; a 3xx is a failed delivery.
A delivery that fails is retried after 1 minute, 5 minutes, 30 minutes, 2 hours
and 6 hours. After that it keeps retrying every 6 hours until the endpoint has
failed 50 times in a row, at which point the endpoint is disabled, an email
goes out, and it stays disabled until you re-enable it — nothing is lost, the delivery
history is on GET /api/webhooks/{id}/deliveries. Handle every event
idempotently: the same event id can be delivered more than once, and it will be.
The stream is better when events must reach you within a second or two and you can hold a connection; webhooks are better when your consumer is a queue, a serverless function, or anything that should not keep a long-lived socket open. Both deliver the same object, and nothing stops an Ultra account running both.