Authentication
Send your API key as the X-API-Key header on every request.
curl -H "X-API-Key: zwc_pk_…" \
https://api.zafronix.com/fifa/worldcup/v1/tournaments
A small set of endpoints work without a key for evaluation
(GET /, GET /health, GET /tournaments) — capped at 100 req/day per IP.
Get a key at /signup; free tier requires no card.
Already have a key? Sign in to your dashboard to recover keys, view usage, and manage billing.
Rate limits
Limits depend on your tier. Every response includes:
X-RateLimit-Limit— daily quota for your keyX-RateLimit-Remaining— requests left in the current 24h windowX-RateLimit-Reset— epoch seconds when the window resets
When you exceed the limit you'll get HTTP 429 with a Retry-After header. See /pricing for tier details.
Caching & ETags
Every successful GET response carries two cache validators so you don't burn quota on data that hasn't changed:
Cache-Control— tells your HTTP client (or CDN) how long the response is fresh and how long it can be served stale while revalidating.ETag— a 16-character hash of the response body. Send it back asIf-None-Matchon the next request and you'll get a304 Not Modifiedwith no body.
304 responses don't decrement your rate-limit counter. Conditional GETs are effectively free — use them aggressively.
Defaults by endpoint:
| Endpoint | Cache-Control |
|---|---|
/tournaments, /teams, /stadiums, /trivia |
public, max-age=3600, stale-while-revalidate=86400 |
| everything else (default) | public, max-age=60, stale-while-revalidate=300 |
/matches/live (coming soon) |
public, max-age=10, stale-while-revalidate=30 |
/me/usage (coming soon) |
private, no-cache |
Conditional-GET example:
# First call — pull body + ETag
curl -i -H "X-API-Key: $K" \
https://api.zafronix.com/fifa/worldcup/v1/matches?year=2026
# → 200 OK
# → ETag: "9a4f1b3c2e8d0a17"
# → Cache-Control: public, max-age=60, stale-while-revalidate=300
# Second call — pass the ETag back, get 304 (no body, no quota tick)
curl -i -H "X-API-Key: $K" \
-H 'If-None-Match: "9a4f1b3c2e8d0a17"' \
https://api.zafronix.com/fifa/worldcup/v1/matches?year=2026
# → 304 Not Modified
The ETag is a SHA-256 hash of the response body, hex-encoded, first 16 chars. It's deterministic and stable across server restarts.
Stage names
Stage labels are migrating from a mixed legacy form to snake_case. v1 is byte-stable: the existing stage field on every match keeps its raw value. We add a parallel stageNormalized field with the canonical name. v2 will swap stage to canonical and drop the alias.
Legacy (stage in v1) | Canonical (stageNormalized in v1, stage in v2) |
|---|---|
group_a … group_l | group_a … group_l (unchanged — already canonical) |
r32 | round_of_32 |
r16 | round_of_16 |
qf | quarter_final |
sf | semi_final |
thirdPlace | third_place |
final | final (unchanged) |
The ?stage= query param on /matches accepts both forms — pass either ?stage=qf or ?stage=quarter_final:
curl -H "X-API-Key: $K" \
'https://api.zafronix.com/fifa/worldcup/v1/matches?year=1986&stage=qf'
# same result as
curl -H "X-API-Key: $K" \
'https://api.zafronix.com/fifa/worldcup/v1/matches?year=1986&stage=quarter_final'
Sunset for the legacy form is no sooner than November 2026 (six months out). New code should prefer stageNormalized on read paths and the canonical names on the query param.
Flags & ISO codes
Every team object on /teams, /teams/{name}, and /tournaments/{year} carries a flag sub-object with the canonical URL the API recommends:
{
"name": "Argentina",
"iso": "ar", // ISO-3166-1 alpha-2 (current country)
"code": "ARG", // 3-letter FIFA code
...
"flag": {
"iso": "ar",
"iso3166_3": null,
"fifaCode": "ARG",
"flagUrl": "https://flagcdn.com/w160/ar.png"
}
}
For dissolved nations (Soviet Union, Yugoslavia, Czechoslovakia, East Germany, Zaire, West Germany, Dutch East Indies), iso is null and the API returns the appropriate historical-flag URL plus an iso3166_3 code where one exists:
GET /teams/Soviet%20Union
{
"name": "Soviet Union",
"flag": {
"iso": null,
"iso3166_3": "SUHH",
"fifaCode": "URS",
"flagUrl": "https://commons.wikimedia.org/wiki/Special:FilePath/Flag_of_the_Soviet_Union.svg",
"flagAttribution": "Public domain (Wikimedia Commons)"
},
...
}
This means consumers don't need their own historical-country override tables. Public flag CDNs only serve current ISO-2 codes — every Soviet Union / Yugoslavia / Zaire row would render broken without our resolution. We pin Wikimedia Commons URLs (stable, public domain or CC) for now; a future change may flip them to self-hosted SVGs at api.zafronix.com/static/flags/historical/<slug>.svg with no consumer impact.
Error shape
All errors return JSON with this consistent shape:
{
"error": "not_found",
"message": "No tournament found for year 1932.",
"request_id": "req_abc123"
}
Common error codes: unauthorized, rate_limited, not_found, invalid_request, internal_error.
Meta
GET /
Returns API name + version. Public.
{
"name": "Zafronix World Cup API",
"version": "1.1.0-preview",
"docs": "https://api.zafronix.com/docs",
"timestamp": "2026-05-04T02:02:31Z"
}
GET /health
Uptime + dataset freshness probe. Returns 503 if data files fail to load. Public.
{
"ok": true,
"tournamentsLoaded": 23,
"oldestTournament": 1930,
"newestTournament": 2026,
"triviaFactCount": 98,
"stadiumCount": 206,
"matchCount": 1068
}
Tournaments
GET /tournaments
List of every World Cup tournament with year, host, and champion. Public.
[
{ "year": 1930, "host": ["Uruguay"], "champion": "Uruguay", "edition": 1, "file": "tournaments/1930.json" },
{ "year": 1934, "host": ["Italy"], "champion": "Italy", "edition": 2, "file": "tournaments/1934.json" },
…
{ "year": 2026, "host": ["United States","Canada","Mexico"], "champion": null, "edition": 23, "file": "tournaments/2026.json" }
]
GET /tournaments/{year}
Full data for one tournament — meta, every team, every player. Auth required.
Path: year — 4-digit year, must match a tournament returned by /tournaments.
GET /fifa/worldcup/v1/tournaments/1986
{
"schemaVersion": 1,
"tournament": {
"year": 1986,
"edition": 13,
"host": ["Mexico"],
"datesIso": { "start": "1986-05-31", "end": "1986-06-29" },
"teamsCount": 24,
"matchesCount": 52,
"champion": "Argentina",
"runnerUp": "West Germany",
"thirdPlace": "France",
"topScorer": { "player": "Gary Lineker", "goals": 6 },
"bestPlayer": "Diego Maradona",
"totalGoals": 132,
"totalAttendance": 2394031
},
"teams": [ /* 24 entries with full squads */ ]
}
Teams
GET /teams?tournament=YYYY
All teams in a single tournament — same data as tournaments/{year}.teams, just shorter to fetch when you don't need the meta block. Auth.
Alternatively, use ?country= to retrieve cross-tournament appearances for a country without knowing its path name. Hyphens are treated as spaces, so both forms below are equivalent:
GET /fifa/worldcup/v1/teams?country=New-Zealand
GET /fifa/worldcup/v1/teams?country=New%20Zealand
This returns the same shape as GET /teams/{name} — appearances across every World Cup the country participated in.
GET /teams/{name}
Cross-tournament summary for one team — every WC they appeared in, their final position, and squad-level goal totals. Auth.
GET /fifa/worldcup/v1/teams/Argentina
{
"name": "Argentina",
"appearances": [
{ "year": 1930, "finalPosition": 2, "groupStage": {…}, "squadSize": 19, "goalsScored": 18 },
…
{ "year": 2022, "finalPosition": 1, "groupStage": {…}, "squadSize": 26, "goalsScored": 15 }
]
}
URL-encode names with special characters: /teams/C%C3%B4te%20d%27Ivoire.
GET /teams/{name}/roster?year=YYYY
Squad list for a specific tournament. Each player has jersey, position, club, goals, and (when known) DOB / minutes / cards. Auth.
GET /fifa/worldcup/v1/teams/Argentina/roster?year=1986
[
{
"jersey": 10,
"name": "Diego Maradona",
"fullName": "Diego Armando Maradona",
"position": "MF",
"born": "1960-10-30",
"ageAtTournament": 25,
"club": { "name": "Napoli", "country": "Italy" },
"goals": 5,
"captain": true,
"starter": true,
"heightCm": 165, # see "Player enrichment" below
"weightKg": 72,
"dominantFoot": "left",
"professional": true,
"caps": 50,
"nationalGoals": 23
},
…
]
Every player record includes these optional fields when ground truth is available:
heightCm/weightKg— listed at the tournament.dominantFoot—left|right|both.professional— boolean. Useful for pre-1980 squads where amateur status was common.caps/nationalGoals— international caps and goals at the START of the tournament.birthCountry— only set when the player was born in a country other than the one they're representing (naturalized players: Klose → Poland, Zidane → Algeria).goalBreakdown—{ leftFoot, rightFoot, header, penalty, other, ownGoals }, when per-goal data is available.
Absent = "not yet sourced", not "no data". 12 GOATs seeded end-to-end (Messi, Maradona, Pelé, R9, Beckenbauer, Cruyff, Klose, Zidane, CR7, etc.). Schema is purely additive.
Players
GET /players?q=…&limit=…
Substring search (case-insensitive) over all squads. Returns up to limit matches (default 25, max 100). Auth.
GET /players/{name}
One player's career across every WC they appeared in. Looks up by exact name OR fullName. Auth.
GET /fifa/worldcup/v1/players/Diego%20Maradona
{
"name": "Diego Maradona",
"appearances": [
{ "year": 1982, "team": "Argentina", "position": "MF", "jersey": 10, "goals": 2, "captain": false, "club": {…} },
{ "year": 1986, "team": "Argentina", "position": "MF", "jersey": 10, "goals": 5, "captain": true, "club": {…} },
{ "year": 1990, "team": "Argentina", "position": "MF", "jersey": 10, "goals": 0, "captain": true, "club": {…} },
{ "year": 1994, "team": "Argentina", "position": "MF", "jersey": 10, "goals": 1, "captain": true, "club": {…} }
],
"totalGoals": 8,
"tournamentCount": 4
}
Stadiums
GET /stadiums
Every venue that has ever hosted a World Cup match (1930-2026), 206 unique entries. Multi-WC venues are deduped — Estadio Azteca appears once with tournaments: [1970, 1986, 2026] (only stadium ever to host three WCs). Auth.
Filters (any combination):
?country=Mexico— substring match on country name OR exact ISO-2 (?country=mx).?tournament=1986— venues that hosted matches in this year.?city=Munich— case-insensitive substring on city.
GET /fifa/worldcup/v1/stadiums?country=Mexico
{
"count": 14,
"data": [
{
"id": "estadio-azteca",
"name": "Estadio Azteca",
"fifaNames": { "2026": "Mexico City Stadium" },
"city": "Mexico City",
"country": "Mexico",
"iso": "mx",
"coords": { "lat": 19.3028, "long": -99.1503 },
"capacity": 87000,
"elevationM": 2287,
"opened": 1966,
"demolished": null,
"tournaments": [1970, 1986, 2026],
"isOpenAir": true,
"notes": "Only stadium to host three WCs..."
},
…
]
}
fifaNames is an optional year-keyed map for FIFA's neutral 2026 naming (sponsorship rules). 16 of the 2026 venues have an entry; older WCs may grow this map as we backfill.
elevationM is meters above sea level, sourced from Open-Elevation against each venue's coordinates and rounded to the nearest meter. 203 of 206 stadiums are enriched; the 3 missing ones returned 0 (sea-level coastal venues). Useful for altitude-effect analyses — Estadio Toluca tops the list at 2,666 m, Olímpico Universitario 2,307 m, Estadio Azteca 2,287 m, then Estadio Cuauhtémoc 2,124 m.
GET /stadiums/{id}
Single venue. The id is a stable kebab-case slug (estadio-azteca, maracana, wembley-stadium-old, metlife-stadium). Auth.
Matches
GET /matches?year=YYYY
Every match for one tournament. year is required — the 1,068-row cross-tournament dump is too heavy for a useful default; loop years to aggregate. Auth.
Optional filters (combined with year):
?stage=final|sf|qf|r16|r32|thirdPlace|group_a|…?team=Brazil— match where home OR away matches this name.?stadiumId=estadio-azteca— venue filter.?denormalize=true— embed the full Stadium document inline on each match for one-shot rendering (maps, "where was this played" surfaces).
GET /fifa/worldcup/v1/matches?year=1986&stage=final&denormalize=true
{
"year": 1986,
"count": 1,
"data": [
{
"id": "1986-052",
"date": "1986-06-29",
"kickoff": "12:00",
"stage": "final",
"homeTeam": "Argentina",
"awayTeam": "West Germany",
"homeScore": 3,
"awayScore": 2,
"result": "3-2",
"extraTime": false,
"penalties": null,
"stadium": "Estadio Azteca",
"stadiumId": "estadio-azteca",
"city": "Mexico City",
"attendance": 114600,
"referee": { "name": "Romualdo Arppi Filho", "country": "Brazil" },
"stadiumDetails": { "id": "estadio-azteca", "coords": {…}, "tournaments": [1970,1986,2026], … }
}
]
}
For 2026 matches, scores/attendance/referee are null until played; homeRef/awayRef hold FIFA bracket placeholders ("1A" = Group A winner, "W73" = winner of match M73, "3ABCDF" = best 3rd-place from any of those groups).
GET /matches/{matchId}
Single match by id. IDs follow {year}-{ordinal} zero-padded — the 1986 final is 1986-052, the 2026 opener is 2026-001. ?denormalize=true embeds the stadium. Auth.
Every match response includes these optional fields when ground truth is available. Absent = "not yet sourced", not "no event":
weather— kickoff temperature/humidity/precip/wind/WMO code. Backfilled from Open-Meteo for 1940+ matches (911 / 1,068 ≈ 85% coverage).goals[]— per-goal events:minute,scorer,team,type(penalty / header / etc),bodyPart,assist,extraTime,note.captains— on-the-day armband:{ home, away }.penaltyShootout— kick-by-kick:{ kicks: [{ order, team, kicker, success, gk, outcome }], homeScore, awayScore, winner }.substitutions[]/cards[]— schema-ready, populated as data is sourced.
Currently seeded for the 1994 / 2006 / 2022 finals end-to-end. Schema is purely additive — every new enrichment lands without a breaking change.
GET /matches/{matchId}/history
Audit log for a single match — every score finalisation, patch, and postponement, newest first. Backs "score corrected at 21:04 UTC" surfaces. Read keys allowed. Auth.
{
"matchId": "2026-073",
"count": 2,
"events": [
{ "id": "evt_…", "matchId": "2026-073",
"actorKeyId": "zwc_sk_a1b2…",
"action": "result_patched",
"payload": { "homeScore": 3, "awayScore": 1 },
"before": { "homeScore": 2, "awayScore": 1, "result": "2-1" },
"after": { "homeScore": 3, "awayScore": 1, "result": "3-1" },
"ts": "2026-06-28T21:04:11.000Z",
"requestId": "req_…",
"idempotencyKey": "…" },
{ "id": "evt_…", "matchId": "2026-073",
"action": "result_finalized", ... }
]
}
Write authentication
Write endpoints require a write key (prefix zwc_sk_) — read keys (zwc_pk_, zwc_free_) get a 403 forbidden. Sandbox keys (zwc_skt_, P4) are restricted to year=9999. To request a write key: contact us; production write access is invite-only.
Idempotency-Key header
Every write endpoint accepts an optional Idempotency-Key header. If you send the same key + same request body within 24 hours, you get back the cached response from the original call (with Idempotent-Replay: true on the response). Same key + different body → 422 idempotency_key_conflict.
Use a fresh UUID per logical operation. Restart of our service clears the in-memory cache; we'll move to a durable store before declaring full at-most-once semantics.
POST /matches/{matchId}/result
Finalize a match scoreline. Once finalized, further changes go through PATCH. Write key.
POST /fifa/worldcup/v1/matches/2026-073/result
X-API-Key: zwc_sk_…
Idempotency-Key: 7d3b9e2c-… # optional but recommended
Content-Type: application/json
{
"homeScore": 2,
"awayScore": 1,
"extraTime": false,
"penalties": null, # required-true if extraTime + tied
"attendance": 73450,
"referee": { "name": "Stéphanie Frappart", "country": "France" },
"finalizedAt": "2026-06-28T21:04:11Z" # optional, defaults to now
}
Errors:
404 not_found— match id doesn't exist409 already_finalized— use PATCH for corrections422 validation— negative scores, penalties without extraTime, tied penalty score
PATCH /matches/{matchId}
Correct any subset of result fields after finalisation — typo fixes, late attendance, ref corrections. Write key.
PATCH /fifa/worldcup/v1/matches/2026-073
X-API-Key: zwc_sk_…
{ "attendance": 73602 }
POST /matches/{matchId}/postpone
Mark a match postponed, abandoned, or cancelled. Write key.
POST /fifa/worldcup/v1/matches/2026-073/postpone
{
"status": "postponed", # postponed | abandoned | cancelled
"rescheduledTo": "2026-07-01T20:00:00Z", # required if status=postponed
"reason": "Severe weather"
}
Referees
Aggregated history of every match official across every FIFA World Cup. Built by walking each tournament's match list and bucketing by referee name. 90% match coverage (964 / 1,068 matches have referee data on record — pre-1950 sparse, modern WCs near-complete).
GET /referees
Every referee with aggregate stats. Sorted by matches officiated (desc), then name. Auth.
Query params:
limit=N— max records returned (default 50, max 500)country=Italy— exact-match filter to one country
curl -H "X-API-Key: $K" 'https://api.zafronix.com/fifa/worldcup/v1/referees?limit=5'
{
"count": 415,
"returned": 5,
"referees": [
{
"id": "ravshan-irmatov", # URL-safe slug of the name
"name": "Ravshan Irmatov",
"country": "Uzbekistan",
"totalMatches": 11,
"years": [2010, 2014, 2018],
"tournaments": 3,
"firstYear": 2010,
"lastYear": 2018,
"byStage": {
"group_a": 1, "group_b": 1, # …per stage code
"round_of_16": 1,
"quarter_final": 2,
"semi_final": 1,
"final": 0
}
},
…
]
}
GET /referees/{id}
One referee with their complete match history, newest first. The {id} is the lowercase, accent-stripped, hyphenated slug returned by the list endpoint. Auth.
curl -H "X-API-Key: $K" \
'https://api.zafronix.com/fifa/worldcup/v1/referees/ravshan-irmatov'
{
"id": "ravshan-irmatov",
"name": "Ravshan Irmatov",
"country": "Uzbekistan",
"totalMatches": 11,
"years": [2010, 2014, 2018],
"tournaments": 3,
"byStage": { … },
"matches": [ # all matches officiated, newest first
{
"id": "2018-002",
"year": 2018,
"date": "2018-06-14",
"stage": "group_a",
"homeTeam": "Russia",
"awayTeam": "Saudi Arabia",
"homeScore": 5, "awayScore": 0,
"stadium": "Luzhniki Stadium",
"city": "Moscow"
},
…
]
}
Discovery
GET /search?q=<query>[&types=…&year=YYYY&limit=20]
Cross-entity search over teams, players, matches, stadiums, and tournaments. Substring + light stem + accent-fold (Côte d'Ivoire matches "cote ivoire"). TF-IDF scoring, max 100 results. Auth.
curl -H "X-API-Key: $K" \
'https://api.zafronix.com/fifa/worldcup/v1/search?q=maradona&types=player,match&year=1986'
{
"query": "maradona",
"count": 5,
"results": [
{ "type": "player", "id": "1986-Argentina-Diego Maradona",
"label": "Diego Armando Maradona",
"href": "/players/Diego%20Maradona",
"score": 6.78,
"preview": { "team": "Argentina", "year": 1986, "goals": 5 } },
...
]
}
GET /me/usage
Caller's own activity over the last 24 hours and 30 days. Cache-Control: private, no-cache — never cached by intermediaries. Auth.
{
"keyPrefix": "zwc_pk_a1b2…",
"keyType": "read",
"tier": "pro",
"quota": { "daily": 50000, "used": 1247, "remaining": 48753 },
"windowResetAt": "2026-05-08T00:00:00.000Z",
"last24h": {
"requests": 1247, "errors": 12,
"avgLatencyMs": 38.2, "p95LatencyMs": 142.5,
"topEndpoints": [
{ "path": "/fifa/worldcup/v1/matches", "count": 780, "percent": 62.5 },
...
]
},
"last30d": {
"requests": 38420,
"requestsByDay": [{ "date": "2026-04-08", "count": 1024 }, ...]
}
}
Counters reset on service restart. Phase 2 will move usage tracking to a durable store.
OpenAPI spec
The full API surface is published at:
https://api.zafronix.com/openapi.json— OpenAPI 3.1, no authhttps://api.zafronix.com/openapi.yaml— YAML form for tooling
Use it with openapi-generator, openapi-typescript, Postman, Insomnia, or any code-gen tool that speaks OpenAPI 3.x. A first-party typed npm SDK is on the roadmap; in the meantime openapi-typescript over the JSON form gets you most of the way.
Cursor pagination
Two endpoints support cursor pagination for cross-tournament queries:
GET /matcheswithout?year=— chronologically across the full corpus (1,068+ historical matches plus 2026 fixtures)GET /players— with or without?q=for substring search
# First page
curl -H "X-API-Key: $K" 'https://api.zafronix.com/fifa/worldcup/v1/matches?limit=100'
{
"count": 100,
"data": [...],
"pagination": {
"limit": 100,
"nextCursor": "eyJvIjoxMDAsInYiOjF9",
"hasMore": true,
"totalEstimate": 1172
}
}
# Next page — pass nextCursor back
curl -H "X-API-Key: $K" 'https://api.zafronix.com/fifa/worldcup/v1/matches?limit=100&cursor=eyJvIjoxMDAsInYiOjF9'
Cursors are opaque — don't decode them in client code. Default limit 100, max 500. Add ?order=desc to walk newest-first.
Sandbox
Overview & sandbox keys
The sandbox is a synthetic tournament at year 9999 for exercising write endpoints without touching real data. 12 groups, 48 fictitious teams (Alpha-A through Delta-L), 104 matches with placeholder kickoffs.
Sandbox keys (prefix zwc_skt_) are issued from your dashboard — sign up for any tier first (free works), then sign in and click Mint a sandbox key on the dashboard. One sandbox key per account; reusing the dashboard button returns the existing one.
Sandbox keys can read any year (1930-2026, 9999) but can only write on year=9999. Real keys (zwc_pk_, zwc_sk_) cannot mutate the sandbox — they get 403 sandbox_required on POST/PATCH against any 9999 match.
Auto-reset: the sandbox state is wiped at 00:00 UTC on the first sandbox-touched request of each new UTC day. Don't rely on sandbox state persisting across days — use POST /sandbox/reset to force a clean slate during integration testing.
GET /sandbox/status
Last-reset timestamp + counter for the current sandbox window. Sandbox key only.
{
"status": {
"lastResetAt": "2026-05-07T00:00:00.000Z",
"nextAutoResetAt": "2026-05-08T00:00:00.000Z",
"modificationsCount": 17
},
"autoResetTriggered": false
}
POST /sandbox/reset
Regenerate the year=9999 fixtures from scratch. Idempotent. Capped at 10/hour/key. Sandbox key only.
curl -X POST -H "X-API-Key: zwc_skt_…" \
https://api.zafronix.com/fifa/worldcup/v1/sandbox/reset
# → 200 { "ok": true, "status": { ... } }
Tables
GET /standings?year=YYYY[&group=A]
Computed group standings with FIFA tiebreakers applied. Re-derived per request from the underlying matches, so this works for years where match data is loaded — including in-progress live tournaments once the live endpoints land. Auth.
FIFA tiebreaker order applied:
- Points
- Goal difference (overall)
- Goals scored (overall)
- Head-to-head points (between tied teams only)
- Head-to-head goal difference
- Head-to-head goals scored
- Fair-play points (not yet wired — match-level discipline data pending)
- Drawing of lots — surfaces as
position: nullwithtiebreakerNotes
Modern 3-1-0 scoring is applied uniformly. Pre-1994 historical data used 2-1-0 in the original tournament; rely on the static groupStage.points on a team for that exact figure.
Response (all-groups form):
{
"year": 2026,
"groups": {
"A": [
{ "team": "Mexico",
"played": 3, "won": 2, "drawn": 1, "lost": 0,
"goalsFor": 5, "goalsAgainst": 1, "goalDifference": 4,
"points": 7, "position": 1, "advanced": true },
...
],
"B": [...]
}
}
For year 2026, advanced reflects the 12-group / top-2 + best-8-thirds format. For other years, top-2 of each group are flagged.
Single-group form:
curl -H "X-API-Key: $K" \
'https://api.zafronix.com/fifa/worldcup/v1/standings?year=2026&group=A'
GET /bracket?year=YYYY
Knockout bracket with placeholder→team resolution applied. Auth.
FIFA-style bracket placeholders are resolved on-the-fly:
1A,2A,3A→ group winner / runner-up / 3rd from group A (via/standings)3ABCDF→ highest-ranked 3rd-placed team among groups A, B, C, D, FW73/L73→ winner / loser of match 73 (recursive — needs match 73 finalized)
Refs that can't be resolved yet leave home/away as null while keeping homeRef/awayRef intact, so consumers can render the bracket skeleton pre-tournament.
Stages are keyed by canonical name (round_of_32, round_of_16, quarter_final, semi_final, third_place, final). The original raw stage label from the dataset is preserved in stageRaw during the P2 transition.
{
"year": 2026,
"stages": {
"round_of_32": [
{ "matchId": "2026-073", "matchNo": 73,
"stage": "round_of_32", "stageRaw": "r32",
"homeRef": "2A", "awayRef": "2B",
"home": "Mexico", "away": null,
"kickoffUtc": "2026-06-28T19:00:00.000Z",
"stadium": "Los Angeles Stadium", "city": "Inglewood",
"homeScore": null, "awayScore": null,
"winner": null, "loser": null },
...
],
"round_of_16": [...],
"quarter_final": [...],
"semi_final": [...],
"third_place": [...],
"final": [...]
}
}
Live events
Two delivery mechanisms for live match events. Same event payloads on both — pick whichever fits your stack.
- Server-Sent Events (SSE) — long-lived
GETconnection, the API streams events as they happen. Best for browser clients and any pull-stream consumer. - Webhooks — the API
POSTs each event to a URL you register. Best for server-side consumers that want push delivery.
GET /matches/stream
Live event stream as text/event-stream. One frame per emit on the server side, plus a heartbeat
comment every 15 seconds so idle proxies (nginx, Cloudflare, etc.) don’t drop the connection.
Auth.
Filters (all optional, all combinable):
?year=YYYY, ?match=<id>, ?team=<name>,
?types=match.finalized,match.postponed.
Browsers can’t set custom headers on EventSource, so the stream also accepts the API key as
?key=<your-key>. This is lower-security (the key may end up in referer logs) — for
production browser clients, proxy through a server-side endpoint that adds the X-API-Key header.
# curl: stream every event for the 2026 tournament
curl -N -H "X-API-Key: $WC_API_KEY" \
"https://api.zafronix.com/fifa/worldcup/v1/matches/stream?year=2026"
# filter to a single match's events
curl -N -H "X-API-Key: $WC_API_KEY" \
"https://api.zafronix.com/fifa/worldcup/v1/matches/stream?match=2026-001"
// Browser EventSource (note ?key= fallback for header-less auth)
const es = new EventSource(
`https://api.zafronix.com/fifa/worldcup/v1/matches/stream?year=2026&key=${YOUR_KEY}`
);
es.addEventListener('match.finalized', (e) => {
const ev = JSON.parse(e.data);
console.log('final score:', ev.payload.result, ev.payload.homeTeam, 'vs', ev.payload.awayTeam);
});
es.addEventListener('stream.opened', (e) => console.log('connected', JSON.parse(e.data)));
es.onerror = (err) => console.warn('disconnected, browser will auto-reconnect', err);
Each event line is one JSON-encoded MatchEvent — same shape as a webhook delivery body.
The id: SSE field is the stable event id (sha-256 hash of type|matchId|ts|payload);
browsers send it as Last-Event-ID on auto-reconnect. Persistent replay is on the roadmap; today
we acknowledge the header but don’t replay missed events.
Webhooks
Register a URL via the studio admin (or direct API; subscriber-side endpoints land in a follow-up release). Each
matching event becomes one HTTPS POST with HMAC-SHA256 signature and exponential-backoff retry.
Wire format
POST <your-subscription-url> HTTP/1.1
Content-Type: application/json
User-Agent: Zafronix-WC-API-Webhook/1.0
X-Zafronix-Webhook-Id: whk_<24-hex>
X-Zafronix-Event-Id: <32-hex stable id>
X-Zafronix-Event-Type: match.finalized
X-Zafronix-Delivery-Attempt: 1
X-Zafronix-Timestamp: 1715170782123
X-Zafronix-Signature-256: sha256=<hex hmac>
<raw MatchEvent JSON>
Verifying the signature
Same scheme Stripe / GitHub / Slack use — HMAC-SHA256 over <timestamp>.<raw-body>,
hex-encoded, prefixed with sha256=. The timestamp is included in the signed material to defeat
replay attacks; reject deliveries where |now − ts| > 5min.
// Node verify example. `body` MUST be the raw bytes — JSON-parsing first
// will mutate whitespace and break the HMAC.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(req, secret) {
const ts = req.headers['x-zafronix-timestamp'];
const sig = req.headers['x-zafronix-signature-256'];
if (!ts || !sig) return false;
if (Math.abs(Date.now() - Number(ts)) > 5 * 60_000) return false; // replay window
const expected = 'sha256=' + createHmac('sha256', secret)
.update(`${ts}.${req.rawBody}`).digest('hex');
const a = Buffer.from(sig);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
Retry & auto-disable
- Retry attempts: 1, 2, 3, 4, 5, 6 → 0s, 5s, 25s, 2m, 10m, 52m (~1h total window).
- Each non-2xx or network error counts as a failure; success resets the counter.
- After 20 consecutive failures, the subscription is auto-disabled. The studio admin can re-enable explicitly — clears the failure counter on re-activation.
- Acknowledge fast (return 2xx in <5 seconds) — do heavy work in a background job, not in the request handler.
Event types
Every event payload carries top-level { type, id, matchId, year, ts, payload } — id is the stable hash you can dedupe on. New event types may be added; consumers should ignore unknown types gracefully.
match.finalized
Score posted, result computed (POST /matches/{id}/result succeeded).
{
"type": "match.finalized",
"id": "8a3f1c…",
"matchId": "2026-001",
"year": 2026,
"ts": "2026-06-11T19:30:00Z",
"payload": {
"homeTeam": "Mexico", "awayTeam": "USA",
"homeScore": 2, "awayScore": 1, "result": "2-1",
"extraTime": false, "penalties": null,
"stage": "group_a",
"actor": "actor:f1c8…"
}
}
match.patched
Finalized match corrected. Includes a field-level diff so consumers can render “score corrected from 2-1 to 3-1” without a separate /history call.
{
"type": "match.patched",
"id": "9b1e2d…",
"matchId": "2026-001",
"year": 2026,
"ts": "2026-06-11T20:05:00Z",
"payload": {
"homeTeam": "Mexico", "awayTeam": "USA",
"changes": {
"homeScore": { "from": 2, "to": 3 },
"result": { "from": "2-1", "to": "3-1" }
},
"actor": "actor:f1c8…"
}
}
match.postponed
Status flipped to postponed, abandoned, or cancelled.
{
"type": "match.postponed",
"id": "5c2b8e…",
"matchId": "2026-005",
"year": 2026,
"ts": "2026-06-12T15:00:00Z",
"payload": {
"homeTeam": "Brazil", "awayTeam": "Argentina",
"status": "postponed",
"rescheduledTo": "2026-06-13T17:00:00Z",
"reason": "Stadium roof damage",
"actor": "actor:f1c8…"
}
}
Other
GET /trivia?year=YYYY&category=X
Curated facts. Filters: year (matches scalar OR array), category (e.g. firsts, format, award). Auth.
GET /on-this-day?date=MM-DD
Every World Cup match that ever played on a given calendar date, plus tournaments that opened/closed on that date and any trivia facts that mention it. Defaults to today (UTC) when date is omitted. Accepts MM-DD for the cross-history view or YYYY-MM-DD to filter to one tournament. Built for content engines — daily X bots, RSS, "On this day in WC history" newsletters. Auth.
GET /fifa/worldcup/v1/on-this-day?date=06-25
{
"date": "06-25",
"monthName": "June",
"day": 25,
"counts": { "matches": 7, "tournaments": 0, "facts": 1 },
"matches": [
{ "id": "1950-007", "year": 1950, "stage": "group_3", "homeTeam": "Sweden",
"awayTeam": "Italy", "score": "3-2", "stadium": "Estádio do Pacaembu",
"city": "São Paulo", "yearsAgo": 76 },
…
],
"tournaments": [],
"facts": […]
}
The companion public RSS feed at /feeds/on-this-day.xml requires no auth and updates daily.
GET /compare?years=1986,2022
Side-by-side metrics for 1-8 tournaments. Useful for analytics dashboards. Auth.
GET /aggregates/players?years=…[&hemisphere=N|S]
Position counts, birth-month distribution per position, confederation breakdown, north/south hemisphere split. Auth.
Query params:
years=Y1,Y2,…— narrow rollups to specific tournaments (default: all).hemisphere=Norhemisphere=S— filter to teams from one hemisphere. Birth-month distribution differs by hemisphere because academy/school-year cutoffs differ — the Relative Age Effect (Q1-birthday bias) lands on different months. Filtered response uses team-country hemisphere as proxy (naturalized players are misclassified by this proxy at ~10% noise).
GET /aggregates/champions
Title counts per country + decade-by-decade winners. Auth.
Machine-readable OpenAPI spec available on request — contact us. Found a bug or need a feature? Same form.