Why this is hard
The problem with expected-goals data
xG is the one number that tells you whether a striker is genuinely good or merely finishing hot, and it is the hardest number for a hobby project to get. Goals are everywhere; the chances behind them are not.
- Goal totals are noise over a single season. A forward converting at twice their expected rate is not a better player — they are a regression candidate, and the raw table cannot tell you which.
- Understat publishes xG per player per season, but scraping it means parsing an embedded JSON blob out of a page that changes shape without warning.
- Once you have xG you still need the chance-creation side — xA, key passes, xG chain — or you rank finishers and miss the players actually generating the offence.
- Nobody wants to maintain a scraper through a season. The break always lands the week your model was supposed to matter.
Path 1 · recommended
Have your AI agent build it
Our MCP server is live. Point any MCP client — Claude Code, Claude Desktop, Cursor, or your own agent — at it, and the model can read our data directly while it writes your app. You describe the board; it queries, shapes and renders it.
1. Connect the MCP server
Add the server in your MCP client and sign in with your Big Balls account. Your client registers itself and handles the token exchange — there is no key to copy.
{
"mcpServers": {
"bigballs-sports-data": {
"url": "https://mcp.bigballsdata.com/mcp"
}
}
}2. Tools your agent gets
get_matchesLive, upcoming or historical matches for a sport or league.
get_standingsLeague table with wins, losses, win_pct, games_played.
get_coverageMachine-readable map of what we hold and what we do not.
3. Ask for what you want
Paste this at your agent. It is written to make the model check coverage before it designs anything, which is what stops it inventing a field we do not serve.
Build me a soccer expected-goals board.
Use the Big Balls Sports Data MCP server. Start by calling get_coverage for
football so you know exactly what is available before you design anything.
Then:
1. Pull the current EPL standings with get_standings so each player's team
has league context next to it.
2. Fetch the xG leaderboard over plain HTTP — it is NOT an MCP tool:
GET /v1/leagues/epl/xg-leaders?stat=xg&min_minutes=900&limit=50
with the same API key.
3. Compute goals minus xG for each row and sort by it, so over- and
under-performers sit at opposite ends.
4. Render it as a sortable table.
Do not try to look players up by id from the xG rows — that endpoint returns
player_name only. Join on the name, and tell me where that is ambiguous
rather than guessing.What the agent cannot reach
The MCP server serves five read tools and the xG leaderboard is not one of them — fetch it over plain HTTP with the same key; it is the same gateway. Two of the five tools are also dead ends for soccer specifically: get_team_elo returns nulls for every club we checked (soccer Elo is unrated), and get_player_stats will not join to an xG row because those rows carry no player id. get_coverage, get_standings and get_matches are the three that work here, which is why they are the only three this page asks an agent to use.
Path 2 · hand-coded
Build it yourself
Every call below was run against production on the date in the footer, and the responses are trimmed copies of what came back. You need a free API key from the dashboard and nothing else — no SDK, no build step.
- 01
Get a key and confirm it works
Create a free key in the dashboard, then confirm the gateway sees it. Note the sport slug is football, with soccer accepted as an alias on most routes — see the gotcha below.
Verify your keybashcurl -s "https://api.bigballsdata.com/v1/coverage?sport=soccer" \ -H "x-api-key: $BBS_API_KEY"Real response, trimmed
jsonjson{ "data": { "generated_at": "2026-08-17T00:42:03.513Z", "sports": [ { "name": "Soccer", "slug": "football", "aliases": ["soccer"], "leagues": [ /* 63 */ ] } ] } } - 02
Pull the xG leaderboard — this is the spine
GET /v1/leagues/:id/xg-leaders ranks a league on Understat expected goals. The :id vocabulary is epl, laliga, serie-a, bundesliga, ligue-1 — the big five, which is where xG coverage exists. Pass stat=xg, xa, npxg, goals, assists, shots or key_passes, and min_minutes to drop small samples.
GET /v1/leagues/:id/xg-leadersbashcurl -s "https://api.bigballsdata.com/v1/leagues/epl/xg-leaders?stat=xg&min_minutes=900&limit=50" \ -H "x-api-key: $BBS_API_KEY"Real response, trimmed
jsonjson{ "data": { "league": { "id": "epl", "name": "English Premier League" }, "season": 2025, "stat": "xg", "min_minutes": 900, "leaders": [ { "rank": 1, "player_name": "Erling Haaland", "team": "Manchester City", "position": "F", "matches": 35, "minutes": 2958, "goals": 27, "assists": 8, "shots": 145, "key_passes": 26, "xg": 28.8, "xa": 4.6, "npxg": 23.1, "xg_chain": 34.2, "xg_buildup": 6.9, "goals_above_xg": -1.8 } ] } } - 03
Read goals_above_xg — this is the actual edge
Every row carries xg and goals_above_xg, already differenced for you. Positive means a player is converting above the quality of their chances; negative means the chances are there and the finishing has not followed. Sorting by that column, rather than by goals, is the whole point of an xG app.
Rank by over- and under-performancetypescripttype XgLeader = { rank: number; player_name: string; team: string | null; position: string | null; minutes: number | null; goals: number | null; xg: number | null; xa: number | null; npxg: number | null; goals_above_xg: number | null; }; const res = await fetch( 'https://api.bigballsdata.com/v1/leagues/epl/xg-leaders?stat=xg&min_minutes=900&limit=100', { headers: { 'x-api-key': process.env.BBS_API_KEY! } }, ); const { data } = (await res.json()) as { data: { leaders: XgLeader[] } }; const byRegression = [...data.leaders] .filter((l) => l.goals_above_xg !== null) .sort((a, b) => a.goals_above_xg! - b.goals_above_xg!); const due = byRegression.slice(0, 10); // under-performing their chances const hot = byRegression.slice(-10).reverse(); // finishing above their chances // A player transferred mid-season carries BOTH clubs, comma-joined, because // the season row spans them: "Bournemouth,Manchester City". Split before you // render a club badge. const clubs = (team: string | null) => (team ?? '').split(',').filter(Boolean); - 04
Add the creation side with stat=xa
The same endpoint serves expected assists and key passes. Ranking by xa surfaces the players generating chances rather than finishing them — the half of the picture a goals table never shows.
GET the xA boardbashcurl -s "https://api.bigballsdata.com/v1/leagues/epl/xg-leaders?stat=xa&min_minutes=1800&limit=2" \ -H "x-api-key: $BBS_API_KEY"Real response, trimmed
jsonjson{ "data": { "league": { "id": "epl", "name": "English Premier League" }, "season": 2025, "stat": "xa", "min_minutes": 1800, "leaders": [ { "rank": 1, "player_name": "Bruno Fernandes", "xa": 17.76, "key_passes": 137, "minutes": 3082 }, { "rank": 2, "player_name": "Enzo Fernández", "xa": 8.87, "key_passes": 68, "minutes": 3149 } ] } } - 05
Cross-check against actual goals
GET /v1/leagues/:id/top-scorers serves the box-score view of the same competition from a different source, so it is a useful sanity check on the xG board. Join it on player name — see the identity note below, because the two sources spell names differently.
GET /v1/leagues/:id/top-scorersbashcurl -s "https://api.bigballsdata.com/v1/leagues/epl/top-scorers?limit=1" \ -H "x-api-key: $BBS_API_KEY"Real response, trimmed
jsonjson{ "data": [ { "rank": 1, "player_name": "E. Haaland", "team": "Manchester City", "goals": 27, "assists": 8, "minutes": 2958, "matches": 35 } ], "updated_at": "2026-06-10T04:29:41.018Z", "meta": { "league": "epl", "season": 2025 } }
Reference
Every endpoint this tutorial uses
All on the gateway at api.bigballsdata.com. Verified against production on 2026-08-17.
| Method | Path | Returns | Why you need it | Plan |
|---|---|---|---|---|
| GET | /v1/leagues/:id/xg-leaders?stat=xg|xa|npxg|goals|assists|shots|key_passes | Ranked xG leaders with xg, xa, npxg, xg_chain, xg_buildup and goals_above_xg | The board itself. One call ranks a league on chance quality, with a min_minutes filter to drop small samples.Big five only — epl, laliga, serie-a, bundesliga, ligue-1. That is where xG exists; it is not all 63 soccer leagues. Season 2025. | Free |
| GET | /v1/leagues/:id/top-scorers | Goals, assists, minutes and matches from the box-score feed | The results-side cross-check on the xG board, from an independent source. | Free |
| GET | /v1/coverage?sport=soccer | Machine-readable map of the 63 soccer leagues and what is held for each | Check what exists before you design around it, rather than trusting a tutorial paragraph. | Free |
| GET | /v1/standings?league=EPL | League table by season with wins, draws, losses, points | Team context beside each player, and the source of canonical team ids. | Free |
| GET | /v1/matches?sport=soccer | Matches with kickoff, status, score | Fixtures and results across all 63 leagues, not just the big five. | Free |
| GET | /v1/leagues?sport=football | The eight-league slug catalogue used by the league-scoped routes | Where the :id values for the league-scoped routes come from.This route needs sport=football — sport=soccer returns an empty array here, even though the alias works on /v1/matches. It lists 8 catalogue leagues, not the 63 in /v1/coverage. | Free |
| GET | /v1/teams/:id/form | Recent results with opponent, score, result and competition | Recent form beside the season-long xG numbers — the context that says whether a number is still current. | Solo |
| GET | /v1/players/:id/club-form | Per-club season line — appearances, goals, assists, minutes, rating | The per-player season history behind a name, once you have resolved that name to an id. | Solo |
Pricing, honestly
Where the free tier stops
The whole xG board above — every league, every stat, the full chance-quality picture — runs on a free key. What Solo adds for soccer is the time dimension: form and per-club history, the numbers that say whether a season-long average is still describing the player today.
Free key
// Free key — /v1/leagues/epl/xg-leaders
{
"player_name": "Erling Haaland",
"goals": 27,
"xg": 28.8,
"goals_above_xg": -1.8
}Solo
// Solo — /v1/players/:id/club-form
[
{
"league": "Premier League",
"team": "Manchester City",
"season": 2025,
"appearances": 35,
"goals": 27,
"assists": 8,
"minutes": 2958,
"rating": 7.31
}
]- Team form — /v1/teams/:id/form returns recent results with the competition attached, so a run against Champions League opposition is not mistaken for league form.
- Per-club player history — /v1/players/:id/club-form gives appearances, goals, assists, minutes and a match rating per club-season.
- Rolling form and season projections — /v1/players/:id/rolling-stats and /v1/players/:id/season-projection, for in-season decisions rather than end-of-season summaries.
- Market data stays separate. Odds, line divergence and closing-line value sit on Edge, not Solo — they come from licensed market feeds rather than our models.
More tutorials
Other build guides
Known gaps
What is not here yet
Four honest gaps, all of which will bite you in the first hour if you do not design around them.
- xG rows carry no player id. /v1/leagues/:id/xg-leaders returns player_name only — roughly 94% of the underlying rows have no bridged player id, so exposing one would serve a mostly-empty join. You join to the rest of the API on name, and name spellings differ between sources: the xG board says "Erling Haaland" where top-scorers says "E. Haaland". This is the opposite of our hockey and basketball data, where every endpoint shares one player id.
- Searching that name can return more than one player. A search for Haaland returns two distinct ids, and they do not behave the same — one has club-form data and the other returns an empty array. Worse, the team id nested inside a player record is not always a team you can look up: one of the two resolved to a 404 at /v1/teams. Take team ids from /v1/standings or /v1/teams, never from a nested player payload.
- Soccer has no Elo ratings. /v1/teams/:id/elo returns nulls with "pending — insufficient games" for every Premier League club we checked, so schedule-strength weighting is not available for soccer the way it is for hockey and basketball. Player tiers are unrated for soccer too.
- The Russian Premier League is frozen. Those rows stopped refreshing on 2026-05-30 and carry no league id, so they are excluded from league leaderboards rather than silently bucketed into one.
Questions
- Can I build a soccer xG app on the free tier?
- Yes, entirely. The xG leaderboard, top scorers, standings, matches and coverage are all available on a free key, which is 1,000 requests a day (2,000 with GitHub connected) and needs no credit card. Solo adds form and per-club history.
- Which leagues have xG data?
- The big five: the Premier League, La Liga, Serie A, Bundesliga and Ligue 1, addressed as epl, laliga, serie-a, bundesliga and ligue-1. We hold 63 soccer leagues overall for scores, fixtures and standings, but xG is a big-five dataset — do not read the 63 figure as an xG number.
- What is the difference between xg and npxg?
- npxg is non-penalty expected goals. A penalty is worth roughly 0.76 xG and says nothing about a player creating chances from open play, so npxg is the fairer comparison between a designated penalty taker and everyone else. Both are on every row, along with xg_chain and xg_buildup for possession involvement.
- Why does the xG endpoint not return player ids?
- Because most of the underlying rows do not have one. Only about 6% of the source rows are bridged to a BigBalls player id, so returning the field would mean serving null to almost everyone and implying a join that does not work. We return player_name and document the limit instead. The entity bridge is scoped work, not a shipped feature.
- Does sport=soccer work everywhere?
- Almost. The canonical slug is football and soccer is registered as an alias, which resolves on /v1/matches and /v1/coverage. It does not resolve on /v1/leagues, where sport=soccer returns an empty array and sport=football returns the catalogue. When in doubt use football.
- Why does one player show two clubs?
- Because the season row spans a mid-season transfer, and we pass the source through rather than picking a side we cannot verify. The team field is then comma-joined, like "Bournemouth,Manchester City" — 4 of the top 100 EPL rows looked like that when this page was written. Split on the comma before you render a badge.
- Do I need the MCP server, or can I just call the REST API?
- Either, but for this build REST does more. The MCP server exposes five read tools and the xG leaderboard is not one of them, so an agent still fetches it over plain HTTP. The MCP path is worth it if you are already in an agentic editor and want the model reading standings and coverage while it writes your code.