StatsHub Docs
API

v1 and v2 parity

Measuring whether /api/v2 returns the same data as the /api routes it replaces, pair by pair, against the real database.

/api/v2 is not a copy of /api. It changes the envelope (data, page, links), the casing, the id type (strings, and always the public provider id), the timestamps (RFC 3339) and the errors (Problem Details). None of that is a difference in the data. What this suite measures is everything else: a fixture v1 lists and v2 does not, a count that disagrees, a stat with another value, a board sorted differently, a field a v1 client reads that v2 dropped, or a v2 error where v1 answers.

It runs from packages/tests/api-contract:

pnpm --filter statshub-api-contract contract:v1-v2    # every case, against a private API built from your tree
pnpm --filter statshub-api-contract inventory:v1-v2   # rebuild the pair inventory

contract:v1-v2 wraps the run in scripts/with-test-api.sh, so it builds apps/api from the working tree, starts it on 127.0.0.1 against the real database opened read-only, and hands the run a paid (super admin) and a free test token for the signed-in cases. Both surfaces are served by that one process, so the same request order and the same data are on both sides. The machine-readable result lands in reports/v1-v2-parity.json; the console prints every failing case with its findings.

To run a slice while working on one handler, start the private API once and point the CLI at it:

bash scripts/with-test-api.sh bash -c 'cd packages/tests/api-contract && \
  bun run src/client-parity-cli.ts --only=teams-,players-roster --max-requests=2000'

--only matches a substring of the case id or of either path; commas separate alternatives. --max-requests is a hard ceiling the run refuses to exceed, --concurrency=N (1 to 8) runs cases side by side, and --json=path writes the full result.

The inventory

client-cases/v1-v2-inventory.json pairs every v2 GET with the v1 operations it replaces or partly covers. It is generated from apps/api/docs/openapi.json, openapi-v2.json and v2-capability-mappings.json, and it records for each side the Go handler and its file:line, the first-party client files that still request the path, and the ids of the cases that cover the pair. It also lists v2 GETs with no v1 counterpart and v1 GETs with no v2 counterpart.

bun run scripts/v1-v2-inventory.ts --check   # exit 1 while any pair has no case

Regenerate it whenever a route or a mapping changes; a pair that appears there without a case is a route nobody has compared.

Writing a case

Cases live in client-cases/<domain>.json. Each one names a v1 request, a v2 request, and how to compare them:

  • exact for v2 routes that delegate to the v1 handler (the bridges in internal/api/v2_bridge*.go and the views in v2_user_views.go): the bodies must be identical. legacyPath/v2Path compare a subtree, normalize applies to both sides, and volatile masks values that move between two reads a second apart — live prices, not anything a fix could change.
  • fields for designed v2 resources: each field maps a v1 selector to a v2 selector and names the normalizations that remove the wire-format change between them.
  • status for routes whose body is not JSON.

Selectors are dotted paths with [*] for every element, [0] for one, and {alias=path,…} to compare rows as tuples: data[*].{id=events.id,home=events.homeTeamId} against data[*].{id=id,home=homeTeam.id}.

Normalizations each remove exactly one representation change: number (string ids and numeric strings), unix-seconds, ms-to-seconds, utc-day, legacy-status (v2's scheduled/live/cancelled), loose-keys (case and _), boolean, string, lowercase, empty-as-null, round-N, and for collections sort (a set, not a sequence), unique and count. A field's relation is equal by default, or v2-subset when a paged v2 collection is compared with a complete v1 list. v2Pages follows links.next and joins every page's data first, which is usually the better answer.

Pair ids with care. v1 /api/team/{id} and /api/player/{id} resolve an internal id first; v2 always means the public id. Add ?idType=external to the v1 request (or use the internal id) so both sides name the same entity. Phases work the same way without the flag: v1 /api/tournament/{id}/… takes LaLiga as internal 24, and every /api/v2/phases/{phaseId}/… route takes it as public 36.

A case whose two sides return the same error is reported as inconclusive, not as a pass: it proves only that both refused a request, which usually means its ids went stale.

When the two versions answer with different statuses on purpose, declare it with expectStatus: { "legacy": 404, "v2": 200 }. Any other pair of statuses is still a finding, and when both match the bodies are compared anyway, so select the subtree they share with legacyPath/v2Path.

The database is the real one

Every case is a GET, or a v1 POST the case declares a read with legacyReadOnlyPost after the handler has been checked. The private API refuses writes at the driver, but a case that would write is still a case that should not exist.

Deliberate differences

Where v2 answers differently on purpose, the case says so rather than failing on every run. Each of these is encoded in its case, and a change to one is a change to the v2 contract.

  • An empty basketball day is a page, not a missing resource. /api/basketball/events/by-date answers 404 {"message", "data": []} on a day with no fixtures; /api/v2/basketball/fixtures answers 200 {"data": []}, like every designed v2 collection. The v1 404 is kept for the clients that already read it. Case basketball-fixtures-by-date-empty-day (expectStatus, compares data on both sides).

  • A round is listed once. v1 /api/unique-tournament/{id}/{season}/rounds groups fixtures by round and slug, so a round whose fixtures carry both a blank and a named slug is listed twice, once blank, in no order. v2 /rounds has one row per round, ordered, with the named slug. Case leagues-rounds-champions-league compares the round numbers as sets and requires every v2 (round, slug) to be one v1 lists (v2-subset).

  • A v2 last-games page holds at most 50 fixtures. v1 /api/team/{id}/last-games serves whatever limit asks for (Racing's limit=500 answers all 187 finished fixtures); v2 /teams/{teamId}/last-games serves 50, because every fixture carries both team sheets. A larger limit is served as 50 rather than refused, since released Expo builds ask for 60, and pagination.limit and pagination.totalPages report the page actually served, so a caller that wants the whole history follows the pages (app-router's src/lib/team-last-games.ts). Cases teams-last-games-limit-500-racing (v2 limit=500 against v1's own 50-row page) and teams-last-games-racing-last-page (page 4).

  • national on a v2 season row is the team's flag. v1's isNational in /api/team/{id}/tournaments-and-seasons and /api/player/{id}/tournaments-and-seasons is tournaments.is_national, which is false on all 3,677 phases in the database, so v1 never marks a competition national and app-router's club/national splits (teamDomesticLeague, the player page's national tab) read the bridged v1 field and filter nothing. v2 /teams/{id}/seasons and /players/{id}/seasons set national from the team the fixtures were played for, which is what Expo renders as "International" beside a competition. Case teams-seasons-netherlands-national-flag pins it to /api/team/{id}'s national; teams-seasons-netherlands-competitions no longer compares the v1 flag.

  • v2 refuses v1's AGScollection parameters. tournamentId, location and fixtureId are 400 unknown-query-parameter on /teams/{teamId}/player-fixture-statistics, which takes competitionId, seasonId, venue, opponentId and answers one row per player and fixture. app-router's AGS and player-card boards read the AGScollection payload, so apps/web-app-router/api-compat.js routes requests carrying those names to /api/team/{id}/players/AGScollection. Case teams-player-fixture-statistics-client-params expects 200/400; teams-player-fixture-statistics-arsenal-chelsea compares the data.

  • A finished fixture's bet-builder positions come from the played lineup. v2 /fixtures/{id}/bet-builder-lineups answers from player_statistics_event once a match has been played (source confirmed); v1 /api/event/{id}/predicted-teams-lineup reads the predicted_lineups teamsheet. Both name the same eleven, but the teamsheet swaps a central left/right pair (RCB/LCB, RCDM/LCDM, RCM/LCM) in about one pair in eleven, on either side: across 150 finished fixtures of September 2026 the two tables disagreed on 100 of 1,092 pairs, and the players' match heatmaps sided with player_statistics_event in 92 of them (for Manchester United v Manchester City, Guéhi played right of Dias). So v2 is kept, and case fixtures-detail-bet-builder-vs-predicted-lineup compares the starters without their positions. The teamsheet itself is fed by the lineup ingest, not by this API.

  • v2 commentary statistics leave out players with nothing counted. /fixtures/commentary-statistics and /fixtures/{id}/commentary-statistics drop a player whose every counter is zero; v1 /api/event/extra-stats-batch lists him with zeros (19 lines against 16 for Manchester United v Manchester City). Expo's fetchExtraStatsBatch keys its result by every fixture it asked for, so a missing line reads as no commentary figures for that player. Cases fixtures-lists-commentary-* require every v2 line to be a v1 line (v2-subset).

  • Live-score status is the fixture's status. v1 /api/event/live-scores reports the last live_event_statistics snapshot's match_status, which stays "2nd half" after full time (18 of 22 in-play rows on 25 September were finished fixtures); v2 /fixtures/live-scores prefers events.status (finished, live). The only v1 caller left is apps/web's homepage, which reads the scores and takes the status from the fixture row, so nothing renders the v1 string. Case fixtures-lists-live-scores-status checks that both sides give every fixture a status; fixtures-lists-live-scores-today compares the scores.

  • v2 player odds name the player, not the book's label. v2 /fixtures/{id}/player-odds returns players.name ("Benjamin Šeško"); v1's playerOddsMap carries the label the book used ("Benjamin Sesko"). The prices are the same: for Manchester United v Manchester City and Fulham v Manchester United every (player, line, side, book, price) v1 quotes is in v2, apart from v1 listing a book's line-less assist price and its 0.5 price as two entries where v2 keeps the lower one. Cases fixtures-detail-player-odds-* compare how many squad players are priced.

  • World Cup card boards are competition-scoped when a competition is named. v1 /api/world-cup/player-cards, /team-cards and /referees judge a hard-coded 2026 World Cup roster on each player's last 50 national-team (or club) appearances, each team's last 30 matches anywhere and each official's career. v2 /rankings/{player,team,referee}-cards with a competitionId rank the competition's own fixtures, so for the World Cup the players (73 of 100), the team figures and the officials (52 against 42) differ. A request that names no competition — which is what app-router's World Cup card hub sends, with scope=national|club|career|international — is answered with the v1 World Cup board and payload. Cases tools-{player,team,referee}-cards-world-cup compare statuses (teams: the same 48); tools-*-cards-world-cup-board-* compare the competition-less request with v1 exactly.

  • A v2 price-outlier page holds at most 100 rows. v1 /api/props/outliers serves up to 1,000 rows a request (limit); v2 /rankings/price-outliers pages at 100, the ceiling of the v2 pagination contract (/docs/api/v2/pagination-audit-and-rollout-plan), and a caller wanting the whole board reads the pages. The rows and their order are the same: case tools-price-outliers-nations-league-all-pages joins ten v2 pages and compares them with one v1 request.

  • v1 player trends read at most 2,000 props. /api/props/player-trends reads the 2,000 screener rows with the best stored L5 rate, as the production route does (.limit(2000)), so a competition-wide board is whatever survives that cut: 919 trends for the Nations League where v2 /rankings/player-prop-trends pages all 3,693. Fixture and player scopes stay under the cap and match row for row. Cases tools-player-prop-trends-*.

  • An empty favourites list is a v1 404. /api/user/{id}/favorite and /favorite-players answer 404 {"message": "No favorite … found"} for an empty list, as the production routes do; v2 /me/favorite-teams and /me/favorite-players answer 200 {"data": []}. Cases account-favorite-{teams,players}-{paid,free} (expectStatus; the test accounts own no favourites).

  • Listing bankrolls creates none on v2. v1 GET /api/tracker/bankrolls inserts a default "Units" bankroll for a caller who has none; v2 /me/bankrolls has no side effect and answers []. No first-party client reads /me/bankrolls: app-router reads the /me/bankrolls-view bridge, which keeps v1's behaviour, and its tracker copes with an empty list. On the read-only test database v1's insert fails, so cases account-bankrolls-{paid,free} expect 500/200.

  • An empty lineup-projection board has no pages on v2. v1 /api/lineups/players reports totalPages: 1 for zero rows, as the production route does (Math.max(1, …)); v2 /lineup-projections reports 0, the rule every v2 page-number collection follows (/docs/api/v2/pagination-audit-and-rollout-plan). Case fixtures-lists-lineup-projections-games leaves totalPages out, since a games filter empties once its fixtures kick off; the non-empty cases compare it.

Settled: team and player season lists

v1 and v2 used to answer "which seasons" with two different questions. The decision was v1's: every season of every competition phase the team (or any of the player's teams, and his current club) has a fixture in, the seasons it spent elsewhere included.

  • v2 /teams/{id}/seasons lists the same (competition, season) pairs as v1 /api/team/{id}/tournaments-and-seasons; fixtures and finished count the team's own fixtures, so a season it took no part in reports 0.
  • v2 /players/{id}/seasons lists the same pairs as v1 /api/player/{id}/tournaments-and-seasons; played marks the seasons with the player's own aggregate or fixture rows.
  • Expo reads both lists page by page, since a long career can run past one 100-row page (Haaland's list is 96 rows).

Cases teams-seasons-* and players-seasons-* compare the lists exactly.

Fixed in v1: /api/manager and /api/country/{slug}

Two production quirks the port had kept are fixed, by decision, so v1 and v2 agree. /api/manager lists (and POST inserts) managers rather than players, and /api/country/{slug} reads its path segment rather than ?slug=. No first-party client calls either. Cases content-managers-list, content-country-spain-path-only and content-country-england-query.

On this page