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_standings before rendering a Table tab.
  • Ask by league_id on competition pages, by match id on match pages.
  • Use all four features: table, qualification colours, form, rank movement.
  • Branch on has_groups or 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.