What's inside a match: lineups, events and ratings

8 September 2026 · 6 min read

The match list gives you a score. The match detail gives you the game. This is what is actually in one, taken from a real response rather than a schema.

The top level

{
  "id": 4741712,
  "competition": "premier-league",
  "season": "2026-27",
  "stage": null,
  "round": 1,
  "kickoff": "2026-08-21T19:00:00.000Z",
  "status": "finished",
  "minute": 90,
  "homeTeam": { "id": 104, "slug": "arsenal", "name": "Arsenal", "code": "ARS", "crestUrl": "..." },
  "awayTeam": { "id": 24, "slug": "coventry", "name": "Coventry", "code": "COV", "crestUrl": "..." },
  "score": { "home": 3, "away": 0 },
  "venue": "Emirates Stadium",
  "referee": "Thomas Bramall",
  "playedMinutes": 97,
  "lineups": { "home": { ... }, "away": { ... } },
  "events": [ ... ]
}

Two fields are worth pausing on. minute is the clock, which stops at 90 for a finished match. playedMinutes is how long the game actually lasted including added time, 97 here. If you are placing events on a timeline, the second one is your axis.

stage is null in a domestic league and carries a value in a cup, where the round number alone does not tell you whether you are in the league phase or a knockout.

Lineups

Each side has a formation and two arrays:

"lineups": {
  "home": {
    "formation": "4-2-3-1",
    "starting": [ ... 11 entries ... ],
    "bench": [ ... ]
  }
}

Every entry, on the pitch or on the bench, looks like this:

{
  "player": { "id": 3941, "slug": "david-raya", "name": "David Raya", "photoUrl": "..." },
  "shirtNumber": 1,
  "position": "Goalkeeper",
  "minutes": 90,
  "rating": 7.2,
  "goals": 0,
  "assists": 0,
  "stats": { "Goals": "0", "Assists": "0", "Minutes": "90'", ... }
}

The stats object is the per-match detail the source publishes, and it varies by position: a goalkeeper carries saves, a forward carries shots. Treat it as display material rather than something to compute on. The typed fields beside it, minutes, rating, goals, assists, are the ones that are always there and always numbers.

You rarely need to add it up yourself. A player's season stats already total the useful ones as numbers: xG, expected assists, shots, key passes, passes, tackles, interceptions, and saves for goalkeepers. /stats/expected-goals and its neighbours rank them, and a team's /stats carries shots and xG for and against.

A substitute who did not play has minutes: 0 and a null rating. That is how you tell an unused substitute from one who came on late.

Events

One flat array in chronological order. This match had 14:

{
  "minute": 15,
  "addedTime": 0,
  "type": "goal",
  "subtype": null,
  "label": "Goal",
  "teamId": 104,
  "player": { "id": 45579, "name": "Kai Havertz", ... },
  "secondaryPlayer": { "id": 79301, "name": "Riccardo Calafiori", ... }
}

secondaryPlayer carries the second person in the event, and what that means depends on the type. On a goal it is the assist. On a substitution it is the player going the other way. It is null when there is nobody, as on Bukayo Saka's unassisted goal eight minutes later.

subtype qualifies the type: a goal can be a penalty or an own goal. Check it before you credit a striker with a tap-in past his own keeper.

minute and addedTime are separate. An event in the third minute of stoppage time at the end of the first half is minute 45, added time 3. Adding them together gives 48 and puts it after a real 46th-minute event from the second half, so sort on the pair rather than the sum.

Rebuilding the scoreline

A goal-by-goal summary is a filter and a running count:

const goals = match.events.filter((e) => e.type === 'goal');

let home = 0, away = 0;
for (const g of goals) {
  const own = g.subtype === 'own-goal';
  const forHome = (g.teamId === match.homeTeam.id) !== own;
  forHome ? home++ : away++;
  console.log(
    g.minute + "' " + g.player.name +
    (g.subtype ? ' (' + g.subtype + ')' : '') +
    ' — ' + home + '-' + away
  );
}

Note the own-goal flip. The event carries the team the scorer plays for, not the team that benefited, so an own goal counts the other way. Getting this wrong is the most common bug in a match summary, and it only shows up on the rare match that has one.

The cheap version

A match detail is the largest response in the API. If you only need the score and the scorers, /matches?status=finished gives you the scoreline without the lineups, and it is a fraction of the size. Fetch the detail when somebody clicks.

More

Head-to-head records, and what they are actually good for

They settle ties in three leagues and fill every match preview. Tested as a predictor on 3,042 league matches, this season's form beat them.

Why we serve no table rather than half a season

A league table built from part of a season is not partial data, it is wrong data that looks right. The rule we apply, what it costs us, and why a 404 is the better answer.

How not to burn your free tier

Most quota goes on requests that could not have returned anything new. Read the cache headers you are already being sent, stop polling faster than the data moves, and know which plan the thing you are building actually needs.

Two teams, same points: every league breaks the tie differently

Goal difference decides it in England, Germany and France. In Spain, Italy, Portugal and Turkey the head-to-head record comes first. Sort a table yourself and you have it wrong for four of the twelve.

Build a live football scoreboard

A working scoreboard in about 60 lines: what /matches/live returns, why you poll every 30 seconds and not faster, and why your API key must never reach the browser.

Try it

Every endpoint mentioned here is in the reference, and every listing has a free tier. Start on the front page.