Display a knockout bracket
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.
1. Navigate down to a stage
Section titled “1. Navigate down to a stage”# competition → seasonscurl "https://api.statscore.com/v2/seasons?token=YOUR_TOKEN&competition_id=13245&limit=5"
# season → stagescurl "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".
| Flag | Value | Next 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 Stage1, then Play Offs). Use it instead of sorting byid.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_idThree words, three meanings:
| Term | What it is |
|---|---|
| round | one column of the bracket - Quarter Finals, Semi Finals, Final |
| node | one tie inside a round |
| slot | one 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".
4. Slot state: what is actually populated
Section titled “4. Slot state: what is actually populated”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.
| State | How to detect it | Render as |
|---|---|---|
| Confirmed | participant is non-null | the team name |
| Bye | bye is true (then participant is null) | an empty slot; the opponent advances unplayed |
| Empty | participant is null and bye is false | a placeholder - "Winner of QF1" |
| Undecided | participant is null, candidates[] non-empty | the candidate names |
Two more fields per slot, and the schema types are misleading here:
progress- a boolean, not an integer.truemarks 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-nullon 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.
Building the placeholder index
Section titled “Building the placeholder index”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 slotfunction 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.
The format values
Section titled “The format values”node_event_format has no enum in the specification. The two values observed in production are:
| Value | node_event_count | Meaning |
|---|---|---|
"1_leg" | 1 | single match |
"2_legs" | 2 | home-and-away tie, aggregate decides |
Aggregating the legs
Section titled “Aggregating the legs”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 meczamifunction 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.
Aggregate does not decide the tie
Section titled “Aggregate does not decide the tie”Of the 23 completed Champions League ties, two were level on aggregate and went to a shootout:
| Tie | Leg 1 | Leg 2 | Aggregate | Advanced |
|---|---|---|---|---|
| Real Madrid - Atlético Madrid | 2-1 | 0-1 | 2-2 | Real Madrid (pens 4-2) |
| Paris Saint-Germain - Liverpool | 0-1 | 1-0 | 1-1 | PSG (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 is the authority
Section titled “progress is the authority”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:
| Round | Nodes | Nodes with exactly one progress: true |
|---|---|---|
| UCL 1/16 → Semi Finals | 30 | 30 |
| UCL Final | 1 | 0 |
| WC 1/16 → Final and 3rd Place | 32 | 32 |
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:
| Leg | Score in payload | Event result 411 Winner | Event result 412 Progress | Bracket winner_id |
|---|---|---|---|---|
| PSG 0-1 Liverpool | 0:1 | Liverpool | - | Liverpool |
| Real 2-1 Atlético | 2:1 | Real | - | Real |
| Atlético 1-0 Real (pens 2-4) | 1:0 | Atlético | Real | Real |
| Liverpool 0-1 PSG (pens 1-4) | 0:1 | PSG | PSG | PSG |
| Inter 4-3 Barcelona (a.e.t.) | 4:3 | nobody | Inter | Inter |
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
411Winner - who won regular time, the ninety minutes. Empty for both sides on any match that went to extra time or penalties. - result
412Progress - who advances. Correct for regular time, extra time, shootouts and byes. - bracket
winner_id-412where it is set, otherwise411. 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.
Detecting and labelling a shootout
Section titled “Detecting and labelling a shootout”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:
| Round | Match | Advanced |
|---|---|---|
| 1/16 Finals | Germany 1-1 Paraguay | Paraguay |
| 1/16 Finals | Netherlands 1-1 Morocco | Morocco |
| 1/16 Finals | Australia 1-1 Egypt | Egypt |
| 1/8 Finals | Switzerland 0-0 Colombia | Switzerland |
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_name | What 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 producenulltotals here. A node with no events is not "not started".bye: truesits on the empty slot, not on the team receiving the bye. Arsenal hasbye: false.progress: trueis still set on the advancing side. Readingprogresshandles byes for free; computing the winner from results does not.
The third-place playoff is its own round
Section titled “The third-place playoff is its own round”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=Spainr6 '3rd Place' autofill=false 1 node next_slot_id=null France 4-6 England progress=EnglandBut the wiring is not derivable from the tree:
semi-final node 1 → next_slot_id 34727 → Finalsemi-final node 2 → next_slot_id 34728 → FinalFinal slots: 34727, 34728 3rd Place slots: 34729, 34730The 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.
7. Putting it together
Section titled “7. Putting it together”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ływdef 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}, }Checklist for a correct bracket UI
Section titled “Checklist for a correct bracket UI”Seven things that the two production payloads prove you have to handle:
- Branch on
has_brackets- never call/bracketspeculatively. - Sort by
number, never byid. - Size legs from
events.length, never fromnode_event_count. - Read advancement from
progress, never from the aggregate. - Fall back to the deciding leg's
winner_idon the terminal round, whereprogressmay be unset. - Handle
events: []- a bye is not "not started". - Expect slots with no feeder node - the third-place playoff has none.
Errors
Section titled “Errors”| Status | Cause |
|---|---|
400 | limit outside the accepted enum on the navigation calls |
401 | missing, expired or malformed token |
403 | the endpoint is outside your contract |
404 | the stage has no bracket - expected whenever has_brackets: "no"; also a genuinely unknown stage id |
500 | server 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".
Still open
Section titled “Still open”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_statusisnullin 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.progresson the terminal round. The Champions League final leaves itfalseon 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.
- Display a league table - for stages where
show_standings: "yes" - Get one match with score and stats - reading each leg's result and
the penalty shootout (result id
7) - Build a livescore board
Full reference: GET /v2/stages/{id}/bracket ·
GET /v2/stages ·
GET /v2/seasons ·
GET /v2/standings