Football standings API: build a league table
The standing endpoint returns more than points: home and away splits, a form string, qualification colours and whether a team moved up or down. Here is how to render all of it, including the competitions that have no table at all.
Updated 2026-10-26 7 min read
A league table looks like the easiest thing in a sports app and then turns out to carry four separate features: the standings themselves, home and away splits, qualification colouring, and rank movement. The endpoint returns all four. Most implementations render the first and throw the rest away.
Check before you ask
Competitions differ. A knockout cup has no table, and requesting one is a wasted round trip plus an empty tab for the user. Every league object in a fixture list already tells you:
if (league.has_standings) tabs.push('table'); Two ways to ask
# from a match page
GET /v3/?type=standing&id=MATCH_ID
# from a competition page
GET /v3/?type=standing&league_id=39&season_id=2026 season_id is optional and defaults to the current season. Use
the league_id form when the page is about the competition rather
than about one fixture.
What comes back
{
"success": true,
"data": {
"league": {
"name": "Premier League", "season": "2026/2027",
"format": "league", "has_groups": false,
"country": "England", "flag": "...", "logo": "...",
"color": "#38003c", "text_color": "#ffffff"
},
"table": [
{
"position": 1,
"team": { "name": "Arsenal", "short_name": "ARS", "badge": "..." },
"stats": { "played": 9, "wins": 7, "draws": 1, "losses": 1,
"goals_for": 20, "goals_against": 7,
"goal_diff": "+13", "points": 22 },
"stats_home": { "played": 5, "wins": 4 },
"stats_away": { "played": 4, "wins": 3 },
"promotion": { "text": "Champions League", "id": 2 },
"form": "WWDWL",
"status": "up"
}
]
}
} The four features, one at a time
1. The table
Rows arrive in position order, so render them as they come. The eight numbers
in stats are the standard columns: played, won, drawn, lost,
goals for, goals against, goal difference, points.
<tr>
<td>{row.position}</td>
<td><img src={row.team.badge} /> {row.team.name}</td>
<td>{row.stats.played}</td>
<td>{row.stats.wins}</td>
<td>{row.stats.draws}</td>
<td>{row.stats.losses}</td>
<td>{row.stats.goal_diff}</td> {/* already "+13" or "-4" */}
<td><strong>{row.stats.points}</strong></td>
</tr> On narrow screens, drop everything except position, badge, name, played and points. Those five are what people actually scan for.
2. Qualification colours
promotion is the band a position sits in — Champions
League, relegation, play-offs. It carries text and an
id, and is null for rows in no band.
The colour is yours to choose. The row does not carry
one. color and text_color on the league
object are the competition’s own brand colours, not the band’s. Map the
bands yourself, keyed on id so a renamed label does not break
the colouring:
const bandColour = { 2: '#2ee88a', 3: '#38bdf8', 4: '#f59e0b', 5: '#ef4444' };
{row.promotion && (
<span style={`border-left:3px solid ${bandColour[row.promotion.id] ?? '#334155'}`}
title={row.promotion.text} />
)}
Build your legend from the distinct promotion.text values
present in the table rather than from a hard-coded list — the bands
differ per competition and per season.
3. Form
A short string of result characters, most recent last. Render it as pills, not as letters in a cell:
const colour = { W: 'bg-green-500', D: 'bg-slate-500', L: 'bg-red-500' };
[...row.form].map(r => <span class={`pill ${colour[r]}`}>{r}</span>) 4. Rank movement
status says whether the team moved since the previous round.
It is passed through from upstream rather than normalised, so treat the
values as data and keep a fallback:
const arrow = { up: '▲', down: '▼', same: '–' }[row.status] ?? '–'; Group stages
Multi-group competitions do not return one flat table. Check the league object and branch:
if (data.league.has_groups) {
// one table per group, each with its own name
data.groups.forEach(g => renderTable(g.name, g.table));
} else {
renderTable(null, data.table);
} Writing the flat case only is the usual cause of a Champions League page that renders nothing in September.
Cache it properly
A table changes when matches finish, not continuously. Refreshing it on your live-score interval wastes most of your quota for no visible benefit.
- No match in that competition in play — cache for hours. Nothing can change it.
- A match in play — a few minutes is plenty; the table only moves when the result is final.
- Cache per league, not per match. Twenty match pages in the same competition share one table. Keying the cache on match id turns one request into twenty.
That last point is worth stating plainly because it is the most common
expensive mistake: standing is per-competition data reached
through a per-match id, and caching it as if it were per-match multiplies
your usage by the number of fixtures on screen.
Summary
- Check
has_standingsbefore rendering a Table tab. - Ask by
league_idon competition pages, by match id on match pages. - Use all four features: table, qualification colours, form, rank movement.
- Branch on
has_groupsor your group stages render empty. - Cache per league and refresh on finished matches, not on a live clock.
Frequently asked
Which competitions have a league table?
Not all of them. A knockout cup structurally has none. Every league in a matches or scores response carries has_standings, so you can decide whether to render a Table tab before you request anything.
Can I get a table without a match id?
Yes. standing accepts either a match id or a league_id, with an optional season_id. Use league_id when you are building a competition page rather than a match page.
How do I show group stages?
Multi-group competitions return a groups array instead of one flat table. Check has_groups on the league object and render one table per group.
What is the form string?
A short run of recent results as single characters, most recent last - for example WWDWL. Render it as coloured pills rather than as text.
What does the status field mean on a row?
Rank movement since the previous round: up, down or same. It is what lets you draw the little arrow next to a position.