Migrating to v2

Same power. Cleaner design. More endpoints.

⚠️ v1 sunsets 01 January 2027

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

v1v2
DataSame canonical store — v2 is a redesign of the API, not a separate dataset. Coverage does not differ by version.
IDsIntegers (39, 140)Opaque native strings (lg_8K3mZ0pQ, tm_a1B2c3, fx_99Xy, pl_...)
Response envelope{ get, results, response }{ data, meta }
Row shapeFlat by default; nested ({ fixture, teams, goals, score }) on request via ?format=nestedFlat, always — one shape, no opt-in
Row field nameshomeTeam, goalsHome, elapsedhome, score.home, status.short
Paginationpaging.current / paging.total?limit (default 200, max 2000) + meta.nextCursor
Ids acceptedIntegers onlyNative ids and the v1 integers — ?league=39 and ?league=lg_… resolve identically, so Step 1 is optional
Rate limitsIdentical: 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)
StatusDeprecated — sunsets 01 Jan 2027Active, 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.

NeedCall
Nothing — just migrateKeep passing ?league=39. No mapping table required.
One id, on demandGET /api/v2/resolve?entity=league&legacyId=39 → { "data": { "entity": "league", "legacyId": 39, "id": "lg_..." } }
Every league at onceGET /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.

Pro tip: Resolve your league ids once and cache the mapping, rather than calling /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

  1. Replace /api/v1 → /api/v2 in your base URL
  2. Parse data instead of response, meta.count instead of results
  3. Update fixture parsing: teams.home.name → home.name, goals.home → score.home, elapsed → status.short / status.elapsed — see the v2 reference for every shape
  4. Switch cursor-based pagination: ?limit (200/2000) + meta.nextCursor instead of paging.current / paging.total
  5. Optionally resolve your league (and team/player/fixture) ids via /api/v2/idmap/leagues or /api/v2/resolve — the v1 integers keep working until you do
  6. Check your rate-limit code against the v2 Limits & Errors table — same headers, same bodies, and X-RateLimit-* is the per-minute window while X-RateLimit-Daily-* is the daily allowance
  7. Test with one league first
  8. Adopt what v1 never had: xG, Dixon-Coles projections, AI previews, season simulation, Elo predictions, and (on Ultra) the live stream and webhooks