FotMob Match & Player Stats
Purpose
Given a free-text query (team / fixture / player name), find a specific football match on FotMob and return:
- Team match stats — possession, expected goals (xG), shots, passes, defence, duels, discipline, etc., as home/away pairs.
- Per-player match stats — every player's rating, minutes, goals/assists, passing, duels, physical metrics, plus shotmap, for that single match.
- Player general stats — a player's current-season league summary and full per-season/per-competition breakdown, recent matches, career history, traits and bio.
Read-only. Never follows, subscribes, or posts. The fast path uses FotMob's public search API and server-rendered page data (no auth, no anti-bot); a browser fallback covers the minority of match pages that defer their data to a client-side, token-signed request.
When to Use
- "What were the match stats for {home} vs {away}?" — possession, xG, shots, cards.
- "How did {player} play in {match}?" — single-match player rating and stat line.
- "Give me {player}'s season numbers" — appearances, goals, assists, rating per competition.
- Bulk/automated harvesting of match or player statistics where you'd otherwise scrape FotMob HTML.
Workflow
FotMob serves no anti-bot challenge on these surfaces — a bare cloud session (no --verified, no --proxies) and plain HTTP fetches both work. The recommended path is hybrid: pull search results and player data with cheap fetch calls, and pull match stats from the match page's embedded JSON, dropping to a browser only when a given match page ships an empty shell.
Step 1 — Find the match (and player IDs) via the search API
GET https://apigw.fotmob.com/searchapi/suggest?term=<url-encoded query>&lang=en
Returns JSON (no headers/auth needed; CORS-open). Read:
matchSuggest[].options[].payload→id(the numeric matchId),homeName,awayName,leagueName,leagueId,matchDate,homeScore,awayScore,statusId.teamSuggest[].options[].payload→ teamid+name.squadMemberSuggest[].options[].payload→ playerid+name+teamName(use this to resolve a player by name).
Pick the matchId you want. A two-word term (e.g. arsenal paris) narrows multi-fixture rivalries; a bare team name returns that team's recent/upcoming matches.
Step 2 — Get match stats + per-player match stats from the match page
Resolve the canonical URL — https://www.fotmob.com/match/<matchId> issues a 308 redirect whose Location is https://www.fotmob.com/matches/<slug>/<code>. Fetch that canonical URL and parse the <script id="__NEXT_DATA__" type="application/json"> blob:
- Match metadata:
props.pageProps.general→matchId,matchName,leagueName,homeTeam,awayTeam,finished,matchTimeUTC. - Team match stats:
props.pageProps.content.stats.Periods.All.stats[]— an array of groups (Top stats,Shots,Expected goals (xG),Physical performance,Passes,Defence,Duels,Discipline). Each group hasstats[], and each entry is{ title, key, stats: [home, away], format, type }. (Periodsalso hasFirstHalf,SecondHalf,FirstExtraHalf,SecondExtraHalf.) - Per-player match stats:
props.pageProps.content.playerStatsis an object keyed by playerId. Each value is{ name, id, optaId, teamId, teamName, isGoalkeeper, shirtNumber, usualPosition, stats: [groups...], shotmap: [...] }. Each stat group'sstatsis an object keyed by label →{ key, stat: { value, total?, type } }(e.g.Top stats → "FotMob rating" → {value: 7.26},"Accurate passes" → {value: 88, total: 95}). Players who did not play havestats: []— skip them. - Also available:
content.lineup,content.shotmap,content.momentum,content.h2h,content.matchFacts.
Pre-render caveat (important): match __NEXT_DATA__ carries content only for pre-rendered matches (popular/recent fixtures). For deferred matches the blob is just { fetchingLeagueData: true, fallback: {...}, translations: {...} } with no general/content — the app loads the data client-side. Detect this (missing props.pageProps.content) and switch to the Browser fallback below.
Step 3 — Get the player's general (season/career) stats from the player page
GET https://www.fotmob.com/players/<playerId>
Always server-rendered (200, no redirect). Parse __NEXT_DATA__ props.pageProps.data:
mainLeague→{ leagueId, leagueName, season, stats: [{ title, value }] }— current-season summary (Matches, Started, Goals, Assists, Minutes played, Rating, cards).statSeasons[]→ per season,tournaments[]with{ name, tournamentId, entryId, hasDeepStats }.playerInformation[]→ height, shirt, age, preferred foot, country, market value.primaryTeam,recentMatches[](each carries that match'smatchId,minutesPlayed,goals,assists,rating),careerHistory,trophies,marketValues,traits.
recentMatches[] is a handy shortcut: a player's recent single-match stat lines are already on the player page, so you can often skip Step 2 when you only need a player's line for a recent match.
Browser fallback (universal — required for deferred match pages)
When a match page's __NEXT_DATA__ has no content:
- Create a bare session:
browse cloud sessions create --keep-alive(no stealth/proxies). browse open "https://www.fotmob.com/match/<matchId>"— let it follow the redirect.browse wait timeout 3000— the app fetchesmatchDetails(with its client-generatedx-masheader) and hydrates.browse get text body— the rendered text reliably contains the full stat tables (possession, xG, shots…) and the per-player ratings table for both teams, regardless of pre-render state. Parse the labelled values from it. (You can also re-readbrowse get text script#__NEXT_DATA__, which may now be populated.)- For the player page, the same
browse open https://www.fotmob.com/players/<id>+browse get text bodyworks, but Step 3's plain fetch is normally sufficient.
Do not use browse snapshot/click to read stats — FotMob is heavily JS-driven and snapshot returns empty/error. Navigate by URL and read body text or __NEXT_DATA__.
Site-Specific Gotchas
- No anti-bot on these surfaces. Bare session + plain fetch both succeed; the pre-run probe and live runs needed neither
--verifiednor--proxies. Don't add stealth/proxies — they only slow it down. apigw.fotmob.com/matchDetailsand/playerDataare a dead end without thex-masheader. Both return 404 when called directly (the token is generated by FotMob's obfuscated client JS per request). Confirmed across multiple attempts. Use page-embedded__NEXT_DATA__(or the rendered body in a browser, which generatesx-masfor you) instead — do not waste time trying to forgex-mas.- Old
www.fotmob.com/api/...paths are gone.www.fotmob.com/api/matches,/api/searchapi/...etc. now 404 (handled by the Next.js app, not the API). The live API host isapigw.fotmob.com; onlysearchapi/suggest(andsearchapi/search) are usable unauthenticated. - Match
__NEXT_DATA__is populated only for pre-rendered matches. Verified live: the CL final (/matches/arsenal-vs-paris-saint-germain/377nyb, matchId 5205834) returns 8 stat groups + 46 players; the semifinal (/matches/paris-saint-germain-vs-arsenal/3775bz, 4737577) returns an empty shell (fetchingLeagueData: true, nocontent). Always check forprops.pageProps.contentbefore parsing; fall back to the browser otherwise. /match/<id>is a 308 redirect, not the page. A plain fetcher that doesn't follow redirects gets a 54-byte body whose payload IS the canonical path (/matches/<slug>/<code>). Either follow the redirect or read thatLocationand fetch the canonical URL.playerStatsis keyed by playerId, not an array, and bench players who didn't appear havestats: []. Resolve your target player byname/idand skip empty stat lines.- Stat values are positional
[home, away]pairs undercontent.stats.Periods.All.stats[].stats[].stats. Maphome/awayusinggeneral.homeTeam/general.awayTeam.xGvalues come as strings ("1.72"), most others as integers. - Search ranking ≠ relevance you want.
matchSuggestmay surface a related fixture (e.g. the semifinal before the final). Disambiguate usingpayload.matchDate,leagueName, andhomeScore/awayScorebefore committing to a matchId. - In a browser, navigate by URL — never rely on
browse snapshot/click. FotMob is fully client-rendered; the accessibility snapshot is empty/unreliable. Tabs (Stats, Lineup) can be reached by clicking the tab viabrowse evalif a screenshot of a specific view is needed, but data extraction should usebrowse get text body/__NEXT_DATA__.
Expected Output
{
"success": true,
"matchId": "5205834",
"matchName": "Paris Saint-Germain vs Arsenal",
"league": "Champions League",
"matchDate": "2026-05-30T16:00:00Z",
"home": "Paris Saint-Germain",
"away": "Arsenal",
"matchStats": {
"Ball possession": [75, 25],
"Expected goals (xG)": [1.72, 0.51],
"Total shots": [21, 7],
"Shots on target": [4, 1],
"Touches in opposition box": [42, 16],
"Big chances": [3, 1],
"Accurate passes": [809, 196],
"Corners": [11, 3],
"Fouls committed": [11, 17],
"Yellow cards": [2, 4]
},
"player": {
"id": 267365,
"name": "Marquinhos",
"teamName": "Paris Saint-Germain",
"matchStats": {
"FotMob rating": 7.26,
"Minutes played": 105,
"Goals": 0,
"Assists": 0,
"Accurate passes": { "value": 88, "total": 95 },
"Chances created": 2,
"Distance covered": 10442
},
"seasonStats": {
"league": "Ligue 1",
"season": "2025/2026",
"matches": 14,
"started": 11,
"goals": 0,
"assists": 0,
"minutesPlayed": 1049,
"rating": 6.88,
"yellowCards": 0,
"redCards": 0
}
},
"error_reasoning": null
}
Failure / not-found shape:
{
"success": false,
"matchId": null,
"error_reasoning": "No match in searchapi/suggest matchSuggest matched the query 'foo vs bar'."
}