# Tennis Stats Scraper: ATP & WTA Match History, Elo (`deepurple/tennis-abstract-scraper`) Actor

Tennis Abstract match history with serve and return stats for many ATP and WTA players per run, plus profiles, season and surface splits and Elo tables. Filters by date, surface, level, opponent rank. $6.40 per 1,000 rows, no monthly fee.

- **URL**: https://apify.com/deepurple/tennis-abstract-scraper.md
- **Developed by:** [Misha Ershov](https://apify.com/deepurple) (community)
- **Categories:** Sports, News, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $6.40 / 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.

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 Abstract Scraper: Match Stats, Elo, ATP & WTA

Pulls structured tennis data from [Tennis Abstract](https://www.tennisabstract.com): full match logs with serve and return statistics for any ATP or WTA player, player profiles, season and surface splits, and the current ATP/WTA Elo rankings (overall, hard, clay, grass).

Give it player names, slugs or Tennis Abstract URLs. Get back clean rows you can drop into a spreadsheet, a model or a betting/analytics pipeline. No login and no API key are needed.

### What you get

- **Matches** (`type: "match"`): date, tournament, surface, level (raw code plus a readable `levelName`), round, result, score (plus `sets` and `outcome`), `matchId`, opponent with rank/seed/hand/age data, minutes, and the serve statistics for both players (aces, double faults, first/second serve points won, break points). Derived percentages (ace %, first serve in %, service points won %, return points won %, dominance ratio) are computed for you. Newest matches come first.
- **Players** (`type: "player"`): current rank, peak rank, age, hand, backhand, height, country, titles, career match count and the current Elo values.
- **Seasons** (`type: "season"`): year-by-year and surface/last-52-week split tables.
- **Elo** (`type: "elo"`): the full Tennis Abstract Elo ranking tables for ATP and WTA.

### Input

| Field | Type | Default | Description |
|---|---|---|---|
| `mode` | `players` / `elo` | `players` | `players` scrapes the players you list. `elo` scrapes the Elo ranking tables only. |
| `players` | list of strings | empty | Names (`Jannik Sinner`), Tennis Abstract slugs (`JannikSinner`) or URLs containing `p=`. Required in `players` mode. |
| `tour` | `auto` / `atp` / `wta` | `auto` | `auto` tries ATP first, then WTA. |
| `dateFrom`, `dateTo` | `YYYY-MM-DD` | none | Keep only matches in this date range. |
| `surfaces` | list of `Hard`, `Clay`, `Grass`, `Carpet` | all | Keep only these surfaces. |
| `levels` | list of level names | all | Keep only these tournament levels: `Grand Slam`, `Masters 1000`, `Tour 250/500`, `Tour Finals`, `Challenger`, `ITF`, `Team & Olympics`, `Other`. |
| `rounds` | list of rounds | all | Keep only these rounds: `R128`, `R64`, `R32`, `R16`, `QF`, `SF`, `F`, `RR`, `BR`, `Q1`, `Q2`, `Q3`. |
| `opponentRankMin`, `opponentRankMax` | integer | `0` (off) | Keep only matches where the opponent's ranking at the time is within this range. Matches with unknown opponent rank are dropped when either is set. If min is above max they are swapped. |
| `results` | `all` / `wins` / `losses` | `all` | Filter by the player's result. |
| `includeUpcoming` | boolean | `false` | Also output scheduled matches (result `U`). |
| `opponent` | string | none | Keep only matches whose opponent name contains this text (case-insensitive). |
| `maxMatchesPerPlayer` | integer 0-5000 | `500` | Cap on match rows per player, newest first. `0` means no match rows (profile and seasons only). |
| `includeProfile` | boolean | `true` | Output one `player` row per player. |
| `includeSeasonStats` | boolean | `false` | Output `season` rows (yearly and split tables). |
| `eloTours` | list of `atp`, `wta` | both | Which Elo tables to read in `elo` mode. |
| `maxResults` | integer | `1000` | Max Elo rows per tour in `elo` mode. |
| `onlyNew` | boolean | `false` | Skip matches already returned by earlier runs with the same state. |
| `proxyConfiguration` | object | Apify proxy | Datacenter proxy is enough. |

#### Match row fields added for filtering

- `levelName`: readable tournament level, derived from the raw `level` code (table below).
- `matchId`: Tennis Abstract match id, `null` for older matches.
- `sets`: list of `{winnerGames, loserGames, tiebreak}` from the winner's point of view (the same as `score`); `tiebreak` is the loser's tie-break points or `null`.
- `outcome`: `completed`, `retired`, `walkover`, `default`, or `null` when there is no score.

| Raw `level` code | `levelName` |
|---|---|
| `G` | Grand Slam |
| `M`, `PM` | Masters 1000 |
| `A`, `P`, `I` | Tour 250/500 |
| `F` | Tour Finals |
| `C` | Challenger |
| `S`, any number (ITF prize money in $ thousands) | ITF |
| `D`, `O` | Team & Olympics |
| anything else (for example WTA `W`) | Other |

Example input:

```json
{
  "mode": "players",
  "players": ["Jannik Sinner", "Iga Swiatek"],
  "dateFrom": "2026-01-01",
  "surfaces": ["Grass"],
  "maxMatchesPerPlayer": 100
}
```

### Output examples

Real rows from a live run.

Match row (shortened):

```json
{
  "type": "match",
  "player": "Jannik Sinner",
  "playerSlug": "JannikSinner",
  "tour": "atp",
  "date": "2026-06-29",
  "tournament": "Wimbledon",
  "surface": "Grass",
  "level": "G",
  "levelName": "Grand Slam",
  "round": "F",
  "result": "W",
  "score": "6-7(7) 7-6(2) 6-3 6-4",
  "sets": [{"winnerGames": 6, "loserGames": 7, "tiebreak": 7}, {"winnerGames": 7, "loserGames": 6, "tiebreak": 2}, {"winnerGames": 6, "loserGames": 3, "tiebreak": null}, {"winnerGames": 6, "loserGames": 4, "tiebreak": null}],
  "outcome": "completed",
  "bestOf": 5,
  "rank": 1,
  "seed": 1,
  "opponent": "Alexander Zverev",
  "opponentRank": 3,
  "opponentHand": "R",
  "opponentCountry": "GER",
  "minutes": 226,
  "aces": 15,
  "doubleFaults": 2,
  "servePoints": 136,
  "firstServesIn": 87,
  "firstServeWon": 70,
  "secondServeWon": 32,
  "acePct": 11,
  "doubleFaultPct": 1.5,
  "firstServeInPct": 64,
  "firstServeWonPct": 80.5,
  "secondServeWonPct": 65.3,
  "servicePointsWonPct": 75,
  "returnPointsWonPct": 30.9,
  "dominanceRatio": 1.24,
  "playerUrl": "https://www.tennisabstract.com/cgi-bin/player-classic.cgi?p=JannikSinner",
  "scrapedAt": "2026-10-13T05:46:44Z"
}
```

Player row:

```json
{
  "type": "player",
  "name": "Carlos Alcaraz",
  "slug": "CarlosAlcaraz",
  "tour": "atp",
  "url": "https://www.tennisabstract.com/cgi-bin/player.cgi?p=CarlosAlcaraz",
  "currentRank": 3,
  "peakRank": 1,
  "peakRankDate": "2022-09-12",
  "birthDate": "2003-05-05",
  "age": 23.4,
  "heightCm": 183,
  "hand": "R",
  "backhand": "two-handed",
  "country": "ESP",
  "careerMatches": 477,
  "careerWins": 387,
  "careerLosses": 90,
  "titles": 35,
  "eloRank": 2,
  "elo": 2171.2,
  "hardElo": 2111.3,
  "clayElo": 2098.8,
  "grassElo": 2026.4,
  "scrapedAt": "2026-10-13T05:47:22Z"
}
```

Season row:

```json
{
  "type": "season",
  "table": "tour-years",
  "label": "2026",
  "matches": 47,
  "wins": 44,
  "losses": 3,
  "winPct": 93.6,
  "holdPct": 94.4,
  "breakPct": 29.1,
  "acePct": 14.2,
  "doubleFaultPct": 2.1,
  "firstServeInPct": 64.8,
  "firstServeWonPct": 81.4,
  "secondServeWonPct": 59.1,
  "servicePointsWonPct": 73.6,
  "returnPointsWonPct": 40.7,
  "totalPointsWonPct": 56.2,
  "dominanceRatio": 1.54,
  "player": "Jannik Sinner",
  "slug": "JannikSinner",
  "tour": "atp"
}
```

Elo row:

```json
{
  "type": "elo",
  "tour": "atp",
  "eloRank": 1,
  "name": "Jannik Sinner",
  "slug": "JannikSinner",
  "age": 24.8,
  "elo": 2296.9,
  "hardEloRank": 1,
  "hardElo": 2234.3,
  "clayEloRank": 1,
  "clayElo": 2186.8,
  "grassEloRank": 1,
  "grassElo": 2100.5,
  "peakElo": 2339.8,
  "peakMonth": "2026-05",
  "tourRank": 1,
  "updatedAt": "2026-10-05"
}
```

### Pricing events

Rows are charged per type: `match`, `player`, `season` and `elo-row`. Runs stop cleanly when your spending limit is reached.

### FAQ

**Where does the data come from?**
Directly from Tennis Abstract's public pages and data files. Tennis Abstract refreshes its data roughly weekly, so expect "weekly-ish" updates; very recent matches may show up with a delay.

**How are player names matched?**
A URL with `p=` always wins. A single token without spaces is treated as the slug. Otherwise the name is matched (accents, case and punctuation ignored) against the ATP and WTA Elo tables, which are downloaded once per run. If there is no match, the capitalized words are joined into a slug (`jannik sinner` becomes `JannikSinner`). Players that are not found are logged and counted as `notFound`; the run still succeeds.

**What do Elo, hElo, cElo and gElo mean?**
Elo is the overall rating. hElo, cElo and gElo are the surface-specific ratings for hard, clay and grass courts (output fields `hardElo`, `clayElo`, `grassElo`).

**Why are some statistics empty?**
Detailed serve data exists only for matches Tennis Abstract has charted or received stats for; other rows keep `null` in those fields.

**Missing something? Open an issue - we add fields on request.**

# Actor input Schema

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

players: match history, profile and season stats of the players below. elo: the ATP and WTA Elo ratings tables.

## `players` (type: `array`):

Full names (Jannik Sinner, Iga Świątek), slugs (JannikSinner) or any tennisabstract.com URL with p=<Slug>. ATP and WTA can be mixed.

## `tour` (type: `string`):

auto tries ATP first, then WTA. Choose a tour to search only there.

## `dateFrom` (type: `string`):

First match date to keep, YYYY-MM-DD, inclusive.

## `dateTo` (type: `string`):

Last match date to keep, YYYY-MM-DD, inclusive.

## `surfaces` (type: `array`):

Keep only these surfaces. Empty: all.

## `levels` (type: `array`):

Keep only matches of these tournament levels (Grand Slam, Masters 1000, Tour 250/500, Tour Finals, Challenger, ITF, Team & Olympics, Other). Empty: all levels.

## `rounds` (type: `array`):

Keep only matches of these rounds (R128 to F, RR round robin, BR bronze match, Q1 to Q3 qualifying). Empty: all rounds.

## `opponentRankMin` (type: `integer`):

Keep only matches where the opponent's ranking at the time is at least this number (for example 11 skips the top 10). Matches with unknown opponent rank are dropped when set. 0 turns the filter off.

## `opponentRankMax` (type: `integer`):

Keep only matches where the opponent's ranking at the time is this number or better (for example 10 keeps only top-10 opponents). Matches with unknown opponent rank are dropped when set. 0 turns the filter off.

## `results` (type: `string`):

Keep all matches, only wins or only losses.

## `includeUpcoming` (type: `boolean`):

Also return scheduled matches (result U, no score). Only with results = all.

## `opponent` (type: `string`):

Keep matches against opponents whose name contains this text. Case and accents are ignored.

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

Newest first, after the filters. 0 returns no match rows (profile and seasons only). 0 to 5000.

## `includeProfile` (type: `boolean`):

One player row per player: ranks, birth date, hand, titles and Elo when known.

## `includeSeasonStats` (type: `boolean`):

Rows for every season and for career and last-52-weeks splits (one extra request per player).

## `eloTours` (type: `array`):

Mode elo: which Elo tables to return. Empty: both.

## `maxResults` (type: `integer`):

Mode elo: top rows per tour.

## `onlyNew` (type: `boolean`):

Mode players: skip matches returned by earlier runs with the same players and tour.

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

Apify datacenter proxy is enough. Every attempt uses a new proxy session.

## Actor input object example

```json
{
  "mode": "players",
  "players": [
    "Jannik Sinner"
  ],
  "tour": "auto",
  "surfaces": [],
  "levels": [],
  "rounds": [],
  "opponentRankMin": 0,
  "opponentRankMax": 0,
  "results": "all",
  "includeUpcoming": false,
  "maxMatchesPerPlayer": 50,
  "includeProfile": true,
  "includeSeasonStats": false,
  "eloTours": [],
  "maxResults": 1000,
  "onlyNew": false,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `results` (type: `string`):

Tennis Abstract rows: ATP/WTA matches with serve and return stats, player profiles, season tables and Elo ratings. The type field tells which.

# 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 = {
    "players": [
        "Jannik Sinner"
    ],
    "maxMatchesPerPlayer": 50,
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("deepurple/tennis-abstract-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 = {
    "players": ["Jannik Sinner"],
    "maxMatchesPerPlayer": 50,
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("deepurple/tennis-abstract-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 '{
  "players": [
    "Jannik Sinner"
  ],
  "maxMatchesPerPlayer": 50,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call deepurple/tennis-abstract-scraper --silent --output-dataset

```

## MCP server setup

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