SofaScore Tennis Scraper — Live Scores, Stats & Point-by-Point
Pricing
from $4.00 / 1,000 per match returneds
SofaScore Tennis Scraper — Live Scores, Stats & Point-by-Point
Scrape every ATP, WTA, Challenger and ITF tennis match on SofaScore by date, tournament or player — set-by-set scores, 42 match statistics, point-by-point rally data, serve and return splits, H2H record, seeds, prize money, court and surface. No API key.
Pricing
from $4.00 / 1,000 per match returneds
Rating
0.0
(0)
Developer
Scrapers Delight
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
a day ago
Last modified
Categories
Share
🎾 SofaScore Tennis Scraper — live scores, match statistics & point-by-point
Every ATP, WTA, Challenger and ITF tennis match on SofaScore — singles and doubles — as one wide, flat row: the set-by-set score with tiebreaks, both players with their world ranking, seed, country and full bio, the court and surface, the winner, 42 match statistics per player for the whole match and for each individual set, the complete point-by-point rally log, the momentum curve with break flags, and the two players' head-to-head record.
Find matches three ways: by date (a whole day's world card), by tournament, or by player. Plus a live mode for whatever is on court right now.
No API key. No login. No browser. No CAPTCHA.
📊 What you get, measured — not estimated
Every number here came from a real Apify run on 2026-09-03. Nothing is extrapolated.
| Measurement | Result |
|---|---|
| Matches in the measurement run | 60 (mode date, unfiltered, 149 s) |
| Distinct columns on the dataset | 262 |
| Columns populated on 100% of rows | 75 |
| Statistics sheet on matches that had actually started | 44 / 44 — 100% |
| Point-by-point on finished matches | 41 / 41 — 100% |
| Individual points logged across those 60 matches | 6,667 |
| Court / venue | 59 / 60 — 98% |
| Head-to-head record | 50 / 60 — 83% |
| Crowd prediction votes | 55 / 60 — 92% |
| Winner on finished matches | 41 / 41 — 100% |
| Duplicate matches | 0 (60 unique event IDs) |
| API requests for those 60 enriched matches | 509, of which 8 failed after retry (1.6%), 0 blocked |
The lower tail holds up too. A second run restricted to Challenger + ITF, finished matches
only, returned 30 matches in 98 s: 29 / 30 carry a full statistics sheet and 29 / 30 carry
point-by-point — 28 / 30 carry both (the two gaps are different matches: one doubles rubber
SofaScore publishes no statistics for, and one singles match with no point-by-point feed),
across Challenger 23 /
ITF Women 7, on red clay 11 / hardcourt 19 — and 8 of those rows were doubles, each
carrying both partners on each side.
Player mode: Carlos Alcaraz → 12 matches in 28 s, correctly scored
(7-6(2), 6-3, 6-2) with the tiebreak points parsed out.
🎯 What does this actor do?
It reads SofaScore's own public JSON API — the same feed the sofascore.com scoreboard is drawn from — and flattens it into a dataset you can put straight into a model, a spreadsheet or a database.
- 🏆 Every tour, one row shape. A Grand Slam final and an ITF W15 first round come back identically structured.
- 🗓️ Any day, forwards or backwards. Past results and future fixtures both work;
daysBackbackfills a week or a month without listing every date. - 🎾 Set-by-set scores with tiebreak points —
7-6(2), 6-3, 6-2— plus a structuredsetsarray so you never have to parse a string. - 🔴 Live matches carry live state: the current game point for both players (
0/15/30/40/A), who served first, and the set in progress. - 📈 42 statistics per player, per period. Aces, double faults, 1st and 2nd serve in and won, break points saved and converted, winners and errors split by stroke (forehand, backhand, volley, groundstroke, lob, overhead, drop shot, return), unforced errors, return points, tiebreaks — for the whole match and for each individual set.
- 🎯 Point-by-point. Every point of every game of every set, with the running game score, who was serving, and who won the game. This is the field most tennis feeds simply do not carry.
- 🌊 Momentum. SofaScore's per-game "tennis power" value with a flag on the games where a break actually happened.
- 🤝 Head-to-head. The two players' career record against each other, as three numbers on the row.
- 👤 Player bios. World ranking and the event entry ranking (they differ), seed, height, weight, plays right/left, birth date, birthplace, residence, current-year and career prize money with currency.
- 🏟️ Court and surface. The actual court ("Grandstand", "Louis Armstrong Stadium"), city, country and ground type.
- 👥 Doubles handled properly — both partners on each side as structured objects, not a mangled
"A./B."string. - 🔗 Real public URLs for the match, the tournament and each player, read out of SofaScore's own HTML rather than guessed.
🚫 What it deliberately does not do
One site, one job. These are different row shapes and belong in different actors:
- ATP / WTA rankings tables. They work on this API (500 rows each, verified) but a ranking row is a player, not a match. Different object, different actor.
- Career or season aggregates. "Player stats" here means the statistics of this match plus the player's bio, not their season totals.
- H2H match history. The row carries the head-to-head record (wins–wins–draws). The list of every past meeting is a different row shape.
- Bookmaker odds ship off by default and are not in the title. The endpoint works and you
can switch
includeOddson — but redistributing betting odds is a licensing question for your jurisdiction, so it is an explicit opt-in, never a surprise.
🚀 Quick start
Run it with nothing set and you get today's card, 40 matches, fully enriched.
{ "mode": "date", "maxMatches": 20 }
A whole day of world tennis, results only:
{ "mode": "date", "dates": ["2026-09-02"], "matchStatus": "finished", "maxMatches": 0 }
One tournament's whole season:
{ "mode": "tournament", "uniqueTournamentIds": ["2449"], "wholeSeason": true, "maxPagesPerTournament": 5 }
One player's recent matches, fast and cheap (no enrichment):
{ "mode": "player", "playerNames": ["Carlos Alcaraz"], "maxMatches": 30, "enrichMatches": false }
A week of WTA results:
{ "mode": "date", "daysBack": 6, "tours": ["WTA"], "matchStatus": "finished", "maxMatches": 0 }
⚙️ Input
| Field | Type | Default | What it does |
|---|---|---|---|
mode | enum | date | date · live · tournament · player. Auto-corrects: paste tournament IDs or player names and it switches for you. |
dates | string[] | today (UTC) | YYYY-MM-DD. Past results and future fixtures both work. |
daysBack | int | 0 | Also include the N days before each date. 6 = a full week. |
uniqueTournamentIds | string[] | — | e.g. 2449 from sofascore.com/tennis/tournament/atp/us-open/2449. |
wholeSeason | bool | false | Tournament mode: walk the current season backwards instead of using dates. |
maxPagesPerTournament | int | 3 | Season pages to walk (30 matches per page, contiguous, no overlap). |
playerNames | string[] | — | Resolved against SofaScore's own search, e.g. Iga Swiatek. |
playerIds | string[] | — | SofaScore player IDs if you already have them (every row carries homeId / awayId). |
maxMatchesPerPlayer | int | 30 | How far back to walk each player's history. |
matchStatus | enum | any | finished · inprogress · notstarted. |
tours | string[] | — | Case-insensitive filter on tour + tournament name: ATP, WTA, Challenger, ITF, US Open. |
enrichMatches | bool | true | Master switch for the deep data (4–7 extra requests per match). |
includeStatistics | bool | true | The 42-statistic sheet, per match and per set. |
includePointByPoint | bool | true | Every point of every game. |
includeMomentum | bool | true | Per-game momentum + break flags. |
includeH2H | bool | true | Career head-to-head record. |
includeVotes | bool | true | Crowd predictions. |
includeOdds | bool | false | Bookmaker markets. Opt-in. |
maxMatches | int | 40 | 0 = no limit. A full world day is 1,000+ matches. |
maxConcurrency | int | 8 | Parallel requests. |
proxyConfiguration | proxy | Apify RESIDENTIAL | Required — see Honest limits. |
Capped runs are not random. Tournaments are visited in SofaScore's own priority order (biggest tour and largest following first), so
maxMatches: 20returns the matches people actually watch, not 20 arbitrary ITF qualifiers.
📤 Output — the full field table
One row per match. Fill % below is measured on the 60-match run; where a field is structurally absent for a whole class of match (a fixture has no statistics because it has not been played), the honest denominator is given too.
Identity & state — 100% fill
| Field | Type | Notes |
|---|---|---|
eventId | int | SofaScore's match ID |
slug, customId | string | URL parts |
matchUrl | string | sofascore.com/tennis/match/<slug>/<customId> — verified 200 |
startTimestamp, startTime | int / ISO | Unix + ISO-8601 UTC |
statusCode, statusType, statusDescription | int / string | finished · inprogress · notstarted; "Ended", "4th set" |
finalResultOnly, feedLocked, hasGlobalHighlights | bool | Feed depth flags |
firstToServe | int | 1 = home, 2 = away (73%, live/finished only) |
changeTimestamp, lastChanges | int / string[] | Last feed update |
scrapedAt | ISO |
Competition — 100% fill
tour (ATP / WTA / Challenger / ITF Women…), tourSlug, tourFlag, tournamentName,
tournamentSlug, tournamentId, tournamentPriority, tournamentStartTimestamp,
tournamentEndTimestamp, uniqueTournamentId, uniqueTournamentName, uniqueTournamentSlug,
tournamentUrl, tournamentGroup ("US Open"), tournamentGroupCategory ("Grand Slam"),
titleHolder, tennisPoints, uniqueTournamentUserCount, hasEventPlayerStatistics, surface,
seasonId, seasonName, seasonYear, round, roundName, roundSlug, matchCategory
(singles/doubles), matchLevel, gender.
Players — home* and away*
| Field | Fill | Notes |
|---|---|---|
homeId, homeName, homeFullName, homeShortName, homeNameCode, homeSlug, homeUrl, homeGender, homeCountry, homeCountryCode, homeCountryCode3, homeNational, homeUserCount, homeEntryRanking, homePlayerId | 100% | Entry ranking = the rank on the event's entry list |
homeWorldRanking | 98% | The live world ranking — differs from the entry ranking |
homePlays, homeBirthDate, homeBirthDateTimestamp, homePrizeCurrent, homePrizeCurrentCurrency, homePrizeTotal, homePrizeTotalCurrency, homeUnderage | 97–98% | |
homeHeight | 95% | metres |
homeBirthplace, homeBirthCity, homeBirthCountry | 93–95% | |
homeWeight | 77% | kg |
homeClass | 68% | SofaScore seeding class |
homeResidence, homeResidenceCity, homeResidenceCountry | 57–62% | |
homeSeed | 50% | Only seeded/qualifier entries have one — "5", "Q", "WC" |
homePartners | doubles only | Both partners as {id, name, slug, country, url} — 8 / 8 doubles rows in the Challenger run |
homeTurnedPro | 0% on this sample | SofaScore did not return it for any of the 60 players; the column is declared so it populates where it exists |
Score
| Field | Fill | Notes |
|---|---|---|
homeScore / awayScore, homeScoreDisplay / awayScoreDisplay | 73% (100% of started matches) | Sets won |
scoreline | 73% | "7-6(2), 6-3, 6-2" |
sets | 73% | [{set, label, home, away, homeTieBreak, awayTieBreak}] |
homeSet1…5 / awaySet1…5 | 73 / 52 / 22 / 3% | Falls off naturally — most matches are 2–3 sets |
homeGamePoint / awayGamePoint | 73% | Live game point 0 / 15 / 30 / 40 / A |
winner, winnerName | 100% of finished matches (68% overall) | home / away |
lastPeriod, periodLabels | 5% / 100% | |
setDurationsSeconds, matchDurationSeconds | 73% | Per-set and total, in seconds |
currentPeriodStartTimestamp | 73% |
Court — 98%
venueName ("Grandstand"), venueStadium, venueCity, venueCountry, venueCountryCode,
venueId.
Statistics — 100% of matches that had started (73% of all rows)
Both a flat form for spreadsheets and a nested form for full fidelity.
- Flat, whole-match:
homeAces/awayAces,homeDoubleFaults,homeFirstServeAccuracy+homeFirstServeAccuracyTotal(89of155),homeSecondServeAccuracy(+Total),homeFirstServePointsAccuracy(+Total),homeSecondServePointsAccuracy(+Total),homeServiceGamesTotal,homeBreakPointsSaved(+Total),homePointsTotal,homeServicePointsScored,homeReceiverPointsScored,homeMaxPointsInRow,homeGamesWon,homeServiceGamesWon,homeMaxGamesInRow,homeWinnersTotal,homeForehandWinners,homeBackhandWinners,homeVolleyWinners,homeGroundstrokeWinners,homeLobWinners,homeOverheadWinners,homeDropShotWinners,homeReturnWinners,homeErrorsTotal,homeForehandErrors,homeBackhandErrors,homeGroundstrokeErrors,homeOverheadStrokeErrors,homeReturnErrors,homeUnforcedErrorsTotal,homeForehandUnforcedErrors,homeBackhandUnforcedErrors,homeVolleyUnforcedErrors,homeGroundstrokeUnforcedErrors,homeLobUnforcedErrors,homeDropShotUnforcedErrors,homeFirstReturnPoints(+Total),homeSecondReturnPoints(+Total),homeReturnGamesTotal,homeBreakPointsScored,homeTiebreaks— and theaway*twin of each. 98 flat statistic columns. - Nested, every period:
statistics={ "ALL": {...}, "1ST": {...}, "2ND": {...}, … }, each stat carrying{name, group, home, away, homeDisplay, awayDisplay}and, for ratio stats,homeTotal/awayTotal.statisticsPeriodslists which periods came back.
SofaScore reuses one key (
serviceGamesTotal) for both "Service games played" and "Return games played". A naive flatten drops one of them; here the Return-group value is emitted asreturnGamesTotalso both survive.
Point-by-point — 100% of finished matches (73% of all rows)
pointByPoint = [{ set, games: [{ game, homeGames, awayGames, serving, scoring, points: [{ homePoint, awayPoint, homePointType, awayPointType, pointDescription }] }] }],
re-sorted into ascending set and game order (SofaScore serves it newest-first).
pointByPointPoints is the point count — 6,667 across the 60-match run.
Momentum, H2H, votes, odds
| Field | Fill | Notes |
|---|---|---|
tennisPower | 67% | [{set, game, value, breakOccurred}] |
momentumPoints, momentumBreaks | 67% | Counts |
h2hHomeWins, h2hAwayWins, h2hDraws | 83% | Career duel record |
votesHome, votesAway, votesTotal | 92% | Crowd predictions |
odds | opt-in (0% by default) | [{marketName, marketGroup, marketPeriod, choices:[{name, fractionalValue, initialFractionalValue, change, winning}]}] |
enriched, enrichmentErrors | 100% / 13% | Which enrichment endpoints failed for that row, if any |
💵 Pricing
Plain pay-per-event. No monthly fee, no actor-start fee, no platform-usage surcharge.
| Event | Price | When it fires |
|---|---|---|
Per match returned (match-scraped) | $0.004 — $4.00 / 1,000 | Once per match delivered to your dataset. Matches your filters removed are never charged. |
Per match enriched (match-enriched) | $0.004 — $4.00 / 1,000 | Only when enrichment is on and at least one enrichment endpoint actually answered for that match. |
So a plain score row is $0.004 and a fully loaded row — 42 statistics, per-set splits, every point of the match, momentum, H2H, court, both bios — is $0.008.
How that compares, verified live on 2026-09-03: the busiest tennis actor on the store charges $0.008 per result flat (with a $0.02 minimum charge) for a row that carries no point-by-point and no statistics sheet. A point-by-point specialist charges $0.075 per match record plus a $0.05 start fee. This actor is half price on the base row, matches the flat incumbent's price for a far deeper row, and comes in ~9× under the point-by-point specialist with no start fee.
Billing is charge-first. Rows are charged before they are pushed, and if your run's max total charge cannot cover an enrichment, the row is delivered un-enriched rather than billed for something you did not receive. Delivered always equals billed.
⚠️ Honest limits
Read this part. It is the part most store listings leave out.
1. A residential proxy is mandatory, not optional
SofaScore runs an edge IP allow/deny list (Server: Varnish, Retry-After: 0, a 48-byte body).
Measured on 2026-09-03 from real runs:
| Egress | Result |
|---|---|
| A home broadband IP, curl | 403 × 3/3 |
| Bare Apify platform IP (no proxy), HTTP/1.1 and HTTP/2 | 403 × 8/8 |
Apify auto / datacenter proxy | 200 × 2/4 (h1), 200 × 1/4 (h2) — unusable |
| Apify RESIDENTIAL, HTTP/1.1 | 200 × 3/4 |
| Apify RESIDENTIAL + HTTP/2 | 200 × 4/4 ← what this actor uses |
The input schema defaults to Apify Proxy with the RESIDENTIAL group and the code re-applies
that default if the field arrives empty. If you point it at datacenter proxies it will mostly fail,
and it will tell you so in the run status message — it will never report a block as "no matches
found". If no proxy can be created the run exits cleanly with an explanation rather than
failing.
This is not a JS challenge, a CAPTCHA or a login wall — no browser, no cookies and no credentials are involved. It is purely which IP the request leaves from.
2. Residential transport is flaky per request; that is handled, not hidden
Measured on the 60-match run: 509 requests, 8 failed after every retry (1.6%), 0 blocked.
Transport probing beforehand measured 11 retries across 65 raw fetches (~17%) answering
Proxy responded with 590 UPSTREAM504 on the first try, so every URL is retried up to 5 times on a
fresh proxy session each attempt. A match whose
enrichment call still fails is delivered with those fields null and an enrichmentErrors note —
8 of 60 rows on the measurement run — and the run is never failed over one missing sub-request.
3. Not every match has statistics or point-by-point
This is the honest denominator behind the headline "73%":
- Matches that have not started yet carry neither — there is nothing to count. 16 of the 60 rows were fixtures.
- Of the matches that had started, 44 / 44 (100%) returned a statistics sheet, and of the finished matches 41 / 41 (100%) returned point-by-point.
- In the Challenger/ITF run, 29 / 30 finished matches had both; the one gap was a doubles rubber.
hasEventPlayerStatisticscame backfalseon all 60 rows even where statistics were served — it is not a useful predictor, so do not filter on it.
If you only want rows guaranteed to carry the deep data, set matchStatus: "finished".
4. Fields that were empty on this sample
homeTurnedPro / awayTurnedPro returned 0% across 60 players. homePartners /
awayPartners are populated only on doubles (8/8 on the doubles rows measured). odds is empty
unless you opt in. These columns are declared rather than dropped so the dataset schema stays
stable, but do not plan around them.
5. This is an undocumented internal API and routes do churn
One route this feed used to expose (/sport/tennis/scheduled-events/{date}) now returns 404 —
it died before this actor was built, and nine other plausible routes 404 as well. SofaScore can
retire the working routes the same way with no notice. If the actor ever starts returning nothing,
that is the first thing to check, and it is why every route is exercised on each run rather than
assumed.
6. Legal / fair use
https://www.sofascore.com/robots.txt (fetched 2026-09-03, HTTP 200) contains, verbatim:
User-agent: BytespiderDisallow: /User-agent: *Disallow: /*/2017-Disallow: /*/2018-Disallow: /*/2019-Disallow: /*/2020-Disallow: /*/2021-Disallow: /*/2022-Disallow: /*/2023-Disallow: /*/2024-Disallow: /*/2025-Disallow: /images/share/Disallow: /standings/Disallow: /*/standings/
(plus the same standings path in each translated language). This actor reads
api.sofascore.com/api/v1/... and links to /tennis/match/..., /tennis/player/... and
/tennis/tournament/... — none of which appear in that list. https://api.sofascore.com/robots.txt
answers 403 and publishes no policy of its own.
Only publicly visible scores and statistics are collected. No login, no personal accounts, no private data. Sports results are factual data, but you remain responsible for how you use and redistribute them in your jurisdiction — that goes double if you switch bookmaker odds on.
❓ FAQ
Do I need a SofaScore account or API key? No. The feed is public. You do need a residential proxy — see Honest limits §1.
Why is a proxy required when other scrapers do not need one? SofaScore blocks datacenter IP ranges at the edge, and Apify's platform IPs are in them. It is an IP list, not a bot challenge, so a residential exit clears it. Apify Proxy's RESIDENTIAL group is preselected for you.
How many matches are there in a day?
Between 800 and 1,200 worldwide across ATP, WTA, Challenger, ITF, singles and doubles. The
2026-09-03 card had 88 unique tournaments. Set maxMatches: 0 for all of them.
How long does a full day take?
The 60-match enriched run took 149 s. Enrichment is ~7 requests per match, so a full 1,000-match
enriched day is roughly 40–60 minutes; the same day without enrichment is a few minutes. Raise
maxConcurrency and the run timeout for big pulls.
Can I get just the scores cheaply?
Yes — set enrichMatches: false. You still get 164 columns: tournament, both players, rankings,
countries, set-by-set score with tiebreaks, live game point and winner, at $0.004 per match.
Does it do doubles?
Yes. matchCategory says doubles, and homePartners / awayPartners carry both players on each
side with their own IDs, slugs, countries and URLs.
Does it do live matches?
Yes — mode: "live" returns whatever is on court right now, with the current game point and the
set in progress. Schedule it every few minutes for a live feed.
How far back can I go?
As far as SofaScore keeps the season. wholeSeason walks a tournament's history 30 matches per
page with zero overlap between pages, and daysBack backfills up to 30 days per seed date in one
run.
How do I find a tournament ID?
Run once by date and read uniqueTournamentId off any row — or take the number off the end of a
SofaScore tournament URL.
What is the difference between homeEntryRanking and homeWorldRanking?
homeEntryRanking is the rank the player entered this event with (frozen at the acceptance
deadline). homeWorldRanking is their live ranking today. They often differ by several places, and
both are on every row.
What are homePointType and pointDescription in the point-by-point?
SofaScore's own point classification codes (which side won the point and how — ace, double fault,
break point, and so on). They are passed through unchanged rather than guessed at.
Will my run fail if SofaScore blocks it? No. A block exits the run cleanly with a status message naming the block. A failed run would be permanent on this actor's public record, and it would also be a lie — "blocked" and "no matches" are different answers and are reported differently.
Does it charge me for matches it filtered out? No. Filtering happens before billing, rows are charged before they are pushed, and enrichment is charged only when an enrichment endpoint actually answered.
Not affiliated with, endorsed by or sponsored by SofaScore. "SofaScore" is a trademark of its owner and is used here only to describe what this actor reads.