Skip to content

Compare two teams head to head

Verified on production8 min

Head to head is a single call and it answers three questions at once: the all-time balance, the recent form against this specific opponent, and the list of past meetings. It is the cheapest useful widget in the API.

The endpoint hangs off one participant and takes the other as a parameter:

Terminal window
curl "https://api.statscore.com/v2/participants/136761/compare?token=YOUR_TOKEN&compare_participant_id=136763"
ParameterWhereRequiredMeaning
idpathyesthe first team
compare_participant_idqueryyesthe second team
events_detailsquerynoyes includes each meeting's participants and results

Three things under api.data, and they are unrelated shapes:

{
"api": {
"data": {
"participants": [
{ "id": 136761, "name": "Lech Poznan", "short_name": "Lech Poznan", "area_name": "Poland", "type": "team" },
{ "id": 136763, "name": "Legia Warszawa", "short_name": "Legia Warszawa", "area_name": "Poland", "type": "team" }
],
"head2head": {
"all_matches_stats": {
"participant1_won": 20,
"participant1_draw": 15,
"participant1_lost": 27,
"participant2_won": 27,
"participant2_draw": 15,
"participant2_lost": 20,
"total_events_played": 62
},
"last_10_matches_stats": {
"participant1_won": 3,
"participant1_draw": 5,
"participant1_lost": 2,
"participant2_won": 2,
"participant2_draw": 5,
"participant2_lost": 3
}
},
"h2h_events": {
"competitions": [ "…" ]
}
}
}
}

The stats are symmetric by construction: participant1_won equals participant2_lost, and the draws match. Useful as a sanity check on your own parsing, not as extra information.

3. The event list is nested five levels deep

Section titled “3. The event list is nested five levels deep”

h2h_events is not a flat array of matches. Meetings are grouped by competition, and each competition repeats the full tree the rest of the API uses:

h2h_events.competitions[]
-> seasons[]
-> stages[]
-> groups[]
-> events[]

For Lech against Legia that was 3 competitions holding 5 events between them. Two consequences:

  • You must flatten it. A "last meetings" list needs a walk, not an index.
  • Matches are split by competition, so sorting by date only works after flattening. The league meeting and the cup meeting sit in different branches.

Each event carries the usual shape, so scores are read exactly as in recipe 03 - by result id, from participants[].results[]:

{
"id": 5978349,
"name": "Lech Poznan - Legia Warszawa",
"start_date": "2026-04-26 15:30",
"status_type": "finished",
"status_name": "Finished",
"participants": [
{ "short_name": "Lech Poznan", "counter": 1, "results": [{ "id": 2, "value": "4" }] },
{ "short_name": "Legia Warszawa", "counter": 2, "results": [{ "id": 2, "value": "0" }] }
]
}

counter: 1 is home and 2 is away, so you get the venue for free. Remember that result id: 2 excludes the penalty shootout - for a cup meeting decided on penalties you also need id: 7.

import { apiGet } from "./client.js"; // z recipe 01
const RESULT_FINAL = 2;
/** Splaszcza h2h_events.competitions -> seasons -> stages -> groups -> events */
function flattenEvents(h2hEvents) {
const out = [];
for (const comp of h2hEvents?.competitions ?? []) {
for (const season of comp.seasons ?? []) {
for (const stage of season.stages ?? []) {
for (const group of stage.groups ?? []) {
for (const ev of group.events ?? []) {
out.push({ ev, competition: { id: comp.id, name: comp.name }, season: { id: season.id, name: season.name } });
}
}
}
}
}
return out;
}
export async function getHeadToHead(teamId, opponentId) {
const data = await apiGet(`/v2/participants/${teamId}/compare`, {
compare_participant_id: opponentId,
events_details: "yes",
});
// `participant1` / `participant2` w head2head to POZYCJE, nie id.
// Mapujemy je przez participants[], zeby odwrocenie wywolania nie przestawilo bilansu.
const [p1, p2] = data.participants ?? [];
const balance = (bucket) =>
bucket && {
[p1.id]: { won: bucket.participant1_won, draw: bucket.participant1_draw, lost: bucket.participant1_lost },
[p2.id]: { won: bucket.participant2_won, draw: bucket.participant2_draw, lost: bucket.participant2_lost },
};
const meetings = flattenEvents(data.h2h_events)
.map(({ ev, competition, season }) => {
const side = (p) => ({
id: p.id,
name: p.short_name ?? p.name,
home: p.counter === 1,
// result id 2 = regulaminowy + dogrywka, BEZ karnych (patrz recipe 03)
goals: Number((p.results ?? []).find((r) => r.id === RESULT_FINAL)?.value ?? NaN),
});
const sides = (ev.participants ?? []).map(side);
return {
eventId: ev.id,
date: ev.start_date,
finished: ev.status_type === "finished",
competition,
season,
home: sides.find((s) => s.home) ?? null,
away: sides.find((s) => !s.home) ?? null,
};
})
// sortujemy PO splaszczeniu - w drzewie mecze sa rozbite po rozgrywkach
.sort((a, b) => String(b.date).localeCompare(String(a.date)));
return {
teams: { [p1.id]: p1.short_name ?? p1.name, [p2.id]: p2.short_name ?? p2.name },
allTime: { ...balance(data.head2head?.all_matches_stats),
played: data.head2head?.all_matches_stats?.total_events_played ?? null },
last10: balance(data.head2head?.last_10_matches_stats),
meetings,
};
}
# Python - ten sam kontrakt
RESULT_FINAL = 2
def flatten_events(h2h_events):
for comp in (h2h_events or {}).get("competitions", []):
for season in comp.get("seasons", []):
for stage in season.get("stages", []):
for group in stage.get("groups", []):
for ev in group.get("events", []):
yield ev, comp, season
def get_head_to_head(team_id: int, opponent_id: int):
data = api_get(f"/v2/participants/{team_id}/compare",
compare_participant_id=opponent_id, events_details="yes")
p1, p2 = data.get("participants", [None, None])
def balance(bucket):
if not bucket:
return None
# participant1/2 to pozycje - mapujemy przez participants[]
return {
p1["id"]: {"won": bucket.get("participant1_won"), "draw": bucket.get("participant1_draw"),
"lost": bucket.get("participant1_lost")},
p2["id"]: {"won": bucket.get("participant2_won"), "draw": bucket.get("participant2_draw"),
"lost": bucket.get("participant2_lost")},
}
meetings = []
for ev, comp, season in flatten_events(data.get("h2h_events")):
sides = []
for p in ev.get("participants", []):
goal = next((r.get("value") for r in p.get("results", []) if r.get("id") == RESULT_FINAL), None)
sides.append({"id": p.get("id"), "name": p.get("short_name") or p.get("name"),
"home": p.get("counter") == 1,
"goals": int(goal) if goal not in ("", None) else None})
meetings.append({
"event_id": ev.get("id"), "date": ev.get("start_date"),
"finished": ev.get("status_type") == "finished",
"competition": {"id": comp.get("id"), "name": comp.get("name")},
"home": next((s for s in sides if s["home"]), None),
"away": next((s for s in sides if not s["home"]), None),
})
# sortujemy PO splaszczeniu - w drzewie mecze sa rozbite po rozgrywkach
meetings.sort(key=lambda m: str(m["date"]), reverse=True)
all_stats = (data.get("head2head") or {}).get("all_matches_stats") or {}
return {
"teams": {p1["id"]: p1.get("short_name"), p2["id"]: p2.get("short_name")},
"all_time": {**(balance(all_stats) or {}), "played": all_stats.get("total_events_played")},
"last10": balance((data.get("head2head") or {}).get("last_10_matches_stats")),
"meetings": meetings,
}

Worth knowing before you promise a feature:

  • No per-match statistics. The meetings carry scores and status, not possession or shots. For that you call GET /v2/events/{id} per match.
  • No aggregate goals. head2head counts wins, draws and losses; goals scored across the rivalry are not there. Sum them from the flattened list.
  • The event list is not the full history. All-time says 62 meetings; h2h_events returned 5. It is a recent sample, not an archive, so label it "recent meetings" rather than "all meetings".
StatusCause
400missing compare_participant_id
401missing, expired or malformed token
403one of the teams is outside your contract
404unknown participant id

Full reference: GET /v2/participants/{id}/compare · GET /v2/events/{id}