Skip to content

Display a league table

Verified on production10 min

Standings work in two steps that are easy to confuse: one endpoint returns a catalogue of tables that exist, another returns the rows of one table. A single group stage typically has eight different tables, and only one of them is the league table you probably want.

/v2/standings → catalogue: which tables exist for this scope
/v2/standings/{id} → rows: the actual table
/v2/standings-types → which columns can appear, and in what order

A standings table is attached to an object: a sport, a competition, a season, a stage or an event. For a league table you almost always want a stage, so start by walking down to one.

Terminal window
# competition → season → stage
curl "https://api.statscore.com/v2/seasons?token=YOUR_TOKEN&competition_id=13245&limit=5"
curl "https://api.statscore.com/v2/stages?token=YOUR_TOKEN&season_id=71198&limit=5"

Two flags on a stage decide what to do next, and they are not interchangeable:

FlagMeaningNext call
show_standings: "yes"this stage has tables/v2/standings (this recipe)
has_brackets: "yes"this stage is a knockout tree/v2/stages/{id}/bracket
Terminal window
curl "https://api.statscore.com/v2/standings?token=YOUR_TOKEN&object_type=stage&object_id=153098"

This returns the catalogue, not the rows:

{
"api": {
"data": {
"standings_list": [
{ "id": 219301, "type_name": "League standings", "subtype": "standings" },
{ "id": 219302, "type_name": "Form table", "subtype": "form" },
{ "id": 219303, "type_name": "Halftime standings", "subtype": "standings" },
{ "id": 219304, "type_name": "Most popular results", "subtype": "standings" },
{ "id": 219305, "type_name": "Overall stats", "subtype": "overall_stats" },
{ "id": 219306, "type_name": "Second half standings", "subtype": "standings" },
{ "id": 219307, "type_name": "Streaks", "subtype": "standings" },
{ "id": 219308, "type_name": "Team overall stats", "subtype": "overall_stats" }
]
}
}
}

Eight tables for one group stage. Select by type_name - for a normal league table that is "League standings". Do not select by array position; the order is not guaranteed.

Terminal window
curl "https://api.statscore.com/v2/standings/219301?token=YOUR_TOKEN"

The shape is standings → groups[] → participants[]:

{
"api": {
"data": {
"standings": {
"id": 219301,
"name": "League standings - Africa, Africa CON (W) 2026, stage: Group Stage",
"type_name": "League standings",
"reset_group_rank": "yes",
"groups": [
{
"id": "…",
"name": "Group A",
"zones": [],
"corrections": [],
"participants": [
{
"id": 950684,
"name": "Morocco (W)",
"participant_acronym": "MOR",
"participant_logo": "…",
"rank": 1,
"last_rank": 3,
"zone_id": null,
"zone_name": null,
"columns": [
{ "id": 1, "name": "Matches played", "short_name": "MP", "code": "mp", "value": 3 },
{ "id": 2, "name": "Won", "short_name": "W", "code": "won", "value": 2 },
{ "id": 3, "name": "Draw", "short_name": "D", "code": "draw", "value": 1 },
{ "id": 4, "name": "Lost", "short_name": "L", "code": "lost", "value": 0 },
{ "id": 6, "name": "Goals scored", "short_name": "GS", "code": "goals_scored", "value": 5 },
{ "id": 7, "name": "Goals allowed", "short_name": "GA", "code": "goals_allowed", "value": 0 },
{ "id": 5, "name": "Goals difference", "short_name": "GD", "code": "goals_diff", "value": 5 },
{ "id": 8, "name": "Points", "short_name": "Pts", "code": "pts", "value": 7 }
]
}
]
}
]
}
}
}
}

columns[] carries the label and the value together. Each entry has name, short_name, code and value, so you can render a full table from this response alone - you do not need a second call just to learn what the numbers mean.

Always read values by code, never by array index. Column sets differ between sports and between table types, and the order is not part of the contract.

import { apiGet } from "./client.js"; // z recipe 01
/** Zwraca gotową do wyrenderowania tabelę dla etapu. */
export async function getLeagueTable(stageId, { typeName = "League standings" } = {}) {
// 1 - katalog tabel dla etapu
const { standings_list } = await apiGet("/v2/standings", {
object_type: "stage",
object_id: stageId,
});
const table = standings_list.find((s) => s.type_name === typeName);
if (!table) {
const available = standings_list.map((s) => s.type_name).join(", ");
throw new Error(`No "${typeName}" for stage ${stageId}. Available: ${available}`);
}
// 2 - wiersze wybranej tabeli
const { standings } = await apiGet(`/v2/standings/${table.id}`);
// 3 - nagłówki z pierwszego wiersza; kolumny są identyczne dla całej tabeli
const first = standings.groups?.[0]?.participants?.[0];
const headers = (first?.columns ?? []).map((c) => ({
code: c.code,
label: c.short_name,
title: c.name,
}));
return {
id: standings.id,
name: standings.name,
headers,
groups: (standings.groups ?? []).map((g) => ({
name: g.name,
zones: g.zones ?? [],
corrections: g.corrections ?? [],
rows: (g.participants ?? [])
.slice()
.sort((a, b) => a.rank - b.rank)
.map((p) => ({
rank: p.rank,
movement: p.last_rank ? p.last_rank - p.rank : 0, // + = awans w tabeli
team: p.name,
acronym: p.participant_acronym,
logo: p.participant_logo,
zone: p.zone_name,
// wartości po code, nie po pozycji
values: Object.fromEntries((p.columns ?? []).map((c) => [c.code, c.value])),
})),
})),
};
}
# Python - ten sam przepływ
def get_league_table(stage_id: int, type_name: str = "League standings"):
catalogue = api_get("/v2/standings", object_type="stage", object_id=stage_id)
tables = catalogue["standings_list"]
table = next((t for t in tables if t["type_name"] == type_name), None)
if table is None:
available = ", ".join(t["type_name"] for t in tables)
raise LookupError(f'No "{type_name}" for stage {stage_id}. Available: {available}')
standings = api_get(f"/v2/standings/{table['id']}")["standings"]
groups = []
for g in standings.get("groups", []):
rows = []
for p in sorted(g.get("participants", []), key=lambda x: x["rank"]):
rows.append({
"rank": p["rank"],
"movement": (p["last_rank"] - p["rank"]) if p.get("last_rank") else 0,
"team": p["name"],
"zone": p.get("zone_name"),
"values": {c["code"]: c["value"] for c in p.get("columns", [])},
})
groups.append({"name": g.get("name"), "rows": rows,
"zones": g.get("zones", []), "corrections": g.get("corrections", [])})
return {"id": standings["id"], "name": standings["name"], "groups": groups}

Groups, not a flat list. Even a single-table league arrives wrapped in groups[]. A cup group stage has four. Never assume groups[0] is the whole competition.

reset_group_rank. When "yes", rank restarts at 1 in each group. If you flatten groups into one list without checking this, you get four teams all ranked 1.

corrections[]. Point deductions and administrative adjustments live here, separate from the computed columns. Ignoring them means your table disagrees with the official one.

zones[] and zone_name. Promotion, relegation and qualification bands. This is what drives the coloured stripes in a table UI - there is no need to infer them from rank.

last_rank. Previous position, for up/down arrows. It can be 0 or absent early in a season, so guard before subtracting.

The same three steps give you other tables - only type_name changes:

Goaltype_name
League tableLeague standings
Recent formForm table
Home-only / away-onlyHome standings / Away standings
Table at half-timeHalftime standings
Top scorersTop scorers
CardsCards

To discover the complete column vocabulary for a sport - including columns that only appear in some table types - call GET /v2/standings-types. It returns each type with its full columns[] definition, which is useful for building a fixed table header before any data arrives.

Full reference: GET /v2/standings · GET /v2/standings/{id} · GET /v2/standings-types