Skip to content

Show a match timeline

Verified on production15 min

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.

Three endpoints return incidents for one match. They do not return the same thing.

Endpointapi.data shapeSchemaFields
GET /v2/events/{id}…event.events_incidents[]EventIncidentDTO20
GET /v2/events/{id}/incidentsa bare arrayEventIncidentDTO20
GET /v2/events/{id}/important-incidentsevent_incidents[]EventIncidentItem43

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.

{
"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
}
FieldUse it for
idordering and deduplication - a string, sport-prefixed ("5-…")
incident_idwhat happened; look it up in /v2/incidents?sport_id=…
event_timematch clock, "MM:SS" - see step 3
event_status_id + event_status_namewhich phase the clock refers to
participant_id / subparticipant_nameteam and player
forall · own · rival · none - who the incident counts for
confirmationconfirmed · tbd · cancelled - see step 6
parent_idlinks a child incident to the one it refines
utwhen the record was written, not when it happened
additional_infoassistant_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_timeevent_status_nameIncident
00:00Not startedMatch about to start · Kick off · 1st half started
14:571st halfCorner
37:381st halfYellow card
45:031st halfAdded time
49:011st halfHalftime
45:00Halftime2nd half started ← reset to the nominal boundary
82:012nd halfGoal
90:282nd halfAdded time
94:532nd halfFinished regular time

The first half ran to 49:01, then 2nd half started reported 45:00. So:

  • event_time is not monotonic across the list. It steps backwards at every restart.
  • 45:00 on its own is ambiguous. 45:00 + Halftime is not. Every incident carries its phase in event_status_id / event_status_name - always render the pair, and always branch on the phase, never on the raw number.

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.

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_idIncident
1827Possible goal
1829 / 1830Possible card / Possible card cancelled
1832 / 1831Possible penalty / Possible penalty cancelled
1834 / 2753Possible 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:

ParameterValuesEffect
deletedyes / noinclude or exclude entries the API has removed
confirmationtbd / confirmed / cancellednarrow to one confirmation state
updatedyes / nonarrow 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.

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_idIncident
420Penalty
445Halftime
437Finished regular time
434 / 435 / 436Finished after extra time / penalties / awarded win
438Start delayed
439 / 440 / 441 / 442Cancelled / Postponed / Interrupted / Abandoned
446 / 447 / 448Waiting for extra time / Extra time halftime / Waiting for penalty
451To finish
1834 / 2753Possible 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.

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ływ
ADDED_TIME, SUB_OUT, SUB_IN, GOAL_CANCELLED = 449, 450, 452, 424
SOCCER_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,
}
StatusCause
400limit outside the accepted enum, or an invalid filter value
401missing, expired or malformed token
403the endpoint is outside your contract
404no such event
500server error - retry with backoff, do not hammer

Full reference: GET /v2/events/{id} · GET /v2/events/{id}/incidents · GET /v2/events/{id}/important-incidents · GET /v2/incidents