Migrating to v2
Same power. Cleaner design. More endpoints.
If you built your integration on v1, this guide is everything you need to move to v2. Same authentication. Same pricing. Better data.
What changes
| v1 | v2 | |
|---|---|---|
| Data | Same canonical store — v2 is a redesign of the API, not a separate dataset. Coverage does not differ by version. | |
| IDs | Integers (39, 140) | Opaque native strings (lg_8K3mZ0pQ, tm_a1B2c3, fx_99Xy, pl_...) |
| Response envelope | { get, results, response } | { data, meta } |
| Row shape | Flat by default; nested ({ fixture, teams, goals, score }) on request via ?format=nested | Flat, always — one shape, no opt-in |
| Row field names | homeTeam, goalsHome, elapsed | home, score.home, status.short |
| Pagination | paging.current / paging.total | ?limit (default 200, max 2000) + meta.nextCursor |
| Ids accepted | Integers only | Native ids and the v1 integers — ?league=39 and ?league=lg_… resolve identically, so Step 1 is optional |
| Rate limits | Identical: per-minute and per-plan daily, same headers, same 429 bodies. See Limits & Errors | |
| Only on v2 | — | xG models, Dixon-Coles match projections, AI match previews, season simulation, Elo predictions, team form/strength ratings, the live SSE stream (/api/v2/stream) and webhooks, id resolution (/resolve, /idmap/leagues) |
| Status | Deprecated — sunsets 01 Jan 2027 | Active, current |
Step 1 — Translate your IDs (optional)
v2 ids are opaque, generated strings — not derived from the v1 integer id. But you do not have to resolve anything to start: wherever a league, team or fixture is named, v2 accepts the v1 integer id and the native id interchangeably. ?league=39 and ?league=lg_8K3mZ0pQ return the same competition, and an id v2 has never seen answers 200 with an empty list rather than a 404. Move first, resolve later, if you want the ids in your own database.
| Need | Call |
|---|---|
| Nothing — just migrate | Keep passing ?league=39. No mapping table required. |
| One id, on demand | GET /api/v2/resolve?entity=league&legacyId=39 → { "data": { "entity": "league", "legacyId": 39, "id": "lg_..." } } |
| Every league at once | GET /api/v2/idmap/leagues (optionally ?id=39,140,78, max 1000 rows) → the full legacy-id → native-id table |
If you do resolve, do it once at deploy time and cache the mapping — the ids are stable, so there is no need to call /resolve on every request.
Step 2 — Endpoint paths
# Before (v1) — integer ids, /api/v1
GET /api/v1/fixtures?league=39&season=2024
GET /api/v1/fixtures/1234567
GET /api/v1/standings?league=39&season=2024
GET /api/v1/teams?league=39&season=2024
GET /api/v1/players?league=39&season=2024
# After (v2) — native ids, /api/v2 (the integers still work in place)
GET /api/v2/fixtures?league=lg_8K3mZ0pQ&season=2024
GET /api/v2/fixtures/fx_99Xy
GET /api/v2/standings?league=lg_8K3mZ0pQ&season=2024
GET /api/v2/teams?league=lg_8K3mZ0pQ
GET /api/v2/players?league=lg_8K3mZ0pQ
# Only on v2 — see the v2 reference for full parameters
GET /api/v2/fixtures/fx_99Xy/projection # Dixon-Coles 1X2 / BTTS / O-U
GET /api/v2/predictions/elo?team1=tm_a1B2c3&team2=tm_d4E5f6
GET /api/v2/insights/season-sim?league=lg_8K3mZ0pQ
GET /api/v2/value-bets?league=lg_8K3mZ0pQ
GET /api/v2/teams/rankings
GET /api/v2/stream # live SSE — Ultra & Enterprise
Step 3 — Response format
// v1 response
{ "get": "fixtures", "results": 10,
"paging": { "current": 1, "total": 1 },
"response": [ ... ] }
// v2 response — for GET /api/v2/fixtures?limit=10
{ "data": [ ... ],
"meta": { "count": 10, "cursor": 0, "nextCursor": "10", "source": "owned", "ownedRows": 10 } }
Fixture object shape
v1 has two shapes and picks one for you, which is the most common surprise in this migration. The default is the flat row; the nested object below is what you get with ?format=nested — and ?format=flat forces the flat row back from any client, whatever else is in the headers. If your existing parser reads teams.home.name, your v1 calls were already asking for the nested shape — and moving to v2 means rewriting that parser, because v2 has exactly one shape.
// v1 flat row — THE DEFAULT with x-api-key
{ "id": 1234567, "timezone": "UTC",
"date": "2024-08-17T14:00:00.000Z", "timestamp": 1723898400,
"statusLong": "FT", "statusShort": "FT", "elapsed": null,
"leagueId": 39, "seasonYear": 2024,
"homeTeamId": 40, "homeTeam": { "id": 40, "name": "Liverpool", ... },
"awayTeamId": 33, "awayTeam": { "id": 33, "name": "Man United", ... },
"goalsHome": 2, "goalsAway": 1,
"scoreHalfHome": 1, "scoreHalfAway": 1,
"scoreFullHome": 2, "scoreFullAway": 1 }
// v1 nested object — only with ?format=nested or an alias key header
{ "fixture": { "id": 1234567 },
"teams": {
"home": { "id": 40, "name": "Liverpool" },
"away": { "id": 33, "name": "Man United" }
},
"goals": { "home": 2, "away": 1 },
"league": { "id": 39, "name": "Premier League" } }
// v2 — one shape, no opt-in
{ "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": 2024 },
"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" }
Branch on status.short — NS, 1H, HT, 2H, FT, AET, PEN, PST, CANC — rather than on status.long, which carries the same short code on most rows. See the v1 response-format section for the full nested field list.
Step 4 — Pagination
v2 pages by offset: ?limit (default 200, max 2000) and ?cursor. Pass meta.nextCursor back verbatim as the next cursor. A page that was not full answers nextCursor: null; a final page that lands exactly on a multiple of limit still reports a cursor, so stop when data is empty.
let cursor = null;
do {
const url = new URL("https://api.kickoffapi.com/api/v2/fixtures");
url.searchParams.set("league", "lg_8K3mZ0pQ");
url.searchParams.set("season", "2024");
url.searchParams.set("limit", "200");
if (cursor) url.searchParams.set("cursor", cursor);
const { data, meta } = await (await fetch(url, { headers: { "x-api-key": KEY } })).json();
handle(data);
cursor = meta.nextCursor;
} while (data.length > 0 && cursor);
Step 5 — Images
Logo and flag URLs come back fully-qualified in every response's logo / flag field — nothing to construct. Append ?size=32|64|128|256|512 and/or ?format=webp|png|jpeg to any of them for responsive, cached delivery; a request never upscales past the source image's own size.
/resolve per request.
const res = await fetch("https://api.kickoffapi.com/api/v2/idmap/leagues?id=39,140,78,135,61,88", {
headers: { "x-api-key": "YOUR_KEY" },
});
const { data } = await res.json(); // [{ legacyId, id, name }, ...] — cache `data` by legacyId
Migration checklist
- Replace
/api/v1→/api/v2in your base URL - Parse
datainstead ofresponse,meta.countinstead ofresults - Update fixture parsing:
teams.home.name→home.name,goals.home→score.home,elapsed→status.short/status.elapsed— see the v2 reference for every shape - Switch cursor-based pagination:
?limit(200/2000) +meta.nextCursorinstead ofpaging.current/paging.total - Optionally resolve your league (and team/player/fixture) ids via
/api/v2/idmap/leaguesor/api/v2/resolve— the v1 integers keep working until you do - Check your rate-limit code against the v2 Limits & Errors table — same headers, same bodies, and
X-RateLimit-*is the per-minute window whileX-RateLimit-Daily-*is the daily allowance - Test with one league first
- Adopt what v1 never had: xG, Dixon-Coles projections, AI previews, season simulation, Elo predictions, and (on Ultra) the live stream and webhooks