Skip to content

Build a season calendar

Verified on production12 min

A season calendar is the cheapest useful page in the API: one endpoint, one filter, scores included. You do not need a call per match to show results, and you do not need a date range.

Terminal window
curl "https://api.statscore.com/v2/events?token=YOUR_TOKEN&season_id=70459&limit=150"

Useful filters on top of season_id:

ParameterUse
stage_idone stage only, when a season has Regular Season plus Play Offs
status_typescheduled for upcoming, finished for results
participant_idone team's fixture list
date_from / date_toa specific window, for example "this weekend"

limit on this endpoint is an enum capping at 150: 5, 10, 25, 50, 100, 150. A 306-match season therefore takes three requests.

The response carries api.method.next_page as a ready URL, but it contains your token, so do not hand it to a browser or log it. Read the page number and rebuild the URL yourself, or just increment a page parameter until you have total_items records.

Same shape as head to head: fixtures are not a flat array.

api.data.competitions[]
-> seasons[]
-> stages[]
-> groups[]
-> events[]

The wrapping levels are not noise, they carry things you want:

LevelField worth keeping
competitionstats_lvl (gold here) tells you how much per-match detail to expect
seasonactual: "yes" marks the current season
stagestart_date / end_date - the season span, 2026-07-23 to 2027-05-21
stageshow_standings, has_brackets - which follow-up call makes sense
groupgroup name, empty ("") in a single-table league

Every event carries round_id and round_name:

{
"id": 6432101,
"name": "Lech Poznan - Cracovia",
"start_date": "2026-07-25 18:15",
"round_id": 1,
"round_name": "Round 1",
"status_type": "finished",
"status_name": "Finished",
"neutral_venue": "no",
"participants": [
{ "id": 136761, "short_name": "Lech Poznan", "counter": 1, "results": [{ "id": 2, "value": "2" }] },
{ "id": 137405, "short_name": "Cracovia", "counter": 2, "results": [{ "id": 2, "value": "1" }] }
]
}

Group on round_id rather than on the calendar date. A matchday is spread across days - Round 1 in our sample ran from Friday to Sunday, and the page boundary landed mid-round (9 matches of Round 1 plus 1 of Round 2). Grouping by date splits a matchday into three headings; grouping by round_id gives the structure fans expect.

Each event's participants[] carries counter (1 home, 2 away) and results[]. So a results calendar needs no extra call per match:

  • counter: 1 is the home side
  • result id: 2 is the score, regular time plus extra time, penalties excluded
  • result id: 411 is the regular-time winner, 412 is who advanced

The full ladder and why 2 is not simply "the final score" is in recipe 03. For a league calendar 2 is what you want; for a cup calendar you also need 7 when a match went to penalties.

import { apiGet } from "./client.js"; // z recipe 01
const RESULT_FINAL = 2;
const PAGE_LIMIT = 150; // maksimum enuma na tym endpoincie
/** Splaszcza competitions -> seasons -> stages -> groups -> events */
function flatten(data) {
const out = [];
for (const comp of data.competitions ?? []) {
for (const season of comp.seasons ?? []) {
for (const stage of season.stages ?? []) {
for (const group of stage.groups ?? []) {
for (const ev of group.events ?? []) {
out.push({ ev, comp, season, stage, group });
}
}
}
}
}
return out;
}
/**
* Zwraca kalendarz sezonu pogrupowany po kolejkach.
* Strony dociagamy w petli, bo limit (150) jest mniejszy niz sezon (306).
*/
export async function getSeasonCalendar(seasonId, { stageId } = {}) {
const rows = [];
let page = 1;
let total = Infinity;
let meta = null;
while (rows.length < total) {
// NIE uzywamy `next_page` z odpowiedzi - ten URL zawiera token
const data = await apiGetRaw("/v2/events", {
season_id: seasonId,
stage_id: stageId,
limit: PAGE_LIMIT,
page,
});
total = data.api.method.total_items ?? 0;
const batch = flatten(data.api.data);
if (!batch.length) break; // zabezpieczenie przed petla nieskonczona
if (!meta) {
const { comp, season, stage } = batch[0];
meta = {
competition: { id: comp.id, name: comp.name, statsLvl: comp.stats_lvl },
season: { id: season.id, name: season.name, isCurrent: season.actual === "yes" },
// zakres sezonu jest na ETAPIE, nie na sezonie
span: { from: stage.start_date, to: stage.end_date },
};
}
rows.push(...batch);
page += 1;
}
const num = (v) => (v === "" || v == null ? null : Number(v));
const side = (p) => ({
id: p.id,
name: p.short_name ?? p.name,
home: p.counter === 1,
// result id 2 = regulaminowy + dogrywka, BEZ karnych
goals: num((p.results ?? []).find((r) => r.id === RESULT_FINAL)?.value),
});
const matches = rows.map(({ ev }) => {
const sides = (ev.participants ?? []).map(side);
return {
id: ev.id,
date: ev.start_date,
// grupujemy po round_id, nie po dacie - kolejka rozklada sie na kilka dni
roundId: ev.round_id ?? null,
roundName: ev.round_name ?? null,
statusType: ev.status_type,
statusName: ev.status_name,
neutralVenue: ev.neutral_venue === "yes",
home: sides.find((s) => s.home) ?? null,
away: sides.find((s) => !s.home) ?? null,
};
});
// Map zachowuje kolejnosc wstawiania, a API zwraca kolejki narastajaco
const rounds = new Map();
for (const m of matches) {
const key = m.roundId ?? "bez-kolejki";
if (!rounds.has(key)) rounds.set(key, { roundId: m.roundId, name: m.roundName, matches: [] });
rounds.get(key).matches.push(m);
}
for (const r of rounds.values()) {
r.matches.sort((a, b) => String(a.date).localeCompare(String(b.date)));
}
return { ...meta, total, rounds: [...rounds.values()] };
}
# Python - ten sam przeplyw
RESULT_FINAL = 2
PAGE_LIMIT = 150
def flatten(data):
for comp in data.get("competitions", []):
for season in comp.get("seasons", []):
for stage in season.get("stages", []):
for group in stage.get("groups", []):
for ev in group.get("events", []):
yield ev, comp, season, stage
def get_season_calendar(season_id: int, stage_id: int | None = None):
rows, page, total, meta = [], 1, None, None
while total is None or len(rows) < total:
# nie uzywamy next_page z odpowiedzi - ten URL zawiera token
body = api_get_raw("/v2/events", season_id=season_id, stage_id=stage_id,
limit=PAGE_LIMIT, page=page)
total = body["api"]["method"].get("total_items") or 0
batch = list(flatten(body["api"]["data"]))
if not batch:
break
if meta is None:
_, comp, season, stage = batch[0]
meta = {
"competition": {"id": comp["id"], "name": comp.get("name"),
"stats_lvl": comp.get("stats_lvl")},
"season": {"id": season["id"], "name": season.get("name"),
"is_current": season.get("actual") == "yes"},
# zakres sezonu jest na ETAPIE, nie na sezonie
"span": {"from": stage.get("start_date"), "to": stage.get("end_date")},
}
rows.extend(batch)
page += 1
def num(v):
return None if v in ("", None) else int(v)
def side(p):
goal = next((r.get("value") for r in p.get("results", [])
if r.get("id") == RESULT_FINAL), None)
return {"id": p.get("id"), "name": p.get("short_name") or p.get("name"),
"home": p.get("counter") == 1, "goals": num(goal)}
rounds: dict = {}
for ev, *_ in rows:
sides = [side(p) for p in ev.get("participants", [])]
m = {
"id": ev.get("id"), "date": ev.get("start_date"),
# grupujemy po round_id, nie po dacie
"round_id": ev.get("round_id"), "round_name": ev.get("round_name"),
"status_type": ev.get("status_type"), "status_name": ev.get("status_name"),
"neutral_venue": ev.get("neutral_venue") == "yes",
"home": next((s for s in sides if s["home"]), None),
"away": next((s for s in sides if not s["home"]), None),
}
key = m["round_id"] if m["round_id"] is not None else "bez-kolejki"
rounds.setdefault(key, {"round_id": m["round_id"], "name": m["round_name"],
"matches": []})["matches"].append(m)
for r in rounds.values():
r["matches"].sort(key=lambda x: str(x["date"]))
return {**(meta or {}), "total": total, "rounds": list(rounds.values())}

Both are the same call with one filter changed, so they need no separate code path:

Upcoming fixtures only - add status_type=scheduled. Better than filtering client-side, because you stop paging through a finished season to find three future matches.

One team's season - add participant_id. Combine with venue_type=home for a home-only fixture list.

StatusCause
400limit outside 5, 10, 25, 50, 100, 150, or a date range wider than the endpoint allows
401missing, expired or malformed token
403the competition is outside your contract

Full reference: GET /v2/events · GET /v2/stages