Mon Apr 21 2026 · by Mark Hahnel · 6 min read

Every shot from the 2022 World Cup,
from one API.

Qatar 2022 had 172 goals across 64 matches — the third highest in World Cup history. Every one of them started as a shot, somewhere on a patch of pitch, with a probability attached to it. Here's how to pull and render every single one with a single API call.

The backstory

StatsBomb publishes event-level football data for free under CC-BY. It's genuinely beautiful — per-shot xG, pass locations, pressure events — and available for 75 competition-seasons including every editon of the World Cup going back to 1958.

The catch: their data lives in S3 JSON blobs, you query it through their Python SDK, and each match is a separate request. If you want to stitch shots across a whole tournament into one chart, you're looking at an afternoon of glue code, a Pandas notebook, and an eventual KeyError: 'shot_statsbomb_xg' somewhere.

I loaded all 64 matches from Qatar 2022 into foot.io's shot_events table. Now you can pull every shot from the tournament — or from any individual match — with a REST call.

The query

Here's the one-liner that gets every shot from Argentina vs France (the final), with xG, pitch coordinates, outcome, and player name:

fetch('https://amidfjrgsztslrpeaiec.supabase.co/rest/v1/shot_events' +
  '?select=x_pitch,y_pitch,xg,outcome,body_part,' +
  'player:players(name),team:teams(name)' +
  '&match_id=eq.3869685' +
  '&order=minute',
  { headers: { 'x-api-key': 'YOUR_KEY' } })
  .then(r => r.json())

Response shape (illustrative — run the query yourself for live values):

[
  {
    "x_pitch": 86.7, "y_pitch": 46.2,
    "xg": 0.089, "outcome": "saved",
    "body_part": "left_foot",
    "player": { "name": "Lionel Messi" },
    "team": { "name": "Argentina" }
  },
  …
]

Coordinates are normalised 0-100 regardless of source — StatsBomb's native 120x80 system, Understat's 100x100, and FBref's pitch grid all come out identical.

The render

40 lines of d3 v7 turns the response into an interactive shot map. Here's a live demo built from the JSON above — goals are lime dots, shots on target are black, off-target are grey, dot size = xG.

GOAL ON TARGET OFF TARGET
argentina vs france · 2022 world cup final · all 32 shots

Here's the full code:

// 1. Fetch
const shots = await fetch(
  'https://amidfjrgsztslrpeaiec.supabase.co/rest/v1/shot_events?match_id=eq.3869685' +
  '&select=x_pitch,y_pitch,xg,outcome,team:teams(name)',
  { headers: { 'x-api-key': KEY } }
).then(r => r.json());

// 2. d3 pitch — 100x64 to SVG 600x400
const svg = d3.select('#pitch').append('svg')
  .attr('viewBox', '0 0 600 400');

// ... pitch lines ...

// 3. Shots — size by xG, colour by outcome
const colour = o => o === 'goal' ? '#D0FF14'
                  : o === 'saved' ? '#0d1a00' : '#888';

svg.selectAll('circle.shot').data(shots).enter()
  .append('circle')
  .attr('cx', d => 20 + d.x_pitch * 5.6)
  .attr('cy', d => 20 + d.y_pitch * 3.6)
  .attr('r', d => 4 + d.xg * 28)
  .attr('fill', d => colour(d.outcome))
  .attr('opacity', 0.75)
  .attr('stroke', '#0d1a00').attr('stroke-width', 1);

Queries to answer real questions

With every shot, its xG, its pitch coordinates and outcome in one table, you can answer almost any attacking question from the tournament. Here are the queries — run them yourself to see what the numbers actually say.

Lowest-xG goals of the tournament

Which goals were the biggest shocks vs their expected probability?

GET /shot_events?competition=WC2022&outcome=eq.goal&order=xg.asc&limit=10

Which team over-performed their xG most?

Group goals and summed xG per team to find the side who converted above their chance quality.

SELECT team, SUM(xg) AS total_xg,
  COUNT(*) FILTER (WHERE outcome='goal') AS goals
FROM shot_events WHERE match in (…WC2022)
GROUP BY team ORDER BY (goals - total_xg) DESC;

Highest combined xG in a single match

Which game saw the most total chance-creation? (Hint: run it on the final, then compare to the group stage.)

SELECT match_id, SUM(xg) AS combined_xg
FROM shot_events WHERE match in (…WC2022)
GROUP BY match_id ORDER BY combined_xg DESC LIMIT 5;

Shot maps by player

Every shot Messi took, plotted on one pitch — or any player with player_id.

GET /shot_events?player_id=eq.{MESSI_ID}&select=x_pitch,y_pitch,xg,outcome

We've deliberately not hardcoded "the 5 insights of the tournament" — the data's all yours. We expect the interesting findings to come from the community. If you find one, drop it in our GitHub discussions and we'll feature it.

Reproduce it yourself

  1. Grab a free API key at foot.io/docs — 5,000 requests/month, no card
  2. Fork the renderer gist
  3. Loop over /matches?competition_id=eq.43&season_id=eq.106 for all 64 matches
  4. Overlay every shot on one pitch, colour by team, and you have the tournament-wide attacking map

If you ship something with this data, drop a link in our GitHub discussions and we'll feature it.

What's next

Same endpoint will cover Euros 2024, Copa America 2024, and WSL / NWSL once the StatsBomb open-data import finishes (ETA this week). Pass maps are next — we'll expose match_passes with per-pass start/end coordinates, outcome, and body part.

We think per-shot xG belongs behind a free API, not a paid feed. As long as StatsBomb's licence stays CC-BY, ours will too.

Get a free API key.

5,000 requests/month, forever. MCP server included. Paid tiers start at $19/mo — waitlist open now.

Get an API key →

Data: StatsBomb open (CC-BY) · foot.io · 2026