Head-to-head API: build a match preview page

Head-to-head, season form, last five matches and model probabilities combine into a page with real content before kick-off - which is exactly when people search for the fixture and when an empty page costs you the traffic.

Updated 2026-10-26 9 min read

Most sports sites have a match page that is empty until kick-off and interesting for ninety minutes. That is backwards. People search for “Arsenal vs Chelsea” in the hours before the match, and a page with nothing on it converts none of that.

Four endpoints fill that gap, and none of them need the match to have started.

The four calls

EndpointGives youChanges before kick-off?
h2hHistorical record between the two clubsNo
team_statsSeason form for both sidesOnly after their next match
last_matchesLast five results for eachOnly after their next match
votesModel outcome probabilitiesOccasionally

All four are settled data. Cache them for hours, not minutes — a preview page that re-fetches head-to-head every thirty seconds is spending quota to receive the same bytes.

1. Head-to-head

GET /v3/?type=h2h&id=MATCH_ID
{
  "data": {
    "total": 12,
    "home_wins": 5, "away_wins": 4, "draws": 3,
    "rate": { "home": 42, "draw": 25, "away": 33 },
    "team_duel":    { "home": 5, "away": 4, "draw": 3 },
    "manager_duel": { "home": 2, "away": 1, "draw": 1 }
  }
}

rate is already a percentage split — whole numbers, not decimals — so a three-segment bar is a direct render with no arithmetic:

<div class="bar">
  <span style={`width:${r.home}%`} />
  <span style={`width:${r.draw}%`} />
  <span style={`width:${r.away}%`} />
</div>

manager_duel is the detail worth keeping: the record between the two managers rather than the two clubs. It is a genuine talking point and almost nobody shows it.

2. Season form

GET /v3/?type=team_stats&id=MATCH_ID

Both sides come back together, each with form, last_5 (attack and defense as percentage strings), fixtures won, drawn and lost split home and away, goals, clean sheets, failed-to-score, biggest wins, penalties, cards and the most-used formations.

Render it as a comparison, not as two lists. The same metric on one row with both numbers either side of a label is what makes it readable:

const rows = [
  ['Wins',            h.fixtures.wins.total,     a.fixtures.wins.total],
  ['Goals scored',    h.goals.for.total.total,   a.goals.for.total.total],
  ['Goals conceded',  h.goals.against.total.total, a.goals.against.total.total],
  ['Clean sheets',    h.clean_sheet.total,       a.clean_sheet.total],
  ['Failed to score', h.failed_to_score.total,   a.failed_to_score.total],
];

Note goals.for.total.total: the first total is the counts object (which also carries home and away), the second is the number. goals.for.average sits beside it.

These blocks are passed through from upstream with their shape intact, dynamic keys included — cards.yellow is keyed by minute bucket ("0-15"), not by a plain total. Only three names are normalised on the way through: loses becomes losses, att becomes attack, def becomes defense. Log one real response before binding a UI to a deep path.

The home and away splits deserve their own small section. “Arsenal have won four of five at home; Chelsea three of four away” is a sentence a preview page can state outright, and it comes straight out of fixtures.wins.home and fixtures.wins.away.

3. Last five matches

GET /v3/?type=last_matches&id=MATCH_ID

Returns home and away arrays, each entry carrying the tournament, both team names, the score, the status, a timestamp and a winner_code.

Use winner_code rather than comparing the scores yourself — it already accounts for which side the team was on:

// 1 = home won, 2 = away won, 3 = draw
const result =
  m.winner_code === 3 ? 'D'
  : (m.winner_code === 1) === isHomeTeam ? 'W' : 'L';

4. Probabilities, labelled honestly

GET /v3/?type=votes&id=MATCH_ID
{
  "data": {
    "source": "prediction",
    "match_winner": {
      "home": { "count": 5120, "percent": 54 },
      "draw": { "count": 2180, "percent": 23 },
      "away": { "count": 2180, "percent": 23 }
    },
    "form": { "home": 72, "away": 58 },
    "attack": { "home": 68, "away": 51 },
    "defense": { "home": 64, "away": 60 },
    "poisson": { "home": 1.8, "away": 1.1 },
    "goals": { "total": 2.9 }
  }
}

The source field is the literal string "prediction", and it exists so you can be straight about what this is. These are model predictions, not community votes. Label the block “Prediction” and your page stays trustworthy; label it “Fan votes” and it is simply false.

form, attack and defense are comparable 0–100 values, which makes a radar or a set of paired bars almost free. poisson gives expected goals per side and goals.total an over/under line.

Assembling it

async function preview(id) {
  const [detail, h2h, teams, last, votes] = await Promise.all([
    api(`?type=detail&id=${id}`),
    api(`?type=h2h&id=${id}`),
    api(`?type=team_stats&id=${id}`),
    api(`?type=last_matches&id=${id}`),
    api(`?type=votes&id=${id}`),
  ]);
  return { detail, h2h, teams, last, votes };
}

Five calls, once, cached for hours. Build it on your server and every visitor to that fixture is served from one fetch.

Why this is the page that ranks

A fixture page with a kick-off time and two badges is thin content: there is nothing on it a search engine can distinguish from a thousand other pages. The same page carrying a head-to-head record, season form both home and away, recent results and a stated prediction has real substance, and it exists during the window when people are actually looking for the fixture.

It also answers specific questions — “how many times have they met?”, “what is Arsenal's home record this season?” — which is the kind of concrete, checkable detail answer engines quote. Generic preview prose is not.

Add detail for venue, referee and coaches, and you have a page worth landing on before kick-off, which converts into the stream afterwards.

Summary

  • Four endpoints make a preview page: h2h, team_stats, last_matches, votes.
  • All are settled data — cache for hours, fetch once per fixture on the server.
  • rate is already percentages; winner_code already accounts for side.
  • Label votes as a model prediction, because that is what it is.
  • Show the home and away splits — that is where the sentences come from.

Frequently asked

Which endpoints does a preview page need?

Four: h2h for the historical record, team_stats for season form, last_matches for recent results, and votes for outcome probabilities. detail supplies the venue, referee and coaches around them.

When should I build the preview page?

It should exist as soon as the fixture does. Search traffic for a fixture peaks in the hours before kick-off, so a page that only becomes useful at kick-off misses most of it.

Are the votes real user votes?

No. They are model predictions, and the field is named source so you can say so honestly in your UI. Presenting a model output as a crowd poll is misleading.

How often do preview endpoints change?

Barely at all before kick-off. Head-to-head, season form and last matches are settled data, so they can be cached for hours rather than minutes.

Do preview endpoints work for every fixture?

They are premium endpoints and, like all per-match data, only serve fixtures that have a stream. Check has_stream first.