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
| Endpoint | Gives you | Changes before kick-off? |
|---|---|---|
h2h | Historical record between the two clubs | No |
team_stats | Season form for both sides | Only after their next match |
last_matches | Last five results for each | Only after their next match |
votes | Model outcome probabilities | Occasionally |
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.yellowis keyed by minute bucket ("0-15"), not by a plain total. Only three names are normalised on the way through:losesbecomeslosses,attbecomesattack,defbecomesdefense. 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.
rateis already percentages;winner_codealready accounts for side.- Label
votesas 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.