# Tennis Scraper — ATP/WTA Matches, Sofascore Stats & Odds (`webdatalabs/tennis-scraper`) Actor

Scrape ATP, WTA, Challenger, ITF and UTR tennis from Sofascore and Flashscore. Flat rows for matches (surface, round, seeds, live ranks, set scores, winner, pre-match odds, H2H), match statistics (serve, return, break points, point-by-point) and player rankings.

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

## Pricing

from $3.50 / 1,000 matches

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

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

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Tennis Scraper — ATP/WTA Matches, Sofascore Stats & Odds

Scrape tennis matches, statistics, odds and rankings from **Sofascore and Flashscore** in one run — ATP, WTA, Challenger, ITF and UTR.

### What is Tennis Scraper?

This tennis scraper turns a day of professional tennis into clean, flat rows you can model on directly: who plays whom, on what surface, in what round, with live rankings and seeds, the set-by-set score, the winner, match duration, pre-match odds and the head-to-head record. Turn on statistics and you also get serve, return and break-point numbers plus point-by-point rallies.

It reads **two independent sources and merges them**. Sofascore supplies the depth (surface, seeds, live ranks, statistics, odds, H2H); Flashscore supplies a second opinion on the fixture list and catches events Sofascore does not carry — Billie Jean King Cup ties, for example. Every row names the sources it came from, so you can always tell which data is corroborated.

### What data can it extract?

The full field list is rendered automatically from the dataset schema. These are the ones people build on:

#### Match

| Field | Description |
|---|---|
| `tour`, `tournament`, `round` | ATP / WTA / Challenger / ITF / UTR, event name, round |
| `surface` | Hardcourt outdoor, Clay, Grass, Carpet — as the source states it |
| `startTime`, `status` | ISO start time and `notstarted` / `inprogress` / `finished` / `canceled` … |
| `homePlayer`, `awayPlayer` | Player or doubles pair |
| `homeRanking`, `awayRanking` | Live ATP/WTA rank at match time |
| `homeSeed`, `awaySeed` | Tournament seed; empty when the player is unseeded |
| `set1Home` … `set5Away`, `scoreSummary` | Per-set games plus a readable `6-4 3-6 6-2` |
| `winner`, `winnerPlayer`, `durationMinutes` | Result and how long it took |
| `oddsHome`, `oddsAway` | Pre-match **decimal** odds; `oddsOpeningHome` / `oddsOpeningAway` give the opening line |
| `h2hHomeWins`, `h2hAwayWins` | Career head-to-head between these two |
| `sources`, `sofascoreId`, `flashscoreId` | Provenance and join keys |

#### Match statistics

Aces, double faults, first/second serve percentages, first/second serve points won, service and return games, break points saved, faced and converted, points and games won, tiebreaks — each for both players — plus `pointByPoint`, the rally of every game where the source publishes it.

#### Player

ATP/WTA ranking, ranking points, previous ranking and points, best ranking, tournaments played, country. Scraped by player ID you also get age, birth date, plays (handedness), height, turned pro, prize money and recent form.

### Example output

One match row, trimmed:

```json
{
  "recordType": "match",
  "matchId": "sofascore:17149654",
  "tour": "atp",
  "tournament": "Chengdu, China",
  "round": "Round of 32",
  "surface": "Hardcourt outdoor",
  "startTime": "2026-09-23T05:00:00.000Z",
  "status": "notstarted",
  "homePlayer": "Sebastián Báez",
  "awayPlayer": "Jenson Brooksby",
  "homeCountry": "Argentina",
  "awayCountry": "USA",
  "homeRanking": 51,
  "awayRanking": 77,
  "homeSeed": "8",
  "awaySeed": null,
  "scoreSummary": null,
  "winner": null,
  "oddsHome": 2.38,
  "oddsAway": 1.57,
  "oddsOpeningHome": 2.38,
  "oddsOpeningAway": 1.57,
  "sources": ["sofascore"],
  "matchUrl": "https://www.sofascore.com/tennis/match/sebastian-baez-jenson-brooksby/yMBbsgLNb"
}
```

Empty means "the source does not have it" — an unseeded player, a match with no published odds line, a match that has not been played yet. **Nothing is ever filled in with a guess.**

### How to use it

1. Pick a **mode** — matches on a date, live matches, by tournament, by player, or rankings.
2. Choose your **tours**. ATP, WTA and Challenger are on by default; add ITF and UTR for a full day.
3. Optionally turn on **statistics** and **odds**.
4. Run it, then download JSON, CSV or Excel, or read the dataset over the API.

**A day of main-tour tennis:**

```json
{
  "mode": "date",
  "date": "2026-09-22",
  "tours": ["atp", "wta"],
  "includeOdds": true,
  "maxMatches": 200
}
```

**Everything, with statistics:**

```json
{
  "mode": "date",
  "tours": ["atp", "wta", "challenger", "itf", "utr"],
  "includeStats": true,
  "includeOdds": true,
  "maxMatches": 2000
}
```

**Live scoreboard:**

```json
{ "mode": "live", "tours": ["atp", "wta", "challenger"] }
```

**ATP and WTA rankings:**

```json
{ "mode": "rankings", "tours": ["atp", "wta"], "maxMatches": 500 }
```

### How much does it cost to scrape tennis data?

This actor is **pay-per-event**: you are charged per row it actually delivers, at the rates shown in the pricing box above. Matches, match statistics and player rows are metered separately, so a plain fixture list is the cheapest thing it can do and statistics cost more because they take an extra request per match.

Two levers control your spend:

- **`maxMatches`** caps the run.
- **`includeStats`** is the expensive switch. Leave it off unless you want serve and return numbers — it adds two requests per match and bills at the statistics rate.

Selecting only the tours you need is the other big saving: a typical day has roughly ten times more ITF and UTR matches than main-tour ones.

### Use cases

- **Prediction-market and betting models** — join `oddsOpeningHome` against `oddsHome` to see how a line moved before a match, then grade it against `winner`. ATP became an official prediction-market data partner in 2026 and this is the raw material.
- **Surface-specific player form** — filter `surface = "Clay"` and group by player to build clay-only win rates that a season-long record hides.
- **Serve analytics** — turn on statistics and compare `homeFirstServePct` against `homeBreakPointsSaved` to find players who hold serve on second-serve quality rather than raw power.
- **Upset and seeding research** — filter to rows where the winner's `ranking` is far worse than the loser's to build a dataset of genuine upsets, with the seed and round they happened in.
- **Live dashboards** — run `mode: "live"` on a schedule for an in-progress scoreboard with set scores and current rankings.

### FAQ

#### Which tours and tournaments are covered?

ATP, WTA, Challenger, ITF and UTR, singles and doubles, including qualifying draws. Team events such as the Billie Jean King Cup appear through the Flashscore source.

#### How current is the data?

It is read live at run time. In-progress matches return their current set scores and status, so running it on a schedule gives a live scoreboard.

#### Why do some matches have no odds?

Because no bookmaker line was published for that match. Lower-tier ITF and UTR events usually have none. The fields are left empty rather than filled with a placeholder.

#### What does the `sources` field mean?

Which sources contributed the row: `["sofascore","flashscore"]` means both independently listed that match and it is corroborated. A single-source row is still correct, just not cross-checked. Player names are matched across sources by surname and start time, and a match is only merged when **both** players agree — the scraper will leave two rows unmerged rather than risk fusing two different matches.

#### Does it give season win-loss records?

It gives **recent form** — wins and losses over the rolling window the source publishes, in `recentWins` / `recentLosses` / `recentMatches`. These are not season totals and are deliberately not labelled as such.

#### Can I scrape a specific match, player or tournament?

Yes. The Advanced section takes `matchIds`, `playerIds` and `tournamentIds` (Sofascore IDs), and `statuses` filters to finished, in-progress or upcoming matches only.

#### Why does a run sometimes say the day is incomplete?

Every tournament is a separate request, and occasionally one cannot be fetched even after retries. When that happens the run says so in its status message instead of quietly returning a short day. Re-running fills the gap.

#### Do I need a proxy?

Yes, and residential is required — Sofascore rejects datacenter IPs. The default proxy configuration is already correct; leave it alone unless you need a specific country.

#### Is it legal to scrape tennis data?

The actor reads only publicly available pages, with no login and no paywall bypass, and does not collect personal data — professional match results, rankings and published odds are public sporting facts. You are responsible for your own use: check the sources' terms, respect database rights in your jurisdiction (the EU grants sui generis protection to database compilations), and do not republish wholesale as a competing live-score service. For research, modelling and internal analytics this is normal practice.

### Related actors

- [Transfermarkt Scraper](https://apify.com/webdatalabs/transfermarkt-scraper) — football player market values, transfers and career stats
- [Event Scraper Pro](https://apify.com/webdatalabs/event-scraper-pro) — events from Eventbrite, Meetup and Lu.ma
- [Google Trends Reliable](https://apify.com/webdatalabs/google-trends-reliable) — search interest over time
- [Reddit Scraper Pro](https://apify.com/webdatalabs/reddit-scraper-pro) — posts and comments for sentiment work
- [YouTube Transcript RAG](https://apify.com/webdatalabs/youtube-transcript-rag) — transcripts for analysis pipelines

### Support

Found a bug or need a field that is missing? Open a ticket on the **Issues** tab of this actor — it is monitored and is the fastest way to get a fix. Feature requests that fit the actor's scope are usually shipped.

# Actor input Schema

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

<b>Matches on a date</b> returns every match for the day. <b>Live matches</b> returns only what is in progress right now. <b>By tournament</b> and <b>By player</b> use the IDs in the Advanced section. <b>Rankings</b> returns the ATP/WTA ranking lists instead of matches.

## `date` (type: `string`):

Day to scrape, as YYYY-MM-DD. Leave empty for today (UTC). Ignored for Live and Rankings.

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

Which tiers to include. ATP, WTA and Challenger are on by default so a first run stays fast; add ITF and UTR for full coverage (a typical day has roughly 10x more ITF/UTR matches than main-tour ones).

## `includeStats` (type: `boolean`):

Adds a match-stats row per match: serve, return and break-point statistics plus point-by-point where published. This is the slowest and most expensive option — it adds two requests per match and bills at the match-stats rate. Matches that have not started have no statistics and produce no row.

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

Adds pre-match decimal odds (current and opening) and the head-to-head record to each match row. Odds are left empty when no line has been published for that match.

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

Upper bound on match rows for this run. Raise it for a full day across every tour.

## `matchIds` (type: `array`):

Sofascore event IDs. When set, these matches are scraped directly and the date and tour filters are ignored.

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

Sofascore player (team) IDs, used by the "By player" mode. Returns full player profiles including recent form.

## `tournamentIds` (type: `array`):

Sofascore unique-tournament IDs, used by the "By tournament" mode together with the selected date.

## `statuses` (type: `array`):

Keep only matches in these states. Leave empty to keep all.

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

Residential proxy is required — Sofascore rejects datacenter IPs with HTTP 403. Leave the default unless you know you need a specific country.

## Actor input object example

```json
{
  "mode": "date",
  "tours": [
    "atp",
    "wta",
    "challenger"
  ],
  "includeStats": false,
  "includeOdds": true,
  "maxMatches": 100,
  "matchIds": [],
  "playerIds": [],
  "tournamentIds": [],
  "statuses": [],
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}
```

# Actor output Schema

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

No description

## `match_stats` (type: `string`):

No description

## `players` (type: `string`):

No description

# 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 = {
    "mode": "date",
    "tours": [
        "atp",
        "wta",
        "challenger"
    ],
    "includeStats": false,
    "includeOdds": true,
    "maxMatches": 100,
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ]
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("webdatalabs/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 = {
    "mode": "date",
    "tours": [
        "atp",
        "wta",
        "challenger",
    ],
    "includeStats": False,
    "includeOdds": True,
    "maxMatches": 100,
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
    },
}

# Run the Actor and wait for it to finish
run = client.actor("webdatalabs/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 '{
  "mode": "date",
  "tours": [
    "atp",
    "wta",
    "challenger"
  ],
  "includeStats": false,
  "includeOdds": true,
  "maxMatches": 100,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}' |
apify call webdatalabs/tennis-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,webdatalabs/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/y36Hdnwph9eYVMqrr/builds/IQSw63XV6VACclqqb/openapi.json
