Build a season calendar
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.
1. One call, no date range
Section titled “1. One call, no date range”curl "https://api.statscore.com/v2/events?token=YOUR_TOKEN&season_id=70459&limit=150"Useful filters on top of season_id:
| Parameter | Use |
|---|---|
stage_id | one stage only, when a season has Regular Season plus Play Offs |
status_type | scheduled for upcoming, finished for results |
participant_id | one team's fixture list |
date_from / date_to | a specific window, for example "this weekend" |
2. You will need pagination
Section titled “2. You will need pagination”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.
3. The tree is five levels deep
Section titled “3. The tree is five levels deep”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:
| Level | Field worth keeping |
|---|---|
| competition | stats_lvl (gold here) tells you how much per-match detail to expect |
| season | actual: "yes" marks the current season |
| stage | start_date / end_date - the season span, 2026-07-23 to 2027-05-21 |
| stage | show_standings, has_brackets - which follow-up call makes sense |
| group | group name, empty ("") in a single-table league |
4. Group by round, not by date
Section titled “4. Group by round, not by date”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.
5. The scores are already there
Section titled “5. The scores are already there”Each event's participants[] carries counter (1 home, 2 away) and results[]. So a
results calendar needs no extra call per match:
counter: 1is the home side- result
id: 2is the score, regular time plus extra time, penalties excluded - result
id: 411is the regular-time winner,412is 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.
6. Putting it together
Section titled “6. Putting it together”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 przeplywRESULT_FINAL = 2PAGE_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())}Two smaller calendars you get for free
Section titled “Two smaller calendars you get for free”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.
Errors
Section titled “Errors”| Status | Cause |
|---|---|
400 | limit outside 5, 10, 25, 50, 100, 150, or a date range wider than the endpoint allows |
401 | missing, expired or malformed token |
403 | the competition is outside your contract |
- Display a league table - the standings for the same season
- Get one match with score and stats - per-match detail
- Sync incrementally - poll a calendar without re-downloading it
- Display a knockout bracket - for stages with
has_brackets: "yes"
Full reference: GET /v2/events ·
GET /v2/stages