Build a livescore board
A livescore board is one request repeated forever. The interesting decisions are which request, how often, and what to render when there is no clock - not the parsing.
GET /v2/livescore exists for exactly this. Use it instead of looping GET /v2/events per
competition.
1. One call for every live match
Section titled “1. One call for every live match”curl "https://api.statscore.com/v2/livescore?token=YOUR_TOKEN&sport_id=5&status_type=live&limit=50"The response envelope tells you the scope you actually got:
{ "api": { "ver": "2.317.0", "timestamp": 1785786097, "method": { "parameters": { "limit": "5", "sport_id": "5", "events_details": "yes", "date_from": "2026-07-27 00:00:00", "date_to": "2026-08-03 23:59:59", "status_type": "live" }, "name": "livescore.index", "total_items": 13, "next_page": "https://api.statscore.com/v2/livescore?token=YOUR_TOKEN&limit=5&sport_id=5&page=2" }, "data": { "competitions": [ … ] } }}Three things to read out of that envelope:
total_itemsis the full count, not the page. 13 live soccer matches, 5 on this page.- The API filled in a date window (
date_from/date_to) that you did not send. It echoes every effective parameter back - this is the cheapest way to confirm what the server actually applied. next_pagecarries your token. Extract thepagenumber and rebuild the URL server-side; never hand that string to a browser.
2. Walk the tree - it is not a flat list
Section titled “2. Walk the tree - it is not a flat list”/v2/livescore returns the same five-level tree as /v2/events:
api.data.competitions[] → seasons[] → stages[] → groups[] → events[]There is no flat events array anywhere in the response. Flatten it yourself, and carry the
competition and stage context down as you go - you need it for the board's grouping headers, and the
event object does not repeat competition names (only competition_id, season_id, stage_id,
group_id).
3. What a live event actually contains
Section titled “3. What a live event actually contains”A real live match from the response, with the fields that matter for a board:
{ "id": 6664616, "name": "Defensores de Vilela - Sarmiento Resisten.", "start_date": "2026-08-03 18:30", "status_id": 34, "status_name": "2nd half", "status_type": "live", "relation_status": "in_progress", "clock_time": 312, "clock_status": "running", "played_time": 3012, "winner_id": null, "sport_id": 5, "round_name": "Round 1", "coverage_type": "from_tv", "event_stats_lvl_live": "silver", "bet_status": "suspended", "latency": null, "series": { "order": 1, "event_ids": [] }}clock_time: 312 with played_time: 3012 - 5:12 into the second half, 50:12 into the match.
3012 − 312 = 2700, exactly the 45 minutes of the completed first half. That relation is the whole
clock model, and step 5 uses it.
The result-id vocabulary itself is in
Get one match with score and stats - 2 final, 4/5 halves, 411 winner.
Participants are identified by counter: 1 home, 2 away.
4. How often to poll
Section titled “4. How often to poll”There is no documented rate limit: the specification defines no 429 response and no
X-RateLimit-* headers on any of the 45 endpoints. ⚠️ The actual limit - per token, per
client_id, per IP - needs confirmation from the API team before you tune an interval
aggressively. What follows is a conservative shape, not a permitted maximum.
The one signal the API does give you is per-event: latency, whose documented values are
1-2s, 3-6s and 7-15s. Polling faster than the slowest latency band on your board buys you
nothing - the data has not changed yet.
| Surface | Interval | Why |
|---|---|---|
Live board, status_type=live | 10 s | matches the 7-15s latency band of TV-sourced coverage |
Live board, latency: "1-2s" events only | 5 s | only worth it if your contract includes low-latency coverage |
| Today's schedule, no live matches | 60 s | kick-off times and postponements only |
| Finished matches (result verification) | 5 min | verified_result flips well after the whistle |
Four rules that matter more than the number:
- Poll on a self-scheduling timer, not
setInterval. Schedule the next tick after the previous response resolves, so a slow response cannot stack requests. - One request for the whole board. Not one per match. The endpoint exists to be a single call.
- Back off on errors. On
500or a timeout, double the interval up to a ceiling; on401, refresh the token and retry once; on403, stop - the endpoint is outside your contract and retrying will never fix it. - Never poll from the browser. The token would be in client-side URLs. Poll server-side and push or let clients poll your own cache.
⚠️ /v2/livescore also accepts a timestamp parameter (integer). On /v2/feed timestamp is
documented as the start of a time window; on /v2/livescore the specification gives it no
description, so whether it works as a "changed since" delta filter is unconfirmed. If it does,
it is the right way to cut polling cost - worth asking about, not worth guessing at.
5. Render the clock
Section titled “5. Render the clock”Three independent facts about the clock, all verified on live matches:
| Field | Unit | Behaviour |
|---|---|---|
clock_time | seconds | time in the current period; resets to 0 each period |
played_time | seconds | cumulative match time; null for sports that do not have it |
clock_status | string | running / stopped |
played_time = clock_time + sum of completed periodsProduction evidence:
| Match | status_name | clock_time | played_time |
|---|---|---|---|
| Guemes SdE - Tristan Suarez | 1st half | 2340 (39:00) | 2340 (39:00) |
| Defensores de Vilela - Sarmiento | 2nd half | 312 (5:12) | 3012 (50:12) |
| Kimberley - Argentino Monte Maiz | 2nd half | 1991 (33:11) | 4691 (78:11) |
| USA U18 - Czech U18 (ice hockey) | 2nd period | 1225 (20:25) | null |
For a soccer board you almost always want to display played_time, not clock_time - "50:12", not
"5:12". Use clock_time when you need the period-relative figure, e.g. a basketball quarter.
Sports without a clock
Section titled “Sports without a clock”GET /v2/sports returns has_timer per sport:
{ "id": 1, "name": "Basketball", "active": "yes", "has_timer": "yes" }{ "id": 2, "name": "Volleyball", "active": "yes", "has_timer": "no" }Volleyball has no clock at all. For has_timer: "no" sports, ignore every clock field and drive
the UI from status_name - "Set 3", "Break after 2nd set". Fetch /v2/sports once at boot, cache
it, and key your renderer off the flag rather than off a hardcoded sport list.
⚠️ Clock direction is a real problem with no clean answer yet. Soccer counts up (0 → 45);
basketball counts down (12:00 → 0:00). The schema FeedPeriodTimer is { direction, time } and is
attached to feed incidents as period_timer, but there is no direction field on the event
object returned by /v2/livescore. The schema also declares no enum for direction, so the
allowed values are unknown. Confirm with the API team which sports count down and how to detect
it outside the feed; until then, a countdown sport needs a hardcoded period length on your side.
6. Do not branch on status_type alone
Section titled “6. Do not branch on status_type alone”status_type has five buckets, and the scheduled bucket is a trap. All five statuses typed
scheduled in the 369-status catalogue:
status_id | Name | type |
|---|---|---|
1 | Not started | scheduled |
5 | Postponed | scheduled |
6 | Start delayed | scheduled |
149 | Delayed by rain | scheduled |
152 | To finish | scheduled |
A postponed match is status_type: "scheduled", indistinguishable from a normal upcoming fixture
unless you read status_id. /v2/livescore has a show_postponed parameter (yes / no) if you
want them out of the response entirely.
At the other end, finished is not one thing either - 11 Finished, 12 Finished awarded win,
13 Finished after penalties, 14 Finished after extra time, 140 Finished after overtime. A board
that prints "FT" for all five throws away information the API gave you.
Read all three fields, as recipe 03 does: status_type for the bucket, status_id + status_name
for the phase, relation_status for progress.
7. basic-livescore - the lighter variant
Section titled “7. basic-livescore - the lighter variant”GET /v2/basic-livescore is the smallest live payload available: scores and status only.
curl "https://api.statscore.com/v2/basic-livescore?token=YOUR_TOKEN&sport_id=5×tamp=1785786097"Two things to know before you plan around it:
timestampis required. It is the only required parameter besides the token;sport_idis optional. A call without it returns400.- It is contract-gated. ⚠️ In our harvest run it returned HTTP 403 with a token that worked on every other endpoint on this page. What grants access - plan, product, or IP allowlist - is not documented and needs confirmation.
⚠️ Because of the 403 we have no captured response, and the specification types its api.data
as a bare untyped object with no properties. The payload shape is therefore unknown - do not
write a parser against an assumption. Ask for a sample before designing around it.
Practical guidance: build on /v2/livescore, keep the mapping into your board model in one function,
and swap the source later if basic-livescore gets enabled for you.
8. When to stop polling and move to a push feed
Section titled “8. When to stop polling and move to a push feed”Polling has a floor. Once you need sub-second updates, or you are polling for hundreds of matches at once, pull stops being the right shape.
| Signal | Move to push |
|---|---|
You want updates faster than the 1-2s latency band | yes - polling cannot beat its own interval |
You are trading in-play and need game_break the moment it fires | yes |
| You poll every few seconds and mostly get unchanged data | yes - you are paying for nothing |
| You render a public scoreboard refreshed on user navigation | no - polling is simpler and cheaper |
GET /v2/feed is the pull equivalent of the push feed: scout incidents across all events from
the last 24 hours, or a window set by timestamp / timestamp_to, filterable by type
(incident / event), event_ids, important and important_for_trader. It is documented as
not cached, so it is the closest thing to the push stream you can reach with a plain GET, and a
good way to prototype your incident handling before wiring up a real feed.
Its messages carry an action field on the event ("update" in our capture) and a ut timestamp,
which is what makes delta processing possible.
⚠️ GET /v2/feed/{id} returned HTTP 403 in our harvest run, and the actual push transport is not
part of the published 45-endpoint surface at all. How to obtain push access, and over what
protocol, needs confirmation from the API team.
9. Putting it together
Section titled “9. Putting it together”import { apiGet } from "./client.js"; // z recipe 01
const RESULT_FINAL = 2;const RESULT_WINNER = 411;const num = (v) => (v === "" || v == null ? null : Number(v));
/** has_timer per sport - pobierane raz, potem z cache'u. */let sportsCache = null;async function sportHasTimer(sportId) { if (!sportsCache) { const { sports } = await apiGet("/v2/sports", { limit: 100 }); sportsCache = new Map(sports.map((s) => [s.id, s.has_timer === "yes"])); } return sportsCache.get(sportId) ?? true;}
/** Spłaszcza drzewo competitions → seasons → stages → groups → events. */function flatten(data) { const out = []; for (const c of data?.competitions ?? []) { for (const s of c.seasons ?? []) { for (const st of s.stages ?? []) { for (const g of st.groups ?? []) { for (const e of g.events ?? []) { out.push({ event: e, competition: c, season: s, stage: st, group: g }); } } } } } return out;}
function toRow({ event, competition, stage, group }, hasTimer) { 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.short_name || p.name, acronym: p.acronym, // brak wpisu 2 znaczy "nie wiem", NIE zero - patrz ostrzeżenie w kroku 3 score: num(byId.get(RESULT_FINAL)), isWinner: String(byId.get(RESULT_WINNER)) === "1", }; };
const live = event.status_type === "live"; const seconds = event.played_time ?? event.clock_time;
return { id: event.id, competition: competition.short_name || competition.name, stage: stage.name, group: group.name, round: event.round_name || null, startDate: event.start_date, // trzy pola, nie jedno: 5 = Postponed nadal ma status_type "scheduled" status: { id: event.status_id, name: event.status_name, type: event.status_type, relation: event.relation_status, postponed: event.status_id === 5, }, // zegar tylko na żywo (zakończony mecz zwraca clock_time: 0) // i tylko dla sportów z has_timer: "yes" clock: !live || !hasTimer || seconds == null ? null : { seconds, running: event.clock_status === "running", display: `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`, }, home: side(1), away: side(2), betStatus: event.bet_status, // active | suspended - sygnał in-play latency: event.latency || null, // "1-2s" | "3-6s" | "7-15s" };}
/** Jedno odpytanie tablicy wyników. */export async function fetchBoard({ sportId = 5, limit = 100 } = {}) { const data = await apiGet("/v2/livescore", { sport_id: sportId, status_type: "live", events_details: "yes", show_postponed: "no", limit, // ≤ 100: enum tego endpointu kończy się na 150 }); const hasTimer = await sportHasTimer(sportId); return flatten(data).map((n) => toRow(n, hasTimer));}
/** * Pętla odpytująca. Kolejny tick planujemy PO odpowiedzi, więc wolna * odpowiedź nie skleja się z następnym żądaniem. */export function startBoard({ sportId = 5, intervalMs = 10_000, onUpdate, onError } = {}) { let stopped = false; let delay = intervalMs; const MAX_DELAY = 5 * 60_000;
(async function tick() { while (!stopped) { try { onUpdate?.(await fetchBoard({ sportId })); delay = intervalMs; // sukces → wracamy do bazowego interwału } catch (err) { onError?.(err); // 403 nie naprawi się przez ponowienie - endpoint jest poza kontraktem if (String(err.message).includes("HTTP 403")) return; delay = Math.min(delay * 2, MAX_DELAY); // backoff wykładniczy } await new Promise((r) => setTimeout(r, delay)); } })();
return () => { stopped = true; };}# Python - ten sam przepływimport time
RESULT_FINAL, RESULT_WINNER = 2, 411_sports_cache: dict[int, bool] | None = None
def _num(v): return None if v in ("", None) else float(v) if "." in str(v) else int(v)
def sport_has_timer(sport_id: int) -> bool: global _sports_cache if _sports_cache is None: data = api_get("/v2/sports", limit=100) _sports_cache = {s["id"]: s.get("has_timer") == "yes" for s in data["sports"]} return _sports_cache.get(sport_id, True)
def _flatten(data): for c in data.get("competitions", []): for s in c.get("seasons", []): for st in s.get("stages", []): for g in st.get("groups", []): for e in g.get("events", []): yield e, c, st, g
def _to_row(event, competition, stage, group, has_timer: bool): def side(counter: int): p = next((x for x in event.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.get("short_name") or p.get("name"), "acronym": p.get("acronym"), # brak wpisu 2 = "nie wiem", nie zero "score": _num(by_id.get(RESULT_FINAL)), "is_winner": str(by_id.get(RESULT_WINNER)) == "1", }
live = event.get("status_type") == "live" seconds = event.get("played_time") if event.get("played_time") is not None else event.get("clock_time")
clock = None if live and has_timer and seconds is not None: clock = { "seconds": seconds, "running": event.get("clock_status") == "running", "display": f"{seconds // 60}:{seconds % 60:02d}", }
return { "id": event["id"], "competition": competition.get("short_name") or competition.get("name"), "stage": stage.get("name"), "group": group.get("name"), "round": event.get("round_name") or None, "start_date": event.get("start_date"), "status": { "id": event.get("status_id"), "name": event.get("status_name"), "type": event.get("status_type"), "relation": event.get("relation_status"), "postponed": event.get("status_id") == 5, # nadal type "scheduled" }, "clock": clock, "home": side(1), "away": side(2), "bet_status": event.get("bet_status"), "latency": event.get("latency") or None, }
def fetch_board(sport_id: int = 5, limit: int = 100): data = api_get("/v2/livescore", sport_id=sport_id, status_type="live", events_details="yes", show_postponed="no", limit=limit) has_timer = sport_has_timer(sport_id) return [_to_row(e, c, st, g, has_timer) for e, c, st, g in _flatten(data)]
def run_board(sport_id: int = 5, interval_s: float = 10.0, on_update=print): """Pętla odpytująca z backoffem. Kolejny tick planowany PO odpowiedzi.""" delay, max_delay = interval_s, 300.0 while True: try: on_update(fetch_board(sport_id)) delay = interval_s except Exception as err: if "403" in str(err): # poza kontraktem - ponawianie nie pomoże raise delay = min(delay * 2, max_delay) time.sleep(delay)Errors
Section titled “Errors”| Status | Cause | What to do in a loop |
|---|---|---|
400 | limit outside the enum, or a bad date window | fix the request; do not retry unchanged |
401 | token expired mid-loop | refresh the token, retry once |
403 | endpoint outside your contract (basic-livescore, feed/{id}) | stop - retrying never helps |
404 | no such event when filtering by event_id | drop it from the board |
500 | server error | exponential backoff to a ceiling |
- Show a match timeline - the detail view behind a board row
- Get one match with score and stats - the reliable score source
- Display a knockout bracket
Full reference: GET /v2/livescore ·
GET /v2/basic-livescore ·
GET /v2/events-simple ·
GET /v2/feed ·
GET /v2/sports