← Back to docs
v1 API v2 API

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.

BASE URLhttps://api.kickoffapi.com

01Quickstart

Grab a free key, then your first call is one line. Fetch native v2 fixtures with cursor pagination:

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

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"
}

Read the shape before you read the values:

  • Every response is { data, meta }. meta.count is how many rows came back, meta.nextCursor is the next offset or null — see Pagination.
  • status.short is the code to branch on — NS, 1H, HT, 2H, FT, AET, PEN, PST, CANC. status.long carries the same code on most rows, so "long": "FT" is not a typo.
  • A row that names its own store carries source, and meta.source always summarises the page (owned, canonical or mixed) with meta.ownedRows. A canonical row carries no per-row source, so read the page-level one. This is provenance, not an error code: licensed means 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=39 and ?league=lg_8K3mZ0pQ both resolve to the same competition, and an unknown id answers 200 with an empty data.
  • With no filter at all, that call returns what is next: the window starts at 00:00 UTC today. Add ?league=39&season=2025 for 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.

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

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

HeaderWindowMeaning
X-RateLimit-LimitPer minuteRequests allowed per minute on this key
X-RateLimit-RemainingPer minuteRequests left in the current minute — never your day's quota
X-RateLimit-ResetPer minuteUnix seconds when the minute window resets (≤ 60s away)
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
X-Request-Id—Unique request ID for tracing

Status codes

StatusMeaningAction
200OKSuccess. The body is { data, meta }, or an SSE stream on /stream
400Bad RequestMissing or malformed query parameter — the body names which
401UnauthorizedMissing or invalid x-api-key header. The message names the path
403ForbiddenThe capability is not on the plan. predictive_locked on /insights/* and /fixtures/{id}/projection; PUSH_REQUIRES_ULTRA on /stream
404Not FoundNo such id. An unknown league is not a 404 — it answers 200 with an empty list
422UnprocessableValid request, not enough finished history to model that league-season yet
429Rate LimitedRead Retry-After and the code in the body — five different walls, see below
500Server ErrorRetry 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.

429 · RATE_LIMIT_PER_MINUTE — too many requests in one minute
429 TOO MANY REQUESTS
{ "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.

429 · PLAN_LIMIT_REACHED / NO_ACTIVE_SUBSCRIPTION — the plan allowance is used up
429 TOO MANY REQUESTS
{ "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.

429 · OVERAGE_CAP_REACHED — past the allowance and past the cap above it
429 TOO MANY REQUESTS
{ "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.

429 · CREDITS_EXHAUSTED — past the allowance with no credits left
429 TOO MANY REQUESTS
{ "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.

429 · STREAM_CONNECTIONS_LIMIT — too many open streams
429 TOO MANY REQUESTS
{ "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.

403 · predictive_locked — the in-house models are a paid capability
403 FORBIDDEN
{ "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

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

ParameterTypeDefaultDescription
limitinteger200Rows per page. Maximum 2000 — larger values are clamped, not rejected
cursorinteger0Offset to start from. Pass back meta.nextCursor verbatim
countinteger—/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.

GET /api/v2/resolve

Translate Single Entity ID

Translates legacy integer IDs to KickoffAPI native string IDs (koId).

ParameterTypeRequiredDescription
entitystringYesleague | team | fixture | player
legacyIdintegerYesLegacy integer ID (e.g., 42 for Arsenal)
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "data": { "entity": "team", "legacyId": 42, "id": "tm_a1B2c3" },
  "meta": {}
}
GET /api/v2/idmap/leagues

Bulk League ID Mapping

Returns a complete mapping table translating legacy integer league IDs to native KickoffAPI IDs (lg_...).

EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "data": [
    { "legacyId": 39, "id": "lg_8K3mZ0pQ", "name": "Premier League" },
    { "legacyId": 140, "id": "lg_9X2nY1rT", "name": "La Liga" }
  ],
  "meta": { "count": 2 }
}
GET /api/v2/countries

List Countries

Retrieve all supported countries with ISO country codes and flag icons.

ParameterTypeRequiredDescription
limitintegerNoResults per page (default: 200, max: 2000)
cursorintegerNoPagination offset (default: 0)
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "data": [
    { "id": "cnt_1", "name": "England", "code": "GB", "flag": "https://cdn.kickoffapi.com/images/flags/gb.svg" }
  ],
  "meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
GET /api/v2/leagues

List Leagues

Retrieve football leagues and cup competitions filtered by country or name.

ParameterTypeRequiredDescription
countrystringNoFilter by country name (e.g. England)
searchstringNoSubstring match on the competition name
limitintegerNoResults per page (default: 200, max: 2000)
cursorintegerNoPagination offset
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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 }
}
GET /api/v2/teams

List Teams

Fetch team profiles and club logos by country.

ParameterTypeRequiredDescription
countrystringNoFilter by country name
limitintegerNoResults per page (default: 200, max: 2000)
cursorintegerNoPagination offset
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "data": [
    { "id": "tm_a1B2c3", "name": "Arsenal", "country": "England", "logo": "https://cdn.kickoffapi.com/images/teams/42.png" }
  ],
  "meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
GET /api/v2/players

List Players

Fetch player profiles with native string identifiers (pl_...).

ParameterTypeRequiredDescription
limitintegerNoResults per page (default: 200, max: 2000)
cursorintegerNoPagination offset
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "data": [
    { "id": "pl_x9Y8z7", "name": "Bukayo Saka", "firstname": "Bukayo", "lastname": "Saka", "nationality": "England" }
  ],
  "meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
GET /api/v2/teams/:id/squad

Team Squads (Wikidata CC0 Roster)

Fetch self-owned CC0 Wikidata squad rosters for a team by native ID.

EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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" }
}
GET /api/v2/players/:id/career

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.

ParameterTypeRequiredDescription
:idstringYesNative player id, e.g. pl_x9Y8z7
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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.

GET /api/v2/players/:id/predictions

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.

ParameterTypeRequiredDescription
:idstringYesNative player id
leaguestringNoCompetition id — lg_… or the integer form
seasonintegerNoSeason year. Defaults to 2024
opponentstringNoNarrow the projection to one upcoming opponent
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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.

GET /api/v2/venues

Venues

Retrieve football stadiums, locations, capacities, and venue images.

ParameterTypeRequiredDescription
searchstringNoSearch venue by name (case-insensitive)
limitintegerNoResults per page (default: 200, max: 2000)
cursorintegerNoPagination offset
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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 }
}
GET /api/v2/fixtures

Fixtures & Live Scores

Query upcoming schedules, live scores, and historical match results using native IDs.

ParameterTypeRequiredDescription
leaguestringNoNative league ID (lg_8K3mZ0pQ) or the integer ID v1 used (39) — both resolve, no mapping step
seasonintegerNoSeason year (e.g. 2026)
datedateNoFilter by date (YYYY-MM-DD)
teamstringNoNative team ID (e.g., tm_a1B2c3)
from / todateNoDate range filtering (YYYY-MM-DD)
livestringNoall (or 1/true) for in-play only, most recently kicked off first
countintegerNoReturn at most this many rows (max 50) without changing the page size nextCursor is computed from
limitintegerNoResults per page (default: 200, max: 2000)
cursorintegerNoPagination offset — pass back meta.nextCursor
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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 }
}
GET /api/v2/fixtures/:id

Single Fixture Detail

Retrieve full fixture object with embedded stored AI preview & recap text.

EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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)" }
}
GET /api/v2/fixtures/:id/events

Fixture Events

Minute-by-minute events (goals, cards, substitutions) for a specific match.

EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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 }
}
GET /api/v2/fixtures/:id/lineups

Fixture Lineups

Starting XI, tactical formation, substitutes, and head coach for a match.

EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "data": [
    { "team": { "id": "tm_a1B2c3", "name": "Arsenal" }, "formation": "4-3-3", "startXI": [ ... ], "substitutes": [ ... ] }
  ],
  "meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
GET /api/v2/fixtures/:id/statistics

Fixture Team Statistics

Match statistics (shots on goal, possession %, passes, fouls, corners, offsides) for both teams.

EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "data": [
    { "team": { "id": "tm_a1B2c3", "name": "Arsenal" }, "statistics": [ { "type": "Ball Possession", "value": "58%" } ] }
  ],
  "meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
GET /api/v2/fixtures/:id/players

Fixture Player Statistics

Individual player statistics and ratings for a single match.

EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "data": [
    { "player": { "id": "pl_saka", "name": "B. Saka" }, "rating": "8.2", "goals": 1, "assists": 1 }
  ],
  "meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
GET /api/v2/events

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.

ParameterTypeRequiredDescription
fixturestringYesFixture id — fx_… or the integer form
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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.

GET /api/v2/headtohead

Head-to-Head (H2H)

Historical match records between two specific teams using native team IDs.

ParameterTypeRequiredDescription
h2hstringYes*Format: tm_1-tm_2 (e.g. tm_a1B2c3-tm_d4E5f6)
team1 / team2stringYes*Alternative: separate team1 and team2 parameters
limitintegerNoHow many past meetings to return (default: 200, max: 2000)
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "data": [ { "id": "fx_99Xy", "date": "2026-06-27T16:30:00Z", "score": { "home": 2, "away": 1 } } ],
  "meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
GET /api/v2/fixtures/:id/projection

Match Projections (Dixon-Coles v2)

In-house Dixon-Coles time-weighted match outcome probability projection model.

EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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" }
}
GET /api/v2/fixtures/:id/markets

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.

EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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.

GET /api/v2/fixtures/:id/model-odds

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

EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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 }
}
GET /api/v2/insights/fixture

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.

ParameterTypeRequiredDescription
fixturestringYesFixture id — fx_… or the integer form
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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.

GET /api/v2/insights/accuracy

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.

ParameterTypeRequiredDescription
leaguestringYesCompetition id — lg_… or the integer form
seasonintegerNoSeason year. Defaults to the current calendar year
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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.

GET /api/v2/teams/:id/form

Team Form

Recent match form, streak sequence, goals scored/conceded, and clean sheets over last N matches.

ParameterTypeRequiredDescription
seasonintegerYesSeason year (e.g. 2026)
nintegerNoNumber of recent matches (default: 5, max: 20)
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "data": {
    "team": "tm_a1B2c3",
    "name": "Arsenal",
    "season": 2026,
    "matches": 5,
    "points": 13,
    "sequence": "WWWDW",
    "goalsScored": 12,
    "goalsConceded": 3,
    "cleanSheets": 3
  },
  "meta": { "source": "kickoffapi-derived" }
}
GET /api/v2/teams/:id/results

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.

ParameterTypeRequiredDescription
:idstringYesNative team id, e.g. tm_a1B2c3
nintegerNoHow many matches. Default 10, clamped to 50
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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.

GET /api/v2/teams/:id/streaks

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.

ParameterTypeRequiredDescription
:idstringYesNative team id
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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.

GET /api/v2/teams/:id/statistics

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.

ParameterTypeRequiredDescription
:idstringYesNative team id
leaguestringYesCompetition id — lg_… or the integer form
seasonintegerYesSeason year
RESPONSE SHAPE

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.

200 OK · APPLICATION/JSON
{
  "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.

GET /api/v2/leagues/:id/form-guide

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.

ParameterTypeRequiredDescription
:idstringYesCompetition id — lg_… or the integer form
seasonintegerYesSeason year. No default
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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.

GET /api/v2/leagues/:id/over-under

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.

ParameterTypeRequiredDescription
:idstringYesCompetition id — lg_… or the integer form
seasonintegerYesSeason year. No default
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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.

GET /api/v2/insights/strength

League Attack & Defense Strength

Poisson strength parameters (attack and defense ratings) for all teams in a league season.

ParameterTypeRequiredDescription
leaguestringYesNative league ID (e.g. lg_8K3mZ0pQ) — this one matches the native id only, so an integer answers 400
seasonintegerYesSeason year
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "data": [
    { "team": { "id": "tm_a1B2c3", "name": "Arsenal" }, "attack": 1.45, "defense": 0.65 }
  ],
  "meta": { "league": "lg_8K3mZ0pQ", "season": 2026, "model": "poisson-strength" }
}
GET /api/v2/insights/season-sim

Monte Carlo Season Simulation

Simulate remaining season matches using Dixon-Coles Monte Carlo engine to project final standing ranks and points distributions.

ParameterTypeRequiredDescription
leaguestringYesNative league ID (lg_8K3mZ0pQ) or the integer ID v1 used (39) — both resolve, no mapping step
seasonintegerYesSeason year
simsintegerNoNumber of simulations (default: 1000)
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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" }
}
GET /api/v2/fixtures/:id/preview

AI Match Previews

Generate or retrieve grounded AI match preview analysis generated by KickoffAPI's proprietary content engine.

EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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)" }
}
GET /api/v2/fixtures/:id/content

Stored Match Content (Preview & Recap)

Pure database read endpoint returning pre-generated match previews and post-match recaps without triggering LLM latency.

EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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)" }
}
GET /api/v2/xg/model

xG Model Metadata

Retrieve information about KickoffAPI's in-house Expected Goals (xG) model, frame coordinates (StatsBomb 120x80), and features.

EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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." }
}
GET / POST /api/v2/xg

Calculate Shot xG

Calculate Expected Goal value for a single shot via GET parameters or a batch array of shots via POST JSON payload.

ParameterTypeRequiredDescription
xnumberYesShot X coordinate (0-120; goal line at 120)
ynumberYesShot Y coordinate (0-80; center goal at 40)
bodyPartstringNofoot | header | other
situationstringNoopen_play | free_kick | penalty | corner
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "data": {
    "xg": 0.38,
    "distance": 12.0,
    "angleDegrees": 32.5,
    "inputs": { "x": 108, "y": 40, "bodyPart": "foot", "situation": "open_play" }
  },
  "meta": { "source": "kickoffapi-derived" }
}
GET /api/v2/standings

League Standings

Fetch league standings table for a specific league and season year.

ParameterTypeRequiredDescription
leaguestringYesNative league ID (lg_8K3mZ0pQ) or the integer ID v1 used (39) — both resolve, no mapping step
seasonintegerYesSeason year (e.g. 2026)
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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 }
}
GET /api/v2/topscorers

Top Scorers

Retrieve leading goal scorers in a league season.

ParameterTypeRequiredDescription
leaguestringYesNative league ID (lg_8K3mZ0pQ) or the integer ID v1 used (39) — both resolve, no mapping step
seasonintegerYesSeason year
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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 }
}
GET /api/v2/topassists

Top Assists

Retrieve assist leaders in a league season.

ParameterTypeRequiredDescription
leagueintegerYesThe integer league ID (39) — this endpoint parses an integer, so a lg_… id is ignored and the answer is not narrowed to that competition
seasonintegerYesSeason year
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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 }
}
GET /api/v2/topyellowcards

Top Yellow Cards

Players with the most yellow cards in a league season.

ParameterTypeRequiredDescription
leaguestringYesNative league ID (lg_8K3mZ0pQ) or the integer ID v1 used (39) — both resolve, no mapping step
seasonintegerYesSeason year
limitintegerNoRows to return (default 200, capped at 200 by this endpoint)
cursorintegerNoPagination offset
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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 }
}
GET /api/v2/topredcards

Top Red Cards

Players with the most red cards in a league season.

ParameterTypeRequiredDescription
leaguestringYesNative league ID (lg_8K3mZ0pQ) or the integer ID v1 used (39) — both resolve, no mapping step
seasonintegerYesSeason year
limitintegerNoRows to return (page default 200, capped at 200 by this endpoint)
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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 }
}
GET /api/v2/sidelined

Sidelined

Players ruled out through injury or suspension. One of player, team or fixture is required.

ParameterTypeRequiredDescription
playerstringNoNative player ID
teamstringNoNative team ID — asked through that club’s squad
fixtureintegerNoFixture ID
limitintegerNoRows to return (default 200)
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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 }
}
GET /api/v2/timezone

Timezones

Every IANA timezone name accepted elsewhere in the API. UTC is listed first.

ParameterTypeRequiredDescription
———No parameters
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "data": ["UTC", "Africa/Abidjan", "Africa/Accra", "America/New_York", "Europe/London"],
  "meta": { "count": 419, "cursor": 0, "nextCursor": null }
}
GET /api/v2/teams/rankings

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.

ParameterTypeRequiredDescription
countrystringNoFull country name (England) or three-letter code (ENG) — both work, case-insensitively. Omit for every club
limitintegerNoHow many clubs. Default 20, clamped to 100
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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.

GET /api/v2/predictions/elo

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.

ParameterTypeRequiredDescription
team1stringYesHome club id, tm_…
team2stringYesAway club id, tm_…
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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.

GET /api/v2/value-bets

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.

ParameterTypeRequiredDescription
leaguestringYesCompetition id — lg_… or the integer form
seasonintegerNoSeason year. Defaults to the current calendar year
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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.

GET /api/v2/odds

Pre-Match Odds

Retrieve pre-match betting odds across bookmakers and betting markets.

ParameterTypeRequiredDescription
fixturestringOne of the twoOne fixture — native fx_… or the legacy integer id
leaguestringOne of the twoPrices for that competition's 20 most recent fixtures. With neither fixture nor league the endpoint answers 400
bookmakerintegerNoFilter by bookmaker ID
limitintegerNoResults per page (default: 200, max: 2000)
cursorintegerNoPagination offset
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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 }
}
GET /api/v2/odds/live

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.

ParameterTypeRequiredDescription
limitintegerNoResults per page (default: 200, max: 2000)
cursorintegerNoPagination offset
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "data": [ { "id": "odd_live_1", "bookmaker": { "id": 8, "name": "Bet365" }, "values": [ { "value": "Home", "odd": "2.10" } ] } ],
  "meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
GET /api/v2/bookmakers

Reference: Bookmakers

List of supported sports betting bookmakers and logos.

EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "data": [
    { "id": 8, "name": "Bet365", "image": "https://cdn.kickoffapi.com/images/bookmakers/8.png" }
  ],
  "meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
GET /api/v2/bet-types

Reference: Bet Types

List of supported betting market categories (Match Winner, Over/Under, Both Teams to Score, etc.).

EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "data": [
    { "id": 1, "name": "Match Winner" },
    { "id": 5, "name": "Goals Over/Under" }
  ],
  "meta": { "count": 2, "cursor": 0, "nextCursor": null }
}
GET /api/v2/transfers

Player Transfers

Historical and active player transfers between teams.

ParameterTypeRequiredDescription
playerstringNoNative player ID
teamstringNoNative team ID
limitintegerNoResults per page (default: 200, max: 2000)
cursorintegerNoPagination offset
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "data": [ { "id": "tr_1", "date": "2026-07-01", "type": "Transfer", "player": { "id": "pl_rice" } } ],
  "meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
GET /api/v2/injuries

Injuries & Sidelined Players

Player injury and suspension records per match or team.

EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "data": [ { "id": "inj_1", "reason": "Hamstring Injury", "player": { "id": "pl_x" } } ],
  "meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
GET /api/v2/coaches

Coaches

Head coach profile details and team career history.

EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "data": [ { "id": "ch_arteta", "name": "Mikel Arteta", "nationality": "Spain" } ],
  "meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
GET /api/v2/trophies

Player Trophies

Player trophy awards and career silverware achievements.

EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "data": [ { "id": "tr_1", "league": "Premier League", "season": "2023/2024", "place": "Winner" } ],
  "meta": { "count": 1, "cursor": 0, "nextCursor": null }
}
GET /api/v2/leagues/:id/source

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.

ParameterTypeRequiredDescription
:idstringYesCompetition id — lg_… or the integer form
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "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.

GET /api/v2/account/status

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.

FieldDescription
account.idusr_… for an account key, key_… for a keyless one
account.planPlan 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.dailyRequests served today against the plan's daily allowance
usage.monthlyRequests served this calendar month against the plan's monthly allowance
EXAMPLE RESPONSE
200 OK · APPLICATION/JSON
{
  "data": {
    "account": { "id": "usr_1a2b3c", "plan": "Pro" },
    "usage": {
      "daily": { "used": 1420, "limit": 75000 },
      "monthly": { "used": 18450, "limit": 2250000 }
    }
  },
  "meta": { "source": "kickoffapi-auth" }
}
GET /API/V2/ACCOUNT/OVERAGE

Today's overage — requests past the plan allowance — and the cap above it. See Limits & Errors for what happens at the cap.

POST /api/v2/account/export · /delete-request

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.

OperationMethod & pathWhat it does
Export everythingPOST /api/v2/account/exportReturns 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 deletedPOST /api/v2/account/delete-requestSends a confirmation link to the account email. Nothing is deleted yet — the token is valid for one hour
Confirm deletionGET|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
EXAMPLE RESPONSES
POST /api/v2/account/export
{
  "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." }
}
POST /api/v2/account/delete-request
{
  "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.

GET /api/v2/stream

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.

ParameterTypeRequiredDescription
keystringNoAPI key, for a browser EventSource that cannot set headers
leaguesstringNoComma-separated competition ids, e.g. 39,140. Omit for all
teamsstringNoComma-separated club ids, either side of a fixture. Omit for all
eventsstringNoComma-separated event types, e.g. goal,card,full_time. Omit for all
Last-Event-IDheaderNoResume after this stream id — everything after it is replayed, up to 500 events, before live events flow
EVENT TYPES

kickoff, goal, goal_cancelled, card, substitution, half_time, second_half, full_time, score_correction, odds_change. The same ten are the webhook filter values.

FRAME FORMAT
text/event-stream
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.

JAVASCRIPT · EventSource
JAVASCRIPT
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
TERMINAL
$ 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
ResponseWhen
200The stream. It stays open until you disconnect
401No key in the header or ?key=, or the key is disabled
403PUSH_REQUIRES_ULTRA — the plan does not include push
429STREAM_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.

POST /api/webhooks

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.

REGISTER AN ENDPOINT
REQUEST BODY
{ "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.

VERIFY THE SIGNATURE — KickoffAPI-Signature
NODE.JS
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.

RETRIES

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.

STREAM OR WEBHOOK?

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.