Skip to content

Get one match with score and stats

Verified on production10 min

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.

Terminal window
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.event

Walk it once and keep a reference:

function unwrapEvent(data) {
return data?.competition?.season?.stage?.group?.event ?? null;
}

Participants arrive in event.participants[] with a counter field:

counterSide
1home
2away

Do not rely on array order. Sort or select by counter.

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.

idMeaning
2score after regular time + extra time - penalties excluded
3regular time (the 90 minutes)
4 / 5first / second half
7penalty shootout
104overtime - the extra-time total
133 / 134extra time, first / second half
411winner of regular time ("1" for the winner)
412progress 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 own

Inter - 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 H15 H23 Regular133 ET1134 ET2104 OT2 Result7 Pen411412
Inter2131014""01
FC Barcelona0330003""00

And Germany - Paraguay (event 6311565, "Finished after penalties") shows the shootout case:

3 Regular104 OT2 Result7 Pen411412
Germany101300
Paraguay101401

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.

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.

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).

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:

StatKenya (W)Algeria (W)
Shots on target48
Shots off target74
Attacks149150
Dangerous attacks8494
Corners61
Yellow cards40

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
}

event_time is cumulative from kick-off, not period-relative - the opposite of the event-level clock_time. A real Ekstraklasa timeline:

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

Two things to notice:

  • The first half ran to 49:01 real elapsed time, but 2nd half started reports 45:00 - the clock is normalised to the nominal period boundary at each restart. Do not assume event_time increases monotonically across the whole list.
  • Pair event_time with event_status_name. 45:00 alone is ambiguous; 45:00 + Halftime is not. Every incident carries its phase, so use it.

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.

Beyond goals and cards, the timeline carries period markers. For soccer:

incident_idIncidentgame_break
2699Match about to startno
1833Kick offno
429 / 4301st / 2nd half startedno
449Added timeno
445Halftimeyes
437Finished regular timeyes
1834 / 2753Possible VAR / VAR endedyes
413Goalno
419 / 418Yellow / Red cardno
450 / 452Substitution out / inno
408Cornerno
420Penalty awardedyes

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.

  • Sort by id, not by event_time. Ids are strings prefixed with the sport ("5-337932808") and increase in recording order. Because event_time resets 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.

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=5
const 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),
})),
};
}
# Python
RESULT = {"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/livescore instead of polling this endpoint per match
  • Display a league table

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