Skip to content

Build a livescore board

Verified on production15 min

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.

Terminal window
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_items is 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_page carries your token. Extract the page number and rebuild the URL server-side; never hand that string to a browser.

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

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.

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.

SurfaceIntervalWhy
Live board, status_type=live10 smatches the 7-15s latency band of TV-sourced coverage
Live board, latency: "1-2s" events only5 sonly worth it if your contract includes low-latency coverage
Today's schedule, no live matches60 skick-off times and postponements only
Finished matches (result verification)5 minverified_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 500 or a timeout, double the interval up to a ceiling; on 401, refresh the token and retry once; on 403, 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.

Three independent facts about the clock, all verified on live matches:

FieldUnitBehaviour
clock_timesecondstime in the current period; resets to 0 each period
played_timesecondscumulative match time; null for sports that do not have it
clock_statusstringrunning / stopped
played_time = clock_time + sum of completed periods

Production evidence:

Matchstatus_nameclock_timeplayed_time
Guemes SdE - Tristan Suarez1st half2340 (39:00)2340 (39:00)
Defensores de Vilela - Sarmiento2nd half312 (5:12)3012 (50:12)
Kimberley - Argentino Monte Maiz2nd half1991 (33:11)4691 (78:11)
USA U18 - Czech U18 (ice hockey)2nd period1225 (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.

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.

status_type has five buckets, and the scheduled bucket is a trap. All five statuses typed scheduled in the 369-status catalogue:

status_idNametype
1Not startedscheduled
5Postponedscheduled
6Start delayedscheduled
149Delayed by rainscheduled
152To finishscheduled

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.

GET /v2/basic-livescore is the smallest live payload available: scores and status only.

Terminal window
curl "https://api.statscore.com/v2/basic-livescore?token=YOUR_TOKEN&sport_id=5&timestamp=1785786097"

Two things to know before you plan around it:

  • timestamp is required. It is the only required parameter besides the token; sport_id is optional. A call without it returns 400.
  • 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.

SignalMove to push
You want updates faster than the 1-2s latency bandyes - polling cannot beat its own interval
You are trading in-play and need game_break the moment it firesyes
You poll every few seconds and mostly get unchanged datayes - you are paying for nothing
You render a public scoreboard refreshed on user navigationno - 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.

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ływ
import 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)
StatusCauseWhat to do in a loop
400limit outside the enum, or a bad date windowfix the request; do not retry unchanged
401token expired mid-looprefresh the token, retry once
403endpoint outside your contract (basic-livescore, feed/{id})stop - retrying never helps
404no such event when filtering by event_iddrop it from the board
500server errorexponential backoff to a ceiling

Full reference: GET /v2/livescore · GET /v2/basic-livescore · GET /v2/events-simple · GET /v2/feed · GET /v2/sports