Skip to content

Display a knockout bracket

Verified on production20 min

A bracket is a different data model from a league table, reached through a different endpoint, and the first thing you have to get right is deciding whether the stage has one at all. Calling the bracket endpoint on a league stage returns 404 - and that 404 is a correct answer, not a fault.

The second thing to get right is that a tie is not a match. In a two-legged tie the individual results do not determine who advances, and the field that looks like it should tell you (winner_id) answers a different question. Section 6 is the important one.

Terminal window
# competition → seasons
curl "https://api.statscore.com/v2/seasons?token=YOUR_TOKEN&competition_id=13245&limit=5"
# season → stages
curl "https://api.statscore.com/v2/stages?token=YOUR_TOKEN&season_id=71198&limit=5"

/v2/stages also returns a tree, not a list. Stages sit at api.data.competition.season.stages[]:

{
"api": {
"data": {
"competition": {
"id": 13245,
"short_name": "Africa CON (W)",
"sport_id": 5,
"season": {
"id": 71198,
"name": "Africa CON (W) 2026",
"actual": "yes",
"stages": [
{
"id": 153098,
"name": "Group Stage",
"start_date": "2026-07-25",
"end_date": "2026-08-05",
"show_standings": "yes",
"has_brackets": "no",
"type": "standing",
"groups_nr": 4,
"sort": 1,
"is_current": "yes"
}
]
}
}
}
}
}

2. Decide from the stage flags, before you call anything

Section titled “2. Decide from the stage flags, before you call anything”

Two flags on a stage decide the next call. They are not interchangeable, and both can legitimately be "no".

FlagValueNext call
show_standings"yes"GET /v2/standings?object_type=stage&object_id={id} → recipe 02
has_brackets"yes"GET /v2/stages/{id}/bracket → this recipe

The stage above reports show_standings: "yes" and has_brackets: "no", plus type: "standing". It is a group stage: fetch a table, not a bracket.

Two more useful fields on the stage record:

  • sort - the intended display order of stages within a season (Group Stage 1, then Play Offs). Use it instead of sorting by id.
  • is_current - "yes" marks the stage in progress, which is the sensible default tab.

3. The structure: rounds → nodes → slots

Section titled “3. The structure: rounds → nodes → slots”

api.data is a StageBracketShow:

StageBracketShow
├── id, stage_id
├── default_node_event_format, default_node_event_count, default_round_autofill
└── rounds[] ← StageBracketRound
├── id, round_id, name, number
├── node_count, node_event_format, node_event_count, autofill
└── nodes[] ← StageBracketNode
├── id, number, next_slot_id
├── slots[] ← StageBracketNodeSlot
│ ├── id, number, bye
│ ├── participant ← StageBracketParticipant | null
│ ├── candidates[] ← StageBracketParticipant[]
│ └── series_status, progress
└── events[] ← StageBracketNodeEvent
├── id, node_id, event_id
├── home_participant_id, away_participant_id
├── home_result, away_result
└── winner_id

Three words, three meanings:

TermWhat it is
roundone column of the bracket - Quarter Finals, Semi Finals, Final
nodeone tie inside a round
slotone side of a tie

A StageBracketParticipant is deliberately thin - id, name, short_name, seed. If you need logos or acronyms, look the participant up by id via /v2/participants/{id} or from the stage's own participants[] list, which GET /v2/stages/{id} returns in full. In both captured brackets seed was null on every slot - do not build a UI that depends on it.

Here is one complete node - a real two-legged tie from the Champions League bracket:

{
"id": 22262,
"number": 1,
"next_slot_id": 44555,
"slots": [
{
"id": 44523,
"number": 1,
"candidates": [],
"participant": { "id": 136572, "name": "Juventus Football Club S.p.A.", "short_name": "Juventus", "seed": null },
"bye": false,
"series_status": null,
"progress": false
},
{
"id": 44524,
"number": 2,
"candidates": [],
"participant": { "id": 136696, "name": "Philips Sport Vereniging Eindhoven", "short_name": "PSV Eindhoven", "seed": null },
"bye": false,
"series_status": null,
"progress": true
}
],
"events": [
{ "id": 19597, "node_id": 22262, "event_id": 5826406, "home_participant_id": 136572, "away_participant_id": 136696, "home_result": "2", "away_result": "1", "winner_id": 136572 },
{ "id": 19598, "node_id": 22262, "event_id": 5826416, "home_participant_id": 136696, "away_participant_id": 136572, "home_result": "3", "away_result": "1", "winner_id": 136696 }
]
}

Read it carefully, because it contains the whole trap: Juventus won winner_id on the first leg, and PSV is the side with progress: true. PSV advanced 4-3 on aggregate. Neither winner_id alone answers "who goes through".

participant, candidates and bye are all present on every slot, but they are not equally useful, and the schema is more permissive than the data.

StateHow to detect itRender as
Confirmedparticipant is non-nullthe team name
Byebye is true (then participant is null)an empty slot; the opponent advances unplayed
Emptyparticipant is null and bye is falsea placeholder - "Winner of QF1"
Undecidedparticipant is null, candidates[] non-emptythe candidate names

Two more fields per slot, and the schema types are misleading here:

  • progress - a boolean, not an integer. true marks the side that advances out of this tie. This is the single most useful field in the payload; section 6 is about it.
  • series_status - null on all 126 captured slots. It is never populated in the data we have. Treat it as unavailable; do not branch on it, and do not let a reviewer talk you into guessing a vocabulary for it.

next_slot_id is the edge of the tree: it names the slot in the following round that this node feeds into. In both brackets every non-null next_slot_id pointed at a slot in the immediately following round - 30 of 30 checked, no skips, no back-edges. It is null on exactly the nodes that lead nowhere: the final, and the third-place playoff.

5. Render order comes from the round sequence

Section titled “5. Render order comes from the round sequence”

Rounds carry number, and nodes carry number within their round. Use those.

Do not sort by id. Node and round ids are database identifiers; nothing guarantees that Quarter Final 1 has a lower id than Semi Final 1, and a bracket redrawn after a withdrawal can allocate ids in any order.

node_count on a round tells you how many nodes to expect - useful for laying out a fixed grid before the draw fills in, so the bracket does not reflow as teams are confirmed. It matched nodes.length in both captured brackets.

autofill (per round) and default_round_autofill (bracket-wide) are booleans. In the Champions League bracket every round had autofill: true while the bracket default was true; in the World Championship bracket the default was false yet rounds 1-4 were true and the Final and 3rd Place rounds were false. So the per-round value overrides the default in both directions - resolve per round, and never read only the bracket-level default.

Build your client so that it works either way: read the slot state (section 4) rather than assuming a winner has been propagated for you.

Because candidates[] is empty in practice, the label for an unfilled slot has to come from the next_slot_id edges. One pass over the bracket gives you a reverse index:

// slot.id → węzeł poprzedniej rundy, którego zwycięzca wypełni ten slot
function buildFeederIndex(rounds) {
const feeder = new Map();
for (const r of rounds) {
for (const n of r.nodes ?? []) {
if (n.next_slot_id != null) feeder.set(n.next_slot_id, { round: r, node: n });
}
}
return feeder;
}

For an empty slot, feeder.get(slot.id) gives you the tie that feeds it - enough to render "Winner of 1/8 Finals 3" or to draw the connecting line.

6. Two-legged ties: how a tie is actually decided

Section titled “6. Two-legged ties: how a tie is actually decided”

A node carries events[], not one event. This is where the Champions League and World Championship brackets differ, and where most bracket UIs get it wrong.

node_event_format has no enum in the specification. The two values observed in production are:

Valuenode_event_countMeaning
"1_leg"1single match
"2_legs"2home-and-away tie, aggregate decides

Home and away swap between legs, which is exactly why each event repeats home_participant_id and away_participant_id rather than relying on slot order. Sum per participant id, never per side:

// dwumecz: sumujemy po id uczestnika, bo gospodarz/gość zamieniają się między meczami
function aggregate(events) {
const totals = new Map();
for (const e of events ?? []) {
for (const [pid, v] of [[e.home_participant_id, e.home_result],
[e.away_participant_id, e.away_result]]) {
if (pid == null || v == null || v === "") continue;
totals.set(pid, (totals.get(pid) ?? 0) + Number(v));
}
}
return totals;
}

Note that home_result / away_result are strings, and can be "" on a tie that has not been played. Number("") is 0, not NaN - so skipping empty strings explicitly, as above, is what keeps an unplayed tie from rendering as a 0-0 draw.

Of the 23 completed Champions League ties, two were level on aggregate and went to a shootout:

TieLeg 1Leg 2AggregateAdvanced
Real Madrid - Atlético Madrid2-10-12-2Real Madrid (pens 4-2)
Paris Saint-Germain - Liverpool0-11-01-1PSG (pens 4-1)

The shootout score is not in the bracket payload at all. home_result / away_result carry result id 2 (Result), which excludes penalties - verified on event 5854248, where the bracket shows 1:0 and the event's own results are Regular time 1:0, Penalty 2:4.

So aggregate is a display value. It cannot tell you who advanced.

progress: true marks the side that advances, whatever decided it - aggregate, shootout or bye. In both brackets it was set on exactly one slot per node, for every node in every round except the last:

RoundNodesNodes with exactly one progress: true
UCL 1/16 → Semi Finals3030
UCL Final10
WC 1/16 → Final and 3rd Place3232

What winner_id on a node event actually means

Section titled “What winner_id on a node event actually means”

This is the subtle one. winner_id on a StageBracketNodeEvent is per event, not per tie - and it is not simply "who scored more in this match". Compare four real legs against the event objects:

LegScore in payloadEvent result 411 WinnerEvent result 412 ProgressBracket winner_id
PSG 0-1 Liverpool0:1Liverpool-Liverpool
Real 2-1 Atlético2:1Real-Real
Atlético 1-0 Real (pens 2-4)1:0AtléticoRealReal
Liverpool 0-1 PSG (pens 1-4)0:1PSGPSGPSG
Inter 4-3 Barcelona (a.e.t.)4:3nobodyInterInter

Two rows carry the lesson. In row three Atlético won the ninety minutes 1:0 but Real won the shootout and the tie. In row five Inter won 4-3 in extra time and 411 is "0" for both sides.

The precise semantics:

  • result 411 Winner - who won regular time, the ninety minutes. Empty for both sides on any match that went to extra time or penalties.
  • result 412 Progress - who advances. Correct for regular time, extra time, shootouts and byes.
  • bracket winner_id - 412 where it is set, otherwise 411. So: the effective winner of that event, extra time and shootout included.

Note what this means for the bracket's own home_result / away_result: they carry result id 2, which is regular time plus extra time, with penalties excluded. Inter's 4:3 includes its extra-time goal. That is why aggregating the strings still gives the right total for a tie that went to extra time, and the wrong story for one that went to penalties.

So winner_id on the deciding leg does identify the team that goes through, but winner_id on the first leg does not, and there is nothing in the payload that labels which leg was the decider. Use the slot's progress for advancement and treat winner_id as per-match colour.

The full result ladder, and how to render 1-1 (3-4 pen.) or 4-3 a.e.t., is in recipe 03.

You can identify a penalty decision from the bracket alone, without a second call: aggregate is level, but one slot has progress: true. That is enough to render "advanced on penalties".

The same test works unchanged for single-leg rounds, where "level on aggregate" simply means the match was a draw. Four World Championship ties fall out of it:

RoundMatchAdvanced
1/16 FinalsGermany 1-1 ParaguayParaguay
1/16 FinalsNetherlands 1-1 MoroccoMorocco
1/16 FinalsAustralia 1-1 EgyptEgypt
1/8 FinalsSwitzerland 0-0 ColombiaSwitzerland

Switzerland 0-0 Colombia is the clean confirmation of the winner_id rule: nobody won the playing time, so result 411 cannot name a winner, and winner_id reports Switzerland - the shootout winner, taken from result 412.

To show the shootout score you need one call per deciding leg - GET /v2/events/{id} carries the whole ladder on each participant: result 3 (regular time), 104 (extra time), 2 (the two added together, penalties excluded) and 7 (the shootout). status_name distinguishes the three endings without any arithmetic:

status_nameWhat to render
"Finished"2-1
"Finished after extra time"4-3 a.e.t.
"Finished after penalties"1-1 (3-4 pen.)

Recipe 03 has the verified arithmetic and a readScore() helper that produces those strings.

A bye is a real, common state, not an edge case. In the Champions League bracket 8 of the 16 first-round nodes were byes - the eight teams that finished top of the league phase skipped the play-off round. A bye node looks like this:

{
"id": 22263,
"number": 2,
"next_slot_id": 44556,
"slots": [
{ "id": 44525, "number": 1, "candidates": [], "participant": { "id": 136203, "short_name": "Arsenal", "seed": null }, "bye": false, "series_status": null, "progress": true },
{ "id": 44526, "number": 2, "candidates": [], "participant": null, "bye": true, "series_status": null, "progress": false }
],
"events": []
}

Three things follow, and all three break naive code:

  • events[] is empty. Any aggregation that assumes at least one event will produce null totals here. A node with no events is not "not started".
  • bye: true sits on the empty slot, not on the team receiving the bye. Arsenal has bye: false.
  • progress: true is still set on the advancing side. Reading progress handles byes for free; computing the winner from results does not.

The World Championship bracket answers this: 3rd place is a separate round after the Final, not a second node in the final round.

r5 'Final' autofill=false 1 node next_slot_id=null Spain 1-0 Argentina progress=Spain
r6 '3rd Place' autofill=false 1 node next_slot_id=null France 4-6 England progress=England

But the wiring is not derivable from the tree:

semi-final node 1 → next_slot_id 34727 → Final
semi-final node 2 → next_slot_id 34728 → Final
Final slots: 34727, 34728 3rd Place slots: 34729, 34730

The semi-finals point only at the final. Nothing points at the third-place slots - they are populated directly by the API, and 34729/34730 have no feeder node.

So when you build the feeder index from section 5, expect slots with no feeder and do not treat that as corrupt data. Detect the third-place round by next_slot_id: null on a round that is not the last by number, or simply by rendering any round with no outgoing edges as a terminal column.

import { apiGet } from "./client.js"; // z recipe 01
/**
* Zwraca drzewo drabinki gotowe do wyrenderowania, albo null gdy etap
* nie ma drabinki. NIE wołamy /bracket spekulacyjnie - na etapie ligowym
* ten endpoint zwraca 404, co jest poprawną odpowiedzią, nie błędem.
*/
export async function getBracket(stageId) {
const stageData = await apiGet(`/v2/stages/${stageId}`);
const stage = stageData?.competition?.season?.stage;
if (!stage) throw new Error(`Stage ${stageId} not found in response tree`);
if (stage.has_brackets !== "yes") {
return {
kind: stage.show_standings === "yes" ? "standings" : "none",
stage: { id: stage.id, name: stage.name },
bracket: null, // wołający idzie do recipe 02 albo nie renderuje nic
};
}
const b = await apiGet(`/v2/stages/${stageId}/bracket`);
// nazwy/loga uzupełniamy z listy uczestników etapu - slot ma tylko id/name/short_name/seed
const known = new Map((stage.participants ?? []).map((p) => [p.id, p]));
const enrich = (p) =>
p == null ? null : {
id: p.id,
name: p.name,
shortName: p.short_name,
seed: p.seed ?? null, // w obu realnych drabinkach zawsze null
acronym: known.get(p.id)?.acronym ?? null,
hasLogo: known.get(p.id)?.logo === "yes",
};
// sumujemy po id uczestnika - gospodarz/gość zamieniają się między meczami.
// "" pomijamy: Number("") === 0 zrobiłoby z nierozegranej pary remis 0:0
const aggregate = (events) => {
const totals = new Map();
for (const e of events ?? []) {
for (const [pid, v] of [[e.home_participant_id, e.home_result],
[e.away_participant_id, e.away_result]]) {
if (pid == null || v == null || v === "") continue;
totals.set(pid, (totals.get(pid) ?? 0) + Number(v));
}
}
return totals;
};
const roundsSorted = (b.rounds ?? [])
.slice()
.sort((x, y) => (x.number ?? 0) - (y.number ?? 0)); // kolejność z number, NIE z id
// slot.id → para z poprzedniej rundy, która go wypełni (candidates[] jest w praktyce puste)
const feeder = new Map();
for (const r of roundsSorted) {
for (const n of r.nodes ?? []) {
if (n.next_slot_id != null) feeder.set(n.next_slot_id, { roundName: r.name, nodeNumber: n.number });
}
}
const rounds = roundsSorted.map((r) => ({
id: r.id,
roundId: r.round_id,
name: r.name,
number: r.number,
nodeCount: r.node_count, // layout przed losowaniem
// per-runda nadpisuje default w OBIE strony - nie czytaj tylko defaultu
plannedLegs: r.node_event_count ?? b.default_node_event_count ?? null,
format: r.node_event_format ?? b.default_node_event_format ?? null,
autofill: r.autofill ?? b.default_round_autofill ?? null,
nodes: (r.nodes ?? [])
.slice()
.sort((x, y) => (x.number ?? 0) - (y.number ?? 0))
.map((n) => {
const totals = aggregate(n.events);
const slots = (n.slots ?? [])
.slice()
.sort((x, y) => (x.number ?? 0) - (y.number ?? 0))
.map((s) => {
const state = s.participant ? "confirmed"
: s.bye === true ? "bye"
: s.candidates?.length ? "undecided"
: "empty";
const pid = s.participant?.id;
return {
id: s.id,
number: s.number,
state,
participant: enrich(s.participant),
candidates: (s.candidates ?? []).map(enrich),
// etykieta "Winner of …" z krawędzi drzewa, bo candidates[] bywa puste
placeholder: state === "empty" ? (feeder.get(s.id) ?? null) : null,
// progress = TEN bok awansuje (agregat, karne albo bye). Boolean, nie int.
progress: s.progress === true,
// series_status: null we wszystkich 126 zebranych slotach - nie rozgałęziaj się na tym
seriesStatus: s.series_status ?? null,
aggregate: pid != null ? (totals.get(pid) ?? null) : null,
};
});
// liczba legów Z DANYCH, nie z node_event_count - finał LM ma count=2 i jeden mecz
const legs = (n.events ?? []).map((e) => ({
nodeEventId: e.id,
eventId: e.event_id ?? null,
homeId: e.home_participant_id ?? null,
awayId: e.away_participant_id ?? null,
homeResult: e.home_result ?? null,
awayResult: e.away_result ?? null,
// efektywny zwycięzca TEGO meczu, z karnymi; NIE zwycięzca dwumeczu
winnerId: e.winner_id ?? null,
}));
// Awans czytamy z progress - to jedyne pole, które łapie karne i bye.
const advancing = slots.find((s) => s.progress) ?? null;
// Ostatnia runda: LM ma progress=false na obu slotach mimo rozegranego finału.
// Wtedy schodzimy do winner_id decydującego meczu.
const terminal = n.next_slot_id == null;
const fallbackWinnerId =
terminal && !advancing && legs.length > 0
? (legs[legs.length - 1].winnerId ?? null)
: null;
const winnerId = advancing?.participant?.id ?? fallbackWinnerId;
// Karne rozpoznajemy bez dodatkowego zapytania: agregat równy, a ktoś awansuje.
// Sam wynik karnych to result id 7 na evencie - osobne wywołanie.
const scores = [...totals.values()];
const levelOnAggregate = scores.length === 2 && scores[0] === scores[1];
const decidedOnPenalties = levelOnAggregate && advancing != null;
return {
id: n.id,
number: n.number,
nextSlotId: n.next_slot_id ?? null, // krawędź do następnej rundy; null = koniec gałęzi
isBye: slots.some((s) => s.state === "bye"),
legs,
slots,
winnerId,
decidedOnPenalties,
// uczciwie: nie wiemy, kto awansuje, i nie zgadujemy
undecided: winnerId == null,
};
}),
}));
return {
kind: "bracket",
stage: { id: stage.id, name: stage.name },
bracket: { id: b.id, stageId: b.stage_id, rounds },
};
}
# Python - ten sam przepływ
def get_bracket(stage_id: int):
stage_data = api_get(f"/v2/stages/{stage_id}")
stage = stage_data["competition"]["season"]["stage"]
# decyzja z flagi, nie ze złapanego 404
if stage.get("has_brackets") != "yes":
return {
"kind": "standings" if stage.get("show_standings") == "yes" else "none",
"stage": {"id": stage["id"], "name": stage.get("name")},
"bracket": None,
}
b = api_get(f"/v2/stages/{stage_id}/bracket")
known = {p["id"]: p for p in stage.get("participants", [])}
def enrich(p):
if not p:
return None
extra = known.get(p["id"], {})
return {"id": p["id"], "name": p.get("name"), "short_name": p.get("short_name"),
"seed": p.get("seed"), "acronym": extra.get("acronym"),
"has_logo": extra.get("logo") == "yes"}
def aggregate(events):
# sumujemy po id uczestnika; "" pomijamy, żeby nierozegrana para nie wyszła 0:0
totals: dict[int, int] = {}
for e in events or []:
for pid, val in ((e.get("home_participant_id"), e.get("home_result")),
(e.get("away_participant_id"), e.get("away_result"))):
if pid is None or val in (None, ""):
continue
totals[pid] = totals.get(pid, 0) + int(val)
return totals
# kolejność renderowania z number, nie z id
rounds_sorted = sorted(b.get("rounds", []), key=lambda x: x.get("number") or 0)
# slot_id → para, która go wypełni (candidates[] jest w praktyce puste)
feeder = {}
for r in rounds_sorted:
for n in r.get("nodes", []):
if n.get("next_slot_id") is not None:
feeder[n["next_slot_id"]] = {"round_name": r.get("name"), "node_number": n.get("number")}
rounds = []
for r in rounds_sorted:
nodes = []
for n in sorted(r.get("nodes", []), key=lambda x: x.get("number") or 0):
totals = aggregate(n.get("events"))
slots = []
for s in sorted(n.get("slots", []), key=lambda x: x.get("number") or 0):
if s.get("participant"):
state = "confirmed"
elif s.get("bye") is True:
state = "bye"
elif s.get("candidates"):
state = "undecided"
else:
state = "empty"
pid = (s.get("participant") or {}).get("id")
slots.append({
"id": s.get("id"), "number": s.get("number"), "state": state,
"participant": enrich(s.get("participant")),
"candidates": [enrich(c) for c in s.get("candidates") or []],
"placeholder": feeder.get(s.get("id")) if state == "empty" else None,
# progress = ten bok awansuje (agregat, karne albo bye)
"progress": s.get("progress") is True,
# series_status: null we wszystkich zebranych slotach
"series_status": s.get("series_status"),
"aggregate": totals.get(pid) if pid is not None else None,
})
# liczba legów z danych, nie z node_event_count
legs = [{
"node_event_id": e.get("id"),
"event_id": e.get("event_id"),
"home_id": e.get("home_participant_id"),
"away_id": e.get("away_participant_id"),
"home_result": e.get("home_result"),
"away_result": e.get("away_result"),
# efektywny zwycięzca TEGO meczu, z karnymi; nie zwycięzca dwumeczu
"winner_id": e.get("winner_id"),
} for e in n.get("events", [])]
advancing = next((s for s in slots if s["progress"]), None)
# ostatnia runda może nie mieć progress (finał LM) - schodzimy do winner_id
terminal = n.get("next_slot_id") is None
fallback = legs[-1]["winner_id"] if (terminal and not advancing and legs) else None
winner_id = (advancing or {}).get("participant", {}).get("id") if advancing else fallback
scores = list(totals.values())
level = len(scores) == 2 and scores[0] == scores[1]
nodes.append({
"id": n.get("id"),
"number": n.get("number"),
"next_slot_id": n.get("next_slot_id"),
"is_bye": any(s["state"] == "bye" for s in slots),
"legs": legs,
"slots": slots,
"winner_id": winner_id,
# karne: agregat równy, a ktoś awansuje. Wynik karnych = result id 7 na evencie
"decided_on_penalties": level and advancing is not None,
"undecided": winner_id is None,
})
rounds.append({
"id": r.get("id"), "round_id": r.get("round_id"), "name": r.get("name"),
"number": r.get("number"), "node_count": r.get("node_count"),
# per-runda nadpisuje default w obie strony
"planned_legs": r.get("node_event_count") or b.get("default_node_event_count"),
"format": r.get("node_event_format") or b.get("default_node_event_format"),
"autofill": r.get("autofill") if r.get("autofill") is not None
else b.get("default_round_autofill"),
"nodes": nodes,
})
return {
"kind": "bracket",
"stage": {"id": stage["id"], "name": stage.get("name")},
"bracket": {"id": b.get("id"), "stage_id": b.get("stage_id"), "rounds": rounds},
}

Seven things that the two production payloads prove you have to handle:

  1. Branch on has_brackets - never call /bracket speculatively.
  2. Sort by number, never by id.
  3. Size legs from events.length, never from node_event_count.
  4. Read advancement from progress, never from the aggregate.
  5. Fall back to the deciding leg's winner_id on the terminal round, where progress may be unset.
  6. Handle events: [] - a bye is not "not started".
  7. Expect slots with no feeder node - the third-place playoff has none.
StatusCause
400limit outside the accepted enum on the navigation calls
401missing, expired or malformed token
403the endpoint is outside your contract
404the stage has no bracket - expected whenever has_brackets: "no"; also a genuinely unknown stage id
500server error - retry with backoff

Because 404 is overloaded here, the flag check in step 2 is not an optimisation. It is the only way to tell "this stage is a league" from "this stage does not exist".

Three questions the captured data does not answer. All are safe to defer, because the transform above degrades into a visible "undecided" state rather than a wrong result.

  • A bracket captured mid-tournament. Both samples are finished tournaments, so the undrawn-slot path - candidates[], and the placeholder rendering that depends on it - is written from the schema and the tree, not from an observed payload. This is the one gap that matters for a public UI: a bracket is at its most visible right after the group stage.
  • series_status is null in every sample. Either it is unused, or it appears in a competition format we have not captured. A best-of-N series in basketball or hockey is where to look; the field name suggests exactly that.
  • progress on the terminal round. The Champions League final leaves it false on both slots while the World Championship final sets it. Worth asking whether that is intentional or a gap in the Champions League record - if it is a gap, the fallback in section 7 stops being necessary.

The transform in section 7 was run against both stored payloads: every node in both brackets resolves to a winner (0 undecided out of 63), byes and both shootout ties included.

Full reference: GET /v2/stages/{id}/bracket · GET /v2/stages · GET /v2/seasons · GET /v2/standings