Soccer Data gives you expected goals (xG) from two endpoints. /matches/{match_id}/stats returns each team's xG for the whole match and for each half, and /matches/{match_id}/shotmap returns every shot with its own xG value, pitch coordinates, situation and outcome. Check the match's xg_available and shotmap_available flags first, because not every competition or match has xG.
What xG means
Expected goals (xG) is the probability, from 0 to 1, that a shot like this one is scored: a shot with an expected_goals of 0.30 goes in about three times in ten. Added up over a team's shots, it measures the quality of the chances the team created, whatever the final score.
Which endpoint returns xG?
| You want | Endpoint | Field |
|---|---|---|
| Team xG for a finished match, by half | /matches/{match_id}/stats |
overview.expected_goals, np_expected_goals |
| Every shot, with xG and coordinates | /matches/{match_id}/shotmap |
data[].expected_goals |
| A player's xG and xA in one match | /matches/{match_id}/player-stats |
shooting.expected_goals, shooting.expected_assists |
| A player's xG over a season | /players/{player_id}/stats |
scoring.expected_goals, scoring.xg_matches |
| Running team xG while a match is live | /matches/{match_id}/live-stats |
stats.expected_goals, when present |
The examples below use Brighton 3-0 Arsenal (match_nbqbxh7kv0q8) and Bukayo Saka (player_ul3vc6om97u5). Every request sends your key in the X-Api-Key header; keep that key on your server.
Step 1: check that xG exists
xG coverage depends on the competition and the season. To find competitions that have it, filter the coverage list by data type:
curl "https://api.soccer-data.co/v1/coverage/leagues?data_type=xg&search=Premier" \
-H "X-Api-Key: YOUR_API_KEY"
For the share of matches with xG in each season, call /coverage/leagues/{competition_id} and read data_types.xg.coverage_pct. In the Premier League (competition_705uv6jm8myn), for example, xG goes back to 2022/23, while fixtures and team stats go back to 2014/15.
Each match then tells you what it has. The flags are on every match in /matches and /matches/{match_id}:
{
"id": "match_nbqbxh7kv0q8",
"status": "finished",
"home_team": { "id": "team_v70dqyg1h8b0", "name": "Brighton & Hove Albion" },
"away_team": { "id": "team_f0ibhxdw7hs5", "name": "Arsenal" },
"xg_available": true,
"shotmap_available": true
}
| Flag | Means |
|---|---|
xg_available |
Match-level xG exists. |
shotmap_available |
The shot map has at least one shot with an xG above zero. |
xg_quality |
full when every shot has xG, otherwise none. Treat any other value as none. |
xg_available can be true while shotmap_available is false: the match has team xG but no shot-by-shot values. Check shotmap_available before you draw a shot map.
Step 2: team xG from match stats
curl "https://api.soccer-data.co/v1/matches/match_nbqbxh7kv0q8/stats" \
-H "X-Api-Key: YOUR_API_KEY"
{
"data": {
"match_id": "match_nbqbxh7kv0q8",
"overview": {
"expected_goals": {
"all": { "home": 1.31, "away": 1.63 },
"first_half": { "home": 0.36, "away": 0.39 },
"second_half": { "home": 0.95, "away": 1.24 }
},
"total_shots": { "all": { "home": 17, "away": 11 } }
}
}
}
Every stat has an all value plus first_half and second_half, each split into home and away. np_expected_goals has the same shape with penalties left out. Both are null when the match has no xG, never 0.
Step 3: per-shot xG from the shot map
curl "https://api.soccer-data.co/v1/matches/match_nbqbxh7kv0q8/shotmap" \
-H "X-Api-Key: YOUR_API_KEY"
The response has the match's event (with home_team_id and away_team_id), a data array of shots, an np_xg_summary and a meta object. One shot looks like this:
{
"id": "shot_as8bbsa4f1mq",
"player_id": "player_ul3vc6om97u5",
"player_name": "Bukayo Saka",
"team_id": "team_f0ibhxdw7hs5",
"x": 7.9,
"y": 45.3,
"minute": 45,
"result": "save",
"expected_goals": 0.303,
"situation": "fast_break",
"body_part": "right_foot",
"is_goal": false,
"is_on_target": true,
"is_penalty": false
}
Other fields on each shot include team_name, is_headed, is_outside_box, goalkeeper, goal_mouth_location and goal_type. A shot's expected_goals is null when no xG exists for it.
Pitch coordinates
Every shot is flipped so both teams attack the same goal, which lets you draw them on one half:
xruns from 0 at the goal line being attacked to 100 at the shooter's own goal line. The edge of the box is at 16.5 and the penalty spot at about 11.yruns across the pitch from 0 to 100, with 50 in the middle of the goal. The box runs from 20.5 to 79.5.
Situations
situation says how the chance came about:
| Kind | Values |
|---|---|
| Open play | assisted, regular, fast_break |
| Dead ball | corner, free_kick, set_piece, throw_in_set_piece |
| Other | penalty, shootout, own_goal |
result gives the outcome, for example goal, save, miss, block, blocked_off_line or post.
One player's shots
Add player_id to get only that player's shots:
curl "https://api.soccer-data.co/v1/matches/match_nbqbxh7kv0q8/shotmap?player_id=player_ul3vc6om97u5" \
-H "X-Api-Key: YOUR_API_KEY"
Live and finished matches
The xG endpoints behave differently while a match is being played:
| Endpoint | During the match | After full time |
|---|---|---|
/matches/{match_id}/stats |
409 MATCH_IS_LIVE |
Final team xG, once the stats are in |
/matches/{match_id}/live-stats |
Running stats, which can include expected_goals |
Final snapshot for up to 5 minutes, then 409 |
/matches/{match_id}/shotmap |
Shots so far, with meta.is_final: false |
Every shot, with meta.is_final: true |
So during a match you can poll the shot map. Pass the id of a match in play, such as West Ham United v Queens Park Rangers (match_0i2m68v7oaxa) from the Live matches guide:
curl "https://api.soccer-data.co/v1/matches/match_0i2m68v7oaxa/shotmap" \
-H "X-Api-Key: YOUR_API_KEY"
While meta.is_final is false, more shots can still appear, and an empty data list just means nobody has shot yet. On the paid plans (Pro and Business), live data is refreshed every 15 seconds, so polling faster returns the same answer and still counts as a request.
Example: summing xG per team
This Node 18+ script (save it as an .mjs file) checks the flag, fetches the shot map and adds up each team's xG, with and without penalties. It leaves out penalty shoot-out kicks and own goals:
const headers = { "X-Api-Key": process.env.SOCCER_DATA_KEY };
const base = "https://api.soccer-data.co/v1/matches/match_nbqbxh7kv0q8";
const match = (await (await fetch(base, { headers })).json()).data;
if (!match.shotmap_available) {
console.log("No shot-by-shot xG for this match.");
} else {
const shotmap = await (await fetch(`${base}/shotmap`, { headers })).json();
const totals = {};
for (const shot of shotmap.data) {
if (shot.expected_goals === null) continue;
if (shot.situation === "shootout" || shot.situation === "own_goal") continue;
const side = shot.team_id === shotmap.event.home_team_id ? "home" : "away";
totals[side] ??= { team: shot.team_name, xg: 0, npxg: 0, shots: 0 };
totals[side].xg += shot.expected_goals;
if (!shot.is_penalty) totals[side].npxg += shot.expected_goals;
totals[side].shots += 1;
}
for (const t of Object.values(totals)) {
console.log(`${t.team}: ${t.xg.toFixed(2)} xG, ${t.npxg.toFixed(2)} non-penalty, ${t.shots} shots`);
}
}
You don't always have to add it up yourself. np_xg_summary.live already holds each team's non-penalty xG summed from the shots, as home_team and away_team, and np_xg_summary.stored holds the recorded match-level figure, which can differ from the sum.
Common mistakes
- Calling
/matches/{match_id}/shotmapwithout checkingshotmap_available. You can spend a request on a match with no shot-level xG. - Reading
xg_available: trueas "every shot has xG". Checkshotmap_availableorxg_quality. - Treating
nullxG as zero. It means no xG exists for that shot or match. - Asking
/matches/{match_id}/statsfor xG during a match. It returns409; use/matches/{match_id}/live-statsor the shot map until full time.
Next steps
- Player and team stats: the full guide to match stats, shot maps and heatmaps.
- Shot map reference: every field and note.
- Coverage: which competitions and seasons have xG.
- xG is one of the 10 things to check in a football data API.
- Create an account to get your API key.
Frequently asked questions
Which football API has xG data?
Soccer Data has xG in two places: team totals by half in /matches/{match_id}/stats, and a value for every shot in /matches/{match_id}/shotmap. Coverage varies by competition, so call /coverage/leagues?data_type=xg to see which competitions have it.
How do I get xG for every shot in a football match?
Call /matches/{match_id}/shotmap on the Soccer Data API. Each shot in data has expected_goals, x and y coordinates, the minute, the situation, the body_part and the result; add player_id to get one player's shots only.
Does the shot map include pitch coordinates?
Yes. Each shot has x (0 at the goal being attacked, 100 at the shooter's own goal line) and y (0 to 100 across the pitch, 50 in the middle of the goal), and every shot is flipped so both teams attack the same goal.
Can I get live xG during a match?
Yes. In Soccer Data, /matches/{match_id}/shotmap works while a match is in play, with meta.is_final set to false, and /matches/{match_id}/live-stats can include running team expected_goals. /matches/{match_id}/stats returns 409 until full time.
How far back does the xG data go?
It depends on the competition. In the Premier League, Soccer Data has xG from 2022/23; /coverage/leagues/{competition_id} shows the share of matches with xG in every season of a competition.
What is the difference between xg_available and shotmap_available?
xg_available means a match has some xG, such as team totals. shotmap_available means its shot map has shots with xG values, so check it before you draw a shot map.