Show a match timeline
A timeline looks like the easiest thing in this API and is not. Incidents arrive in three different shapes depending on which endpoint you ask, the time field is not what you expect, the ordering you would reach for first is wrong, and entries can be corrected or removed after the fact.
This recipe builds a timeline that survives all four.
1. Pick your source
Section titled “1. Pick your source”Three endpoints return incidents for one match. They do not return the same thing.
| Endpoint | api.data shape | Schema | Fields |
|---|---|---|---|
GET /v2/events/{id} | …event.events_incidents[] | EventIncidentDTO | 20 |
GET /v2/events/{id}/incidents | a bare array | EventIncidentDTO | 20 |
GET /v2/events/{id}/important-incidents | event_incidents[] | EventIncidentItem | 43 |
Two of those three differences will break a naive client.
The envelope differs. /v2/events/{id}/incidents puts the array directly in api.data, with no
wrapper key. /v2/events/{id}/important-incidents wraps it in event_incidents. Real responses
from an event with no incidents yet:
// GET /v2/events/6640628/incidents{ "api": { "method": { "name": "events-incidents", "total_items": 0 }, "data": [] } }
// GET /v2/events/6640628/important-incidents{ "api": { "method": { "name": "events.important-incidents.index", "total_items": 0 }, "data": { "event_incidents": [] } } }For a full rendered timeline use events.show: you get incidents, statistics and participants in
one round trip. Reach for /incidents only when you need its filters (deleted, confirmation,
sort_type, participant_id) or paging through a very long list.
2. One incident, field by field
Section titled “2. One incident, field by field”{ "id": "5-337932808", "incident_id": 413, "incident_name": "Goal", "participant_id": 4053, "participant_name": "Jagiellonia Bialystok", "subparticipant_id": 776281, "subparticipant_name": "Nik Prelec", "event_status_id": 33, "event_status_name": "1st half", "event_time": "32:21", "for": "own", "confirmation": null, "attribute_ids": [], "properties": [], "additional_info": [], "ut": 1785513708, "parent_id": null}| Field | Use it for |
|---|---|
id | ordering and deduplication - a string, sport-prefixed ("5-…") |
incident_id | what happened; look it up in /v2/incidents?sport_id=… |
event_time | match clock, "MM:SS" - see step 3 |
event_status_id + event_status_name | which phase the clock refers to |
participant_id / subparticipant_name | team and player |
for | all · own · rival · none - who the incident counts for |
confirmation | confirmed · tbd · cancelled - see step 6 |
parent_id | links a child incident to the one it refines |
ut | when the record was written, not when it happened |
additional_info | assistant_id, assistant_name, goalkeeper_id, secondary_assistant_* |
additional_info is where the assist lives. If you render "Goal - Prelec (assist: …)", that name
comes from additional_info.assistant_name, not from a second incident.
3. Read the time from event_time - and never alone
Section titled “3. Read the time from event_time - and never alone”There is no minute field on EventIncidentDTO. Time is event_time, a "MM:SS" string,
populated on 100% of incidents (371 out of 371 across seven finished matches: Ekstraklasa, Copa
Sudamericana, MLS All Star, World Championship).
event_time accumulates from the first whistle - but it is reset to the nominal period
boundary when a period restarts. A real Ekstraklasa sequence:
event_time | event_status_name | Incident |
|---|---|---|
00:00 | Not started | Match about to start · Kick off · 1st half started |
14:57 | 1st half | Corner |
37:38 | 1st half | Yellow card |
45:03 | 1st half | Added time |
49:01 | 1st half | Halftime |
45:00 | Halftime | 2nd half started ← reset to the nominal boundary |
82:01 | 2nd half | Goal |
90:28 | 2nd half | Added time |
94:53 | 2nd half | Finished regular time |
The first half ran to 49:01, then 2nd half started reported 45:00. So:
event_timeis not monotonic across the list. It steps backwards at every restart.45:00on its own is ambiguous.45:00+Halftimeis not. Every incident carries its phase inevent_status_id/event_status_name- always render the pair, and always branch on the phase, never on the raw number.
4. Sort by id, not by event_time
Section titled “4. Sort by id, not by event_time”id is a string on EventIncidentDTO ("5-337932808"), so use a string comparison, not
numeric subtraction. On /incidents you can also let the server do it: sort_type=id&sort_order=asc
(the other accepted sort_type is occurred_at, which is delivery time - not match time).
Do not sort by ut or occurred_at either. Those are wall-clock write times; a correction entered
at half-time carries a later ut than a goal scored before it.
5. Deduplicate by id
Section titled “5. Deduplicate by id”The same incident can be delivered more than once - on a re-poll, after a correction, or across an
overlapping page boundary. Keep a Map keyed by id and let the newest copy win, choosing by ut
so an older redelivery never overwrites a newer correction.
const byId = new Map();for (const i of incoming) { const prev = byId.get(i.id); if (!prev || (i.ut ?? 0) >= (prev.ut ?? 0)) byId.set(i.id, i);}6. Handle corrections, cancellations and deletions
Section titled “6. Handle corrections, cancellations and deletions”This is the part most integrations get wrong. There are three separate mechanisms, and a disallowed goal can reach you through any of them.
1 - The incident disappears. A cancelled incident can be removed from the list entirely. Your previous render still shows a goal that the API no longer reports.
2 - confirmation changes. The field is an enum: tbd → confirmed or cancelled. During a
VAR review a goal can sit at tbd for a minute. Soccer also has explicit possible incident types
for exactly this window:
incident_id | Incident |
|---|---|
1827 | Possible goal |
1829 / 1830 | Possible card / Possible card cancelled |
1832 / 1831 | Possible penalty / Possible penalty cancelled |
1834 / 2753 | Possible VAR / VAR ended |
3 - An explicit cancellation incident arrives. Soccer has 424 "Goal cancelled" as a
first-class incident type. So a disallowed goal may show up as a new entry rather than as the
removal of the old one.
Two filters on /v2/events/{id}/incidents help you reason about all of this:
| Parameter | Values | Effect |
|---|---|---|
deleted | yes / no | include or exclude entries the API has removed |
confirmation | tbd / confirmed / cancelled | narrow to one confirmation state |
updated | yes / no | narrow to entries that have been changed |
⚠️ The exact semantics of deleted=yes - whether it returns only deleted entries or adds them to
the live ones, and which field then marks them as deleted - is not visible in the captured responses
or in the schema. Confirm with the API team before relying on it. The safe pattern that needs no
such knowledge is the one above: refetch, re-derive, re-render.
7. Pair the substitutions
Section titled “7. Pair the substitutions”A substitution arrives as two incidents: 450 "Substitution out" and 452 "Substitution in",
carrying the same event_time and the same participant_id. Rendering them raw gives two rows
for one change.
Pair on participant_id + event_time, and take the player names from subparticipant_name on each
half of the pair.
8. Added time is an incident, not arithmetic
Section titled “8. Added time is an incident, not arithmetic”Stoppage time is incident_id: 449 ("Added time"), emitted at the end of each period. You do not
compute it from the clock. Confirmed on Ekstraklasa: 449 at 45:03 in the first half and at
90:28 in the second.
To render "90+4" for an incident, compare its minute against the nominal boundary of its phase:
minute ≤ boundary → "82'"minute > boundary → "90+4'"For soccer the boundaries are 45 / 90 / 105 / 120, keyed by event_status_id
(33 1st half, 34 2nd half, 35 ET 1st half, 36 ET 2nd half).
9. Use game_break to suspend in-play markets
Section titled “9. Use game_break to suspend in-play markets”Every incident type carries a game_break flag in /v2/incidents?sport_id=…. "yes" means the
incident interrupts play - the signal to pause a UI clock or suspend in-play markets.
Soccer incidents with game_break: "yes":
incident_id | Incident |
|---|---|
420 | Penalty |
445 | Halftime |
437 | Finished regular time |
434 / 435 / 436 | Finished after extra time / penalties / awarded win |
438 | Start delayed |
439 / 440 / 441 / 442 | Cancelled / Postponed / Interrupted / Abandoned |
446 / 447 / 448 | Waiting for extra time / Extra time halftime / Waiting for penalty |
451 | To finish |
1834 / 2753 | Possible VAR / VAR ended |
Note that game_break and important are independent of a third flag, important_for_trader. In
soccer there are more trader-relevant incidents (63) than generally important ones (42), because
a trader needs signals like a dangerous free kick that a public timeline would not show.
10. Putting it together
Section titled “10. Putting it together”import { apiGet } from "./client.js"; // z recipe 01
const ADDED_TIME = 449;const SUB_OUT = 450;const SUB_IN = 452;const GOAL_CANCELLED = 424;
// Nominalne granice okresów w piłce, w minutach - API NIE zwraca długości okresu.// Klucz to event_status_id: 33 = 1st half, 34 = 2nd half, 35/36 = połowy dodatkowego czasu.const SOCCER_BOUNDARY = { 33: 45, 34: 90, 35: 105, 36: 120 };
/** Zbiór incident_id przerywających grę - pobierany, nie hardkodowany. */export async function loadBreakingIncidents(sportId) { const { incidents } = await apiGet("/v2/incidents", { sport_id: sportId, limit: 500 }); return new Set(incidents.filter((i) => i.game_break === "yes").map((i) => i.id));}
/** "32:21" + faza → "33'" albo "90+4'". */function displayMinute(eventTime, statusId) { if (!eventTime) return null; const [mm, ss] = eventTime.split(":").map(Number); if (Number.isNaN(mm)) return null; // konwencja piłkarska: 32:21 pokazujemy jako 33' const minute = ss > 0 ? mm + 1 : mm; const boundary = SOCCER_BOUNDARY[statusId]; if (boundary == null) return `${minute}'`; // inny sport / faza bez granicy return minute > boundary ? `${boundary}+${minute - boundary}'` : `${minute}'`;}
/** * Buduje oś czasu gotową do wyrenderowania. * Zwraca płaską, posortowaną listę wierszy plus grupowanie po fazach meczu. */export async function getTimeline(eventId, { breaksPlay = new Set() } = {}) { const data = await apiGet(`/v2/events/${eventId}`); const event = data?.competition?.season?.stage?.group?.event; if (!event) throw new Error(`Event ${eventId} not found in response tree`);
// 1 - deduplikacja po id; przy duplikacie wygrywa nowszy ut (korekta bije redostawę) const byId = new Map(); for (const i of event.events_incidents ?? []) { const prev = byId.get(i.id); if (!prev || (i.ut ?? 0) >= (prev.ut ?? 0)) byId.set(i.id, i); }
// 2 - sortowanie po id (STRING), nie po event_time: event_time resetuje się na starcie połowy const sorted = [...byId.values()].sort((a, b) => String(a.id).localeCompare(String(b.id)));
// 3 - odrzucamy jawnie anulowane; "tbd" zostawiamy i oznaczamy jako niepotwierdzone const visible = sorted.filter((i) => i.confirmation !== "cancelled");
// 4 - parowanie zmian: 450 (out) + 452 (in) mają ten sam event_time i participant_id const subKey = (i) => `${i.participant_id}@${i.event_time}`; const subsOut = new Map(visible.filter((i) => i.incident_id === SUB_OUT).map((i) => [subKey(i), i])); const consumed = new Set();
const rows = []; for (const i of visible) { if (i.incident_id === SUB_OUT && subsOut.has(subKey(i))) { // wiersz zbudujemy przy incydencie 452; jeśli 452 nie przyjdzie, zostanie sierota (niżej) continue; }
const row = { id: i.id, incidentId: i.incident_id, name: i.incident_name, // czas ZAWSZE razem z fazą - "45:00" bez fazy jest dwuznaczne time: i.event_time, phaseId: i.event_status_id, phase: i.event_status_name, display: displayMinute(i.event_time, i.event_status_id), team: i.participant_name || null, teamId: i.participant_id ?? null, player: i.subparticipant_name || null, assist: i.additional_info?.assistant_name || null, for: i.for, // all | own | rival | none unconfirmed: i.confirmation === "tbd", cancelledGoal: i.incident_id === GOAL_CANCELLED, addedTime: i.incident_id === ADDED_TIME, breaksPlay: breaksPlay.has(i.incident_id), // sygnał do zawieszenia rynków in-play };
if (i.incident_id === SUB_IN) { const out = subsOut.get(subKey(i)); if (out) { consumed.add(out.id); row.name = "Substitution"; row.playerIn = i.subparticipant_name || null; row.playerOut = out.subparticipant_name || null; row.player = null; } } rows.push(row); }
// 5 - sieroty: 450 bez pasującego 452 (np. urazowe zejście) trafiają do osi jako osobny wiersz for (const i of visible) { if (i.incident_id === SUB_OUT && !consumed.has(i.id)) { rows.push({ id: i.id, incidentId: i.incident_id, name: i.incident_name, time: i.event_time, phaseId: i.event_status_id, phase: i.event_status_name, display: displayMinute(i.event_time, i.event_status_id), team: i.participant_name || null, player: i.subparticipant_name || null, breaksPlay: breaksPlay.has(i.incident_id), }); } } rows.sort((a, b) => String(a.id).localeCompare(String(b.id)));
// 6 - grupowanie po fazie do nagłówków sekcji; kolejność faz bierzemy z kolejności wierszy const phases = []; for (const r of rows) { const last = phases[phases.length - 1]; if (!last || last.id !== r.phaseId) phases.push({ id: r.phaseId, name: r.phase, rows: [r] }); else last.rows.push(r); }
return { eventId: event.id, // zegar pokazujemy tylko na żywo - zakończony mecz zwraca clock_time: 0 live: event.status_type === "live", status: { id: event.status_id, name: event.status_name, type: event.status_type }, // heurystyka: gra jest przerwana, jeśli NAJNOWSZY incydent ma game_break: "yes" playSuspended: event.status_type === "live" && (rows.at(-1)?.breaksPlay ?? false), rows, phases, };}# Python - ten sam przepływADDED_TIME, SUB_OUT, SUB_IN, GOAL_CANCELLED = 449, 450, 452, 424SOCCER_BOUNDARY = {33: 45, 34: 90, 35: 105, 36: 120}
def load_breaking_incidents(sport_id: int) -> set[int]: data = api_get("/v2/incidents", sport_id=sport_id, limit=500) return {i["id"] for i in data["incidents"] if i.get("game_break") == "yes"}
def _display_minute(event_time: str | None, status_id): if not event_time or ":" not in event_time: return None mm, ss = (int(x) for x in event_time.split(":")[:2]) minute = mm + 1 if ss > 0 else mm # konwencja piłkarska: 32:21 → 33' boundary = SOCCER_BOUNDARY.get(status_id) if boundary is None: return f"{minute}'" return f"{boundary}+{minute - boundary}'" if minute > boundary else f"{minute}'"
def get_timeline(event_id: int, breaks_play: set[int] | None = None): breaks_play = breaks_play or set() data = api_get(f"/v2/events/{event_id}") ev = data["competition"]["season"]["stage"]["group"]["event"]
# 1 - deduplikacja po id, nowszy ut wygrywa by_id: dict[str, dict] = {} for i in ev.get("events_incidents", []): prev = by_id.get(i["id"]) if prev is None or (i.get("ut") or 0) >= (prev.get("ut") or 0): by_id[i["id"]] = i
# 2 - sortowanie po id jako string; event_time resetuje się na starcie połowy ordered = sorted(by_id.values(), key=lambda i: str(i["id"])) # 3 - anulowane odpadają, "tbd" zostaje oznaczone visible = [i for i in ordered if i.get("confirmation") != "cancelled"]
# 4 - parowanie zmian po participant_id + event_time sub_key = lambda i: (i.get("participant_id"), i.get("event_time")) subs_out = {sub_key(i): i for i in visible if i.get("incident_id") == SUB_OUT} consumed: set[str] = set()
rows = [] for i in visible: if i.get("incident_id") == SUB_OUT and sub_key(i) in subs_out: continue row = { "id": i["id"], "incident_id": i.get("incident_id"), "name": i.get("incident_name"), "time": i.get("event_time"), # zawsze razem z fazą "phase_id": i.get("event_status_id"), "phase": i.get("event_status_name"), "display": _display_minute(i.get("event_time"), i.get("event_status_id")), "team": i.get("participant_name") or None, "player": i.get("subparticipant_name") or None, "assist": (i.get("additional_info") or {}).get("assistant_name") if isinstance(i.get("additional_info"), dict) else None, "for": i.get("for"), "unconfirmed": i.get("confirmation") == "tbd", "cancelled_goal": i.get("incident_id") == GOAL_CANCELLED, "added_time": i.get("incident_id") == ADDED_TIME, "breaks_play": i.get("incident_id") in breaks_play, } if i.get("incident_id") == SUB_IN and sub_key(i) in subs_out: out = subs_out[sub_key(i)] consumed.add(out["id"]) row.update(name="Substitution", player=None, player_in=i.get("subparticipant_name") or None, player_out=out.get("subparticipant_name") or None) rows.append(row)
# 5 - 450 bez pary (uraz) jako osobny wiersz for i in visible: if i.get("incident_id") == SUB_OUT and i["id"] not in consumed: rows.append({ "id": i["id"], "incident_id": SUB_OUT, "name": i.get("incident_name"), "time": i.get("event_time"), "phase_id": i.get("event_status_id"), "phase": i.get("event_status_name"), "display": _display_minute(i.get("event_time"), i.get("event_status_id")), "team": i.get("participant_name") or None, "player": i.get("subparticipant_name") or None, "breaks_play": SUB_OUT in breaks_play, }) rows.sort(key=lambda r: str(r["id"]))
# 6 - grupowanie po fazie phases: list[dict] = [] for r in rows: if not phases or phases[-1]["id"] != r["phase_id"]: phases.append({"id": r["phase_id"], "name": r["phase"], "rows": [r]}) else: phases[-1]["rows"].append(r)
live = ev.get("status_type") == "live" return { "event_id": ev["id"], "live": live, "status": {"id": ev.get("status_id"), "name": ev.get("status_name"), "type": ev.get("status_type")}, # heurystyka: gra przerwana, jeśli NAJNOWSZY incydent ma game_break: "yes" "play_suspended": live and bool(rows and rows[-1].get("breaks_play")), "rows": rows, "phases": phases, }Errors
Section titled “Errors”| Status | Cause |
|---|---|
400 | limit outside the accepted enum, or an invalid filter value |
401 | missing, expired or malformed token |
403 | the endpoint is outside your contract |
404 | no such event |
500 | server error - retry with backoff, do not hammer |
- Build a livescore board - polling many matches instead of one
- Get one match with score and stats - reading the score correctly
- Display a knockout bracket
Full reference: GET /v2/events/{id} ·
GET /v2/events/{id}/incidents ·
GET /v2/events/{id}/important-incidents ·
GET /v2/incidents