# SofaScore Tennis Scraper — Live Scores, Stats & Point-by-Point (`scrapersdelight/sofascore-tennis-scraper`) Actor

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.

- **URL**: https://apify.com/scrapersdelight/sofascore-tennis-scraper.md
- **Developed by:** [Scrapers Delight](https://apify.com/scrapersdelight) (community)
- **Categories:** Sports, Developer tools, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $4.00 / 1,000 per match returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## 🎾 SofaScore Tennis Scraper — live scores, match statistics & point-by-point

Every ATP, WTA, Challenger and ITF tennis match on [SofaScore](https://www.sofascore.com/tennis) —
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; `daysBack`
  backfills 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 structured `sets`
  array 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 `includeOdds` on — 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.

```json
{ "mode": "date", "maxMatches": 20 }
```

A whole day of world tennis, results only:

```json
{ "mode": "date", "dates": ["2026-09-02"], "matchStatus": "finished", "maxMatches": 0 }
```

One tournament's whole season:

```json
{ "mode": "tournament", "uniqueTournamentIds": ["2449"], "wholeSeason": true, "maxPagesPerTournament": 5 }
```

One player's recent matches, fast and cheap (no enrichment):

```json
{ "mode": "player", "playerNames": ["Carlos Alcaraz"], "maxMatches": 30, "enrichMatches": false }
```

A week of WTA results:

```json
{ "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: 20` returns 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` (`89` of `155`), `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 the `away*` 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`. `statisticsPeriods` lists 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 as
> `returnGamesTotal` so **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.
- `hasEventPlayerStatistics` came back `false` on 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: Bytespider
Disallow: /

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.*

# Actor input Schema

## `mode` (type: `string`):

How to find the matches.

- **date** — every ATP, WTA, Challenger and ITF match played on the given day(s). This is the full world card.
- **live** — only the matches in progress right now.
- **tournament** — only the tournaments you list below (fill in *Unique tournament IDs*).
- **player** — one or more players' matches (fill in *Player names* or *Player IDs*).

Leave it on `date` and the actor still switches automatically if you fill in tournament IDs or player names.

## `dates` (type: `array`):

Which day(s) to scrape, in UTC, e.g. `2026-09-03`. Leave empty for today. SofaScore serves both past results and future fixtures, so a date in either direction works.

## `daysBack` (type: `integer`):

Adds the N days before each date above. `0` = just that day, `6` = a full week of results. Useful for backfilling a season without listing every date by hand.

## `uniqueTournamentIds` (type: `array`):

SofaScore *unique tournament* IDs — the number at the end of a tournament URL, e.g. `2449` in `sofascore.com/tennis/tournament/atp/us-open/2449`. Every row this actor returns carries its own `uniqueTournamentId`, so a first run by date tells you the IDs you want.

## `wholeSeason` (type: `boolean`):

In tournament mode, ignore the dates and walk the tournament's current season from its most recent match backwards. Use *Max pages per tournament* to control how far back.

## `maxPagesPerTournament` (type: `integer`):

Pages of season history to walk per tournament — 30 matches per page, contiguous with no overlap.

## `playerNames` (type: `array`):

Player names to look up on SofaScore, e.g. `Carlos Alcaraz`. Each name is resolved to the tennis player it matches and their matches are returned, newest first.

## `playerIds` (type: `array`):

SofaScore player IDs, if you already have them — the number at the end of a player URL, e.g. `112783` in `sofascore.com/tennis/player/berrettini-matteo/112783`. Every row carries `homeId` / `awayId`.

## `maxMatchesPerPlayer` (type: `integer`):

How far back to walk each player's match history.

## `matchStatus` (type: `string`):

Keep only matches in this state. `finished` gives completed results (the ones that carry statistics and point-by-point), `inprogress` gives live matches, `notstarted` gives fixtures.

## `tours` (type: `array`):

Case-insensitive text filter on the tour and tournament name, e.g. `ATP`, `WTA`, `Challenger`, `ITF`, `US Open`. Leave empty for every tour. A match is kept if it matches any entry.

## `enrichMatches` (type: `boolean`):

Fetch the deep per-match data. Off, you get the fast match row: tournament, players, rankings, set-by-set score, live game point, winner. On, each match also gets the full statistics sheet, the point-by-point rally log, the momentum curve, the head-to-head record, the court and both players' bios — via 4-7 extra requests, billed as *Per match enriched*.

## `includeStatistics` (type: `boolean`):

42 statistics per player — aces, double faults, 1st/2nd serve in and won, break points saved and converted, winners and errors by stroke, unforced errors, return points, tiebreaks — for the whole match AND for each individual set. Only applies when enrichment is on.

## `includePointByPoint` (type: `boolean`):

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 do not carry. Only applies when enrichment is on.

## `includeMomentum` (type: `boolean`):

SofaScore's per-game 'tennis power' value with a flag for the games where a break happened. Only applies when enrichment is on.

## `includeH2H` (type: `boolean`):

The two players' career win-loss record against each other. Only applies when enrichment is on.

## `includeVotes` (type: `boolean`):

How SofaScore's users voted on the match before it started. Only applies when enrichment is on.

## `includeOdds` (type: `boolean`):

Adds SofaScore's bookmaker markets (full time, set winner, totals) as an `odds` array. OFF by default — redistributing betting odds is a licensing question for your jurisdiction, so it is an explicit opt-in. Only applies when enrichment is on.

## `maxMatches` (type: `integer`):

Stop after this many matches. A full world day is 1,000+ matches, so the default is deliberately modest — raise it once you know the scope you want. `0` = no limit. Tournaments are visited biggest-first (SofaScore's own priority order), so a capped run returns the matches people actually watch.

## `maxConcurrency` (type: `integer`):

How many SofaScore requests to run at once. 8 is a good balance; lower it if you see many retries.

## `proxyConfiguration` (type: `object`):

**Required — do not switch this off.** SofaScore answers 403 to Apify's own datacenter IPs on 100% of calls (measured 8/8). Apify Proxy with the RESIDENTIAL group is the only lane that works (measured 4/4) and is preselected here. Your own residential proxy URLs work too.

## Actor input object example

```json
{
  "mode": "date",
  "dates": [],
  "daysBack": 0,
  "uniqueTournamentIds": [],
  "wholeSeason": false,
  "maxPagesPerTournament": 3,
  "playerNames": [],
  "playerIds": [],
  "maxMatchesPerPlayer": 30,
  "matchStatus": "any",
  "tours": [],
  "enrichMatches": true,
  "includeStatistics": true,
  "includePointByPoint": true,
  "includeMomentum": true,
  "includeH2H": true,
  "includeVotes": true,
  "includeOdds": false,
  "maxMatches": 20,
  "maxConcurrency": 8,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}
```

# Actor output Schema

## `matches` (type: `string`):

The dataset of scraped tennis matches (one item per match).

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "dates": [],
    "uniqueTournamentIds": [],
    "playerNames": [],
    "playerIds": [],
    "tours": [],
    "maxMatches": 20
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapersdelight/sofascore-tennis-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "dates": [],
    "uniqueTournamentIds": [],
    "playerNames": [],
    "playerIds": [],
    "tours": [],
    "maxMatches": 20,
}

# Run the Actor and wait for it to finish
run = client.actor("scrapersdelight/sofascore-tennis-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "dates": [],
  "uniqueTournamentIds": [],
  "playerNames": [],
  "playerIds": [],
  "tours": [],
  "maxMatches": 20
}' |
apify call scrapersdelight/sofascore-tennis-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapersdelight/sofascore-tennis-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/fRyC36A9glHZPeZtA/builds/k6Q6kAqsYRxfTK29D/openapi.json
