Get one match with score and stats
GET /v2/events/{id} is the only endpoint that returns per-match statistics and the incident
timeline in one response. It is also the endpoint where reading the score is genuinely
non-obvious - there is no score field.
1. Fetch the match
Section titled “1. Fetch the match”curl "https://api.statscore.com/v2/events/6640628?token=YOUR_TOKEN"The payload is deeply nested. The match sits at the end of a single-object chain:
api.data.competition.season.stage.group.eventWalk it once and keep a reference:
function unwrapEvent(data) { return data?.competition?.season?.stage?.group?.event ?? null;}2. Identify home and away
Section titled “2. Identify home and away”Participants arrive in event.participants[] with a counter field:
counter | Side |
|---|---|
1 | home |
2 | away |
Do not rely on array order. Sort or select by counter.
3. Read the score
Section titled “3. Read the score”Each participant carries a results[] array. Every entry is keyed by id - not
result_type_id - and you pick the one you need.
Here is a real finished match, Kenya (W) 0 - 2 Algeria (W):
{ "participants": [ { "counter": 1, "id": 141673, "name": "Kenya (W)", "results": [ { "id": 412, "short_name": "Progress", "value": "0" }, { "id": 411, "short_name": "Winner", "value": "0" }, { "id": 2, "short_name": "Result", "value": "0" }, { "id": 3, "short_name": "Regular time", "value": "0" }, { "id": 4, "short_name": "First half", "value": "0" }, { "id": 5, "short_name": "Second half", "value": "0" }, { "id": 133, "short_name": "Extratime 1st half", "value": "" }, { "id": 134, "short_name": "Extratime 2nd half", "value": "" }, { "id": 7, "short_name": "Penalty", "value": "" }, { "id": 104, "short_name": "Overtime", "value": "" } ] }, { "counter": 2, "id": 953910, "name": "Algeria (W)", "results": [ { "id": 412, "short_name": "Progress", "value": "0" }, { "id": 411, "short_name": "Winner", "value": "1" }, { "id": 2, "short_name": "Result", "value": "2" }, { "id": 3, "short_name": "Regular time", "value": "2" }, { "id": 4, "short_name": "First half", "value": "2" }, { "id": 5, "short_name": "Second half", "value": "0" } ] } ]}Reading it: final score is id: 2 → 0-2. Half-time is id: 4 → 0-2, so both goals came
before the break. The winner is the participant whose id: 411 is "1" → Algeria.
Result ids worth knowing
Section titled “Result ids worth knowing”id | Meaning |
|---|---|
2 | score after regular time + extra time - penalties excluded |
3 | regular time (the 90 minutes) |
4 / 5 | first / second half |
7 | penalty shootout |
104 | overtime - the extra-time total |
133 / 134 | extra time, first / second half |
411 | winner of regular time ("1" for the winner) |
412 | progress flag - who advances |
43+ | volleyball sets |
The ids add up - and 2 is not the "final score"
Section titled “The ids add up - and 2 is not the "final score"”The naming invites a wrong assumption. 2 is labelled Result, but on a match decided by a shootout
it does not contain the shootout. The relationships, verified on production:
4 (H1) + 5 (H2) = 3 (Regular time)133 (ET1) + 134 (ET2) = 104 (Overtime) 3 + 104 = 2 (Result) ← penalties are NOT in here 7 (Penalty) ← the shootout, on its ownInter - FC Barcelona, the 2024/25 Champions League semi-final that went to extra time
(event 5928182, status_name: "Finished after extra time"), shows every step at once:
4 H1 | 5 H2 | 3 Regular | 133 ET1 | 134 ET2 | 104 OT | 2 Result | 7 Pen | 411 | 412 | |
|---|---|---|---|---|---|---|---|---|---|---|
| Inter | 2 | 1 | 3 | 1 | 0 | 1 | 4 | "" | 0 | 1 |
| FC Barcelona | 0 | 3 | 3 | 0 | 0 | 0 | 3 | "" | 0 | 0 |
And Germany - Paraguay (event 6311565, "Finished after penalties") shows the shootout case:
3 Regular | 104 OT | 2 Result | 7 Pen | 411 | 412 | |
|---|---|---|---|---|---|---|
| Germany | 1 | 0 | 1 | 3 | 0 | 0 |
| Paraguay | 1 | 0 | 1 | 4 | 0 | 1 |
Rendering a score correctly
Section titled “Rendering a score correctly”Because 2 stops before the shootout, a scoreline built from 2 alone is wrong for any match decided
on penalties - it renders 1-1 for a match that had a winner. Compose the display from the ladder:
const RESULT_IDS = { RESULT: 2, REGULAR: 3, H1: 4, H2: 5, PENALTY: 7, OVERTIME: 104, ET1: 133, ET2: 134, WINNER: 411, PROGRESS: 412 };
const num = (v) => (v === "" || v == null ? null : Number(v));
/** Zwraca gotowy do wyświetlenia wynik z pełnej drabinki wyników. */function readScore(participants) { const side = (p) => { const g = new Map(p.results.map((r) => [r.id, r.value])); return { id: p.id, name: p.short_name ?? p.name, result: num(g.get(RESULT_IDS.RESULT)), // regulaminowy + dogrywka, BEZ karnych regular: num(g.get(RESULT_IDS.REGULAR)), overtime: num(g.get(RESULT_IDS.OVERTIME)), penalty: num(g.get(RESULT_IDS.PENALTY)), // null gdy nie było karnych // 411 to zwycięzca regulaminowego czasu - po dogrywce jest "0" u OBU stron wonRegular: g.get(RESULT_IDS.WINNER) === "1", // 412 to jedyne pole poprawne dla dogrywki, karnych i bye advances: g.get(RESULT_IDS.PROGRESS) === "1", }; }; const [home, away] = participants.map(side);
const wentToPenalties = home.penalty != null && away.penalty != null; const wentToExtraTime = (home.overtime ?? 0) > 0 || (away.overtime ?? 0) > 0 || home.result !== home.regular || away.result !== away.regular;
return { home, away, // "1-1 (3-4 pen.)" albo "4-3 a.e.t." albo "2-1" display: `${home.result}-${away.result}` + (wentToPenalties ? ` (${home.penalty}-${away.penalty} pen.)` : wentToExtraTime ? " a.e.t." : ""), winnerId: home.advances ? home.id : away.advances ? away.id : null, wentToExtraTime, wentToPenalties, };}status_name is a cheaper signal than the arithmetic when you only need the label: production returns
"Finished", "Finished after extra time" and "Finished after penalties" as distinct strings.
Two traps in the values
Section titled “Two traps in the values”Empty string, not null. Results that do not apply arrive as "" - see Penalty above. An empty
string is not 0. Coerce carefully:
const num = (v) => (v === "" || v == null ? null : Number(v));value type is inconsistent between endpoints. It is a string in /v2/events/{id} but a
number in /v2/events/{id}/participants. Normalise on read.
Do not trust winner_id on the event
Section titled “Do not trust winner_id on the event”The event object has its own winner_id field. On the finished match above it was null, even
though the results identified Algeria unambiguously.
Derive the winner from the results, not from the event's winner_id. And use 412, not 411 -
411 is empty for both sides on anything that went past regular time, as shown above.
⚠️ Whether the event's winner_id is populated for some competitions and not others is still open -
treat it as an optimisation, never as the source of truth. Note this is a different field from the
winner_id on a bracket node event, which is reliable because it falls back through
412 → 411 (recipe 06).
4. Read the statistics
Section titled “4. Read the statistics”participants[].stats[] uses the same shape as results[] - id, short_name, value,
data_type. Values are "" when not collected, which depends on the competition's stats_lvl
(bronze / silver / gold).
Real values from the same match:
| Stat | Kenya (W) | Algeria (W) |
|---|---|---|
| Shots on target | 4 | 8 |
| Shots off target | 7 | 4 |
| Attacks | 149 | 150 |
| Dangerous attacks | 84 | 94 |
| Corners | 6 | 1 |
| Yellow cards | 4 | 0 |
5. Read the timeline
Section titled “5. Read the timeline”event.events_incidents[] holds the incident list. Each entry looks like this:
{ "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}Time lives in event_time, not minute
Section titled “Time lives in event_time, not minute”event_time is cumulative from kick-off, not period-relative - the opposite of the event-level
clock_time. A real Ekstraklasa timeline:
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 |
Two things to notice:
- The first half ran to
49:01real elapsed time, but2nd half startedreports45:00- the clock is normalised to the nominal period boundary at each restart. Do not assumeevent_timeincreases monotonically across the whole list. - Pair
event_timewithevent_status_name.45:00alone is ambiguous;45:00+Halftimeis not. Every incident carries its phase, so use it.
Stoppage time is an explicit incident
Section titled “Stoppage time is an explicit incident”Added time is signalled by incident_id: 449 ("Added time"), emitted at the end of each
period. That is how you render "45+3" or "90+4" - you do not compute it from the clock.
Structural incidents worth handling
Section titled “Structural incidents worth handling”Beyond goals and cards, the timeline carries period markers. For soccer:
incident_id | Incident | game_break |
|---|---|---|
2699 | Match about to start | no |
1833 | Kick off | no |
429 / 430 | 1st / 2nd half started | no |
449 | Added time | no |
445 | Halftime | yes |
437 | Finished regular time | yes |
1834 / 2753 | Possible VAR / VAR ended | yes |
413 | Goal | no |
419 / 418 | Yellow / Red card | no |
450 / 452 | Substitution out / in | no |
408 | Corner | no |
420 | Penalty awarded | yes |
game_break: "yes" marks incidents that interrupt play - useful for suspending in-play markets or
pausing a UI clock. The full per-sport catalogue is at
GET /v2/incidents?sport_id=5.
Three rules for handling the list
Section titled “Three rules for handling the list”- Sort by
id, not byevent_time. Ids are strings prefixed with the sport ("5-337932808") and increase in recording order. Becauseevent_timeresets at each period, sorting by it puts the second half before the end of the first. - Incidents can be deleted. A disallowed goal disappears from the list. Treat the timeline as mutable and re-render rather than appending.
- Deduplicate by
id. The same incident can be delivered more than once.
Substitutions arrive as two incidents with the same event_time - 450 out and 452 in.
Pair them on event_time + participant_id if you want one row per substitution.
Putting it together
Section titled “Putting it together”import { apiGet } from "./client.js"; // z recipe 01
const RESULT = { FINAL: 2, REGULAR: 3, H1: 4, H2: 5, PENALTY: 7, OVERTIME: 104, WINNER: 411 };// incident_id przerywające grę (soccer) - z GET /v2/incidents?sport_id=5const BREAKS_PLAY = new Set([445, 437, 1834, 2753, 420]);const ADDED_TIME = 449;const num = (v) => (v === "" || v == null ? null : Number(v));
/** Pobiera mecz i zwraca strukturę gotową do wyświetlenia. */export async function getMatch(eventId) { 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`);
const side = (counter) => { const p = event.participants?.find((x) => x.counter === counter); if (!p) return null; const byId = new Map((p.results ?? []).map((r) => [r.id, r.value])); return { id: p.id, name: p.name, acronym: p.acronym, score: num(byId.get(RESULT.FINAL)), halfTime: num(byId.get(RESULT.H1)), isWinner: byId.get(RESULT.WINNER) === "1", stats: Object.fromEntries( (p.stats ?? []) .filter((s) => s.value !== "" && s.value != null) .map((s) => [s.short_name, num(s.value) ?? s.value]), ), }; };
return { id: event.id, name: event.name, startDate: event.start_date, // stan meczu wymaga trzech pól - patrz recipe o statusach status: { id: event.status_id, name: event.status_name, // np. "Finished", "1st half" type: event.status_type, // scheduled | live | finished relation: event.relation_status, // not_started | in_progress }, // Zegar jest w SEKUNDACH i zeruje się co okres. // Zakończony mecz zwraca clock_time: 0 (nie null), więc pokazujemy zegar // tylko dla trwających spotkań - inaczej po gwizdku wyświetlisz "0:00". clock: event.status_type !== "live" || event.clock_time == null ? null : { periodSeconds: event.clock_time, running: event.clock_status === "running", display: `${Math.floor(event.clock_time / 60)}:${String(event.clock_time % 60).padStart(2, "0")}`, }, home: side(1), away: side(2), // Sortujemy po id - event_time resetuje się na początku każdej połowy, // więc sortowanie po nim wywróciłoby kolejność. timeline: (event.events_incidents ?? []) .slice() .sort((a, b) => String(a.id).localeCompare(String(b.id))) .map((i) => ({ id: i.id, incidentId: i.incident_id, name: i.incident_name, time: i.event_time, // "MM:SS", narastająco od pierwszego gwizdka phase: i.event_status_name, // "1st half" / "Halftime" / "2nd half" team: i.participant_name || null, player: i.subparticipant_name || null, breaksPlay: BREAKS_PLAY.has(i.incident_id), })), };}# PythonRESULT = {"FINAL": 2, "REGULAR": 3, "H1": 4, "H2": 5, "PENALTY": 7, "OVERTIME": 104, "WINNER": 411}
def _num(v): return None if v in ("", None) else float(v) if "." in str(v) else int(v)
def get_match(event_id: int): data = api_get(f"/v2/events/{event_id}") ev = data["competition"]["season"]["stage"]["group"]["event"]
def side(counter: int): p = next((x for x in ev.get("participants", []) if x.get("counter") == counter), None) if not p: return None by_id = {r["id"]: r["value"] for r in p.get("results", [])} return { "id": p["id"], "name": p["name"], "score": _num(by_id.get(RESULT["FINAL"])), "half_time": _num(by_id.get(RESULT["H1"])), "is_winner": by_id.get(RESULT["WINNER"]) == "1", "stats": {s["short_name"]: _num(s["value"]) for s in p.get("stats", []) if s.get("value") not in ("", None)}, }
clock = ev.get("clock_time") return { "id": ev["id"], "name": ev.get("name"), "status": {"id": ev.get("status_id"), "name": ev.get("status_name"), "type": ev.get("status_type"), "relation": ev.get("relation_status")}, # zakończony mecz zwraca clock_time: 0, więc gate'ujemy po status_type "clock": None if (clock is None or ev.get("status_type") != "live") else { "period_seconds": clock, "running": ev.get("clock_status") == "running", "display": f"{clock // 60}:{clock % 60:02d}", }, "home": side(1), "away": side(2), # sortowanie po id, bo event_time resetuje się na starcie drugiej połowy "timeline": [ { "id": i.get("id"), "incident_id": i.get("incident_id"), "name": i.get("incident_name"), "time": i.get("event_time"), # "MM:SS" narastająco "phase": i.get("event_status_name"), "team": i.get("participant_name") or None, "player": i.get("subparticipant_name") or None, "breaks_play": i.get("incident_id") in {445, 437, 1834, 2753, 420}, } for i in sorted(ev.get("events_incidents", []), key=lambda i: str(i.get("id"))) ], }- Show a match timeline - handling corrections, deletions and ordering in depth
- Build a livescore board -
GET /v2/livescoreinstead of polling this endpoint per match - Display a league table
Full reference: GET /v2/events/{id} ·
GET /v2/incidents