# Tennis Abstract Match History Scraper - Stats, H2H, New Matches (`neverempty/tennis-abstract-match-history`) Actor

For tennis bettors, modelers and analysts: every ATP singles match on a player's Tennis Abstract page - score, round, surface, opponent, ranks, aces, serve points, break points. Djokovic: 1,516 matches. Filter by date, surface, level or opponent; get head-to-heads and only new matches.

- **URL**: https://apify.com/neverempty/tennis-abstract-match-history.md
- **Developed by:** [NeverEmpty](https://apify.com/neverempty) (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 $12.00 / 1,000 match or head-to-head returneds

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 Abstract Match History Scraper - Stats, H2H, New Matches

For tennis bettors, modelers and analysts: **every ATP singles match on a player's Tennis Abstract page, one row per match** - tournament, level, surface, round, result, score, opponent with rank, seed, hand, height and country, plus **aces, double faults, serve points, first serves in, first- and second-serve points won, service games and break points for both players**. On 21 Sep 2026 Tennis Abstract listed **1,516 matches for Novak Djokovic (2003-2026)** and 565 for Jannik Sinner (506 of them with serve stats). Pull several players in one run by name, filter by date, surface, level, opponent or result, get **head-to-head records**, or schedule it to return **only the matches added since the last run** - no copying tables by hand and no guessing player ids.

Export as JSON, CSV or Excel.

The data comes from [Tennis Abstract](https://www.tennisabstract.com/)'s public player pages (`/cgi-bin/player-classic.cgi?p=<id>`) and its public player list (`/mwplayerlist.js`). This Actor is unofficial and not affiliated with Tennis Abstract or the ATP. For Elo ratings and win probabilities from the same site, see [Tennis Elo Ratings Scraper](https://apify.com/neverempty/tennis-elo-ratings-scraper).

### Why this Actor

- **Many players in one run, typed the way you write them.** `Jannik Sinner`, `JANNIK SINNER`, `novak djokovič`, the Tennis Abstract id `JannikSinner` or the player page URL all work. The name is looked up in Tennis Abstract's own list of 79,580 players, so a name that fits several men is never guessed: `Sinner` returns a free row listing Jannik Sinner and Martin Sinner.
- **The right player, checked.** Tennis Abstract answers an unknown id with another player's page (on 21 Sep 2026 `?p=IgaSwiatek` returned Benoit Paire's page). Every page's own name is compared with the player you asked for before any row is returned.
- **Filters:** tournament start date range, surfaces, tournament levels (Grand Slam, Masters 1000, Tour Finals, Olympics, Davis Cup, other tour-level, Challenger, ITF Futures, qualifying, juniors), opponents and won/lost.
- **Head-to-head.** `Jannik Sinner vs Carlos Alcaraz` returns one summary row (meetings, wins for each player, wins by surface, first and last meeting) and every meeting as a match row. Like Tennis Abstract's own list, meetings and wins include walkovers; the `walkovers` column says how many there were (the official ATP head-to-head leaves them out).
- **Only new matches.** Turn on `monitoringMode` and schedule the Actor: each run returns only the matches Tennis Abstract added since the player was last checked with the same filters.
- **Honest failures.** Unknown names, ambiguous names, unreadable pages, "no new match" and a run that hits your spending limit each come back as a free row that says what happened. An unreadable page is never reported as "0 matches".

### Input

| Field | Type | Default | What it does |
|---|---|---|---|
| `players` | list | (prefilled: Jannik Sinner, Carlos Alcaraz) | ATP players: full name, Tennis Abstract id or player page URL. |
| `headToHead` | list | (prefilled: Jannik Sinner vs Carlos Alcaraz) | `Player A vs Player B`, one per line. |
| `dateFrom`, `dateTo` | text | (none) | `YYYY-MM-DD`. Compared with the tournament start date. |
| `surfaces` | select | all | `hard`, `clay`, `grass`, `carpet`. |
| `levels` | select | all | `grand-slam`, `masters`, `tour-finals`, `olympics`, `davis-cup`, `other-tour-level`, `challenger`, `futures`, `qualifying`, `juniors`. |
| `opponents` | list | (none) | Only matches against these opponents (all words of a line must be in the opponent's name). |
| `results` | select | both | `won` and/or `lost`. |
| `maxMatchesPerPlayer` | integer | 100 (prefilled: 20) | Newest matching matches per player. `0` = whole career. |
| `monitoringMode` | boolean | false | Return only matches added since the last check (see below). |
| `resetMonitoringState` | boolean | false | Forget what was remembered for this set of filters. |

With no players and no head-to-head, the run returns a 10-match sample of Jannik Sinner.

Examples:

```json
{ "players": ["Jannik Sinner", "Carlos Alcaraz", "Alexander Zverev"], "dateFrom": "2025-01-01", "surfaces": ["clay"], "maxMatchesPerPlayer": 0 }
```

```json
{ "players": ["Novak Djokovic"], "levels": ["grand-slam"], "opponents": ["Rafael Nadal", "Roger Federer"], "maxMatchesPerPlayer": 0 }
```

```json
{ "headToHead": ["Jannik Sinner vs Carlos Alcaraz", "Novak Djokovic vs Rafael Nadal"], "surfaces": ["hard"] }
```

```json
{ "players": ["Jannik Sinner", "Carlos Alcaraz"], "monitoringMode": true, "maxMatchesPerPlayer": 5 }
```

### Output

Match row (Jannik Sinner, Wimbledon 2026 final):

```json
{
  "source": "tennisabstract.com",
  "status": "ok",
  "rowType": "match",
  "player": "Jannik Sinner",
  "playerId": "JannikSinner",
  "requestedAs": "Jannik Sinner",
  "tournamentStartDate": "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",
  "outcome": "completed",
  "bestOf": 5,
  "playerRank": 1,
  "playerSeed": 1,
  "playerEntry": null,
  "opponent": "Alexander Zverev",
  "opponentId": "AlexanderZverev",
  "opponentRank": 3,
  "opponentSeed": 2,
  "opponentEntry": null,
  "opponentHand": "right",
  "opponentBackhand": "two-handed",
  "opponentBirthDate": "1997-04-20",
  "opponentHeightCm": 198,
  "opponentCountry": "GER",
  "minutes": 226,
  "statsAvailable": true,
  "aces": 15,
  "doubleFaults": 2,
  "servePoints": 136,
  "firstServesIn": 87,
  "firstServePointsWon": 70,
  "secondServePointsWon": 32,
  "serviceGames": 22,
  "breakPointsSaved": 1,
  "breakPointsFaced": 1,
  "opponentAces": 17,
  "opponentDoubleFaults": 2,
  "opponentServePoints": 139,
  "opponentFirstServesIn": 105,
  "opponentFirstServePointsWon": 76,
  "opponentSecondServePointsWon": 20,
  "opponentServiceGames": 21,
  "opponentBreakPointsSaved": 3,
  "opponentBreakPointsFaced": 5,
  "matchId": "2026-540-603",
  "matchChartingUrl": "https://www.tennisabstract.com/charting/20260712-M-Wimbledon-F-Jannik_Sinner-Alexander_Zverev.html",
  "playerUrl": "https://www.tennisabstract.com/cgi-bin/player-classic.cgi?p=JannikSinner"
}
```

Head-to-head row (21 Sep 2026):

```json
{
  "source": "tennisabstract.com",
  "status": "ok",
  "rowType": "head-to-head",
  "headToHead": "Jannik Sinner vs Carlos Alcaraz",
  "player1": "Jannik Sinner",
  "player1Id": "JannikSinner",
  "player2": "Carlos Alcaraz",
  "player2Id": "CarlosAlcaraz",
  "meetings": 18,
  "player1Wins": 7,
  "player2Wins": 11,
  "walkovers": 0,
  "bySurface": { "hard": { "player1Wins": 3, "player2Wins": 7 }, "clay": { "player1Wins": 2, "player2Wins": 4 }, "grass": { "player1Wins": 2, "player2Wins": 0 } },
  "lastMeetingTournamentStartDate": "2026-04-05",
  "lastMeetingTournament": "Monte Carlo Masters",
  "lastMeetingRound": "F",
  "lastMeetingWinner": "Jannik Sinner",
  "lastMeetingScore": "7-6(5) 6-3",
  "firstMeetingTournamentStartDate": "2019-04-01",
  "playerUrl": "https://www.tennisabstract.com/cgi-bin/player-classic.cgi?p=JannikSinner"
}
```

Notes on the columns:

- `tournamentStartDate` is the start date of the tournament (Tennis Abstract dates every round of Wimbledon 2026 as 2026-06-29), not the day of the match.
- `score` is written from the winner's side, as Tennis Abstract shows it by default, also when `result` is `L`. `outcome` is `completed`, `retired`, `walkover`, `defaulted` or `abandoned`, read from the score.
- The serve columns mean exactly what Tennis Abstract's own table computes from them: ace % = `aces / servePoints`, 1st in % = `firstServesIn / servePoints`, 1st won % = `firstServePointsWon / firstServesIn`, 2nd won % = `secondServePointsWon / (servePoints - firstServesIn)`, break points saved = `breakPointsSaved / breakPointsFaced`. On 21 Sep 2026 Novak Djokovic's page was compared in a browser with Tennis Abstract's own tables: the first 8 matches on the serve table and the first 5 on the return and raw-count tables matched on every value. `serviceGames` is not shown in those tables; in 3,527 of 3,588 matches checked (four players), both players' service games add up to the games in the score minus tiebreaks.
- Matches without serve statistics (mostly older and lower-level matches, and walkovers) have `statsAvailable: false` and `null` in the serve columns. Nothing is filled with 0.
- `level` is Tennis Abstract's code; `levelName` spells it out. `A` ("Other tour-level") is Tennis Abstract's catch-all for ATP 250/500 events, United Cup and Laver Cup, and in some years it also holds the Tour Finals or the Olympics.
- `playerEntry` / `opponentEntry`: `Q` qualifier, `WC` wild card, `LL` lucky loser, `PR` protected ranking and other codes as Tennis Abstract writes them.
- `matchId` is Tennis Abstract's match id (tournament id and match number) and is how monitoring recognises a match it has already returned.
- Matches that are listed without a score (not yet played) are not returned.

**Not included:** women's players (WTA). Tennis Abstract keeps women's match histories in files under `/jsmatches/`, which its robots.txt asks crawlers not to read, and this Actor respects that (together with `/jsfrags/` and `/jsplayers/`). The men's pages checked on 21 Sep 2026 (18 players, including Djokovic, Federer, Nadal, Connors, Agassi and Lendl) carried the whole career; if a page ever flags that part of a career is kept in such a file, a free `partial-career` row says how many matches were read. Doubles are not included.

#### Monitoring (only new matches)

With `monitoringMode` on, the Actor remembers, per player and per set of filters, the matches it has seen:

- **First check of a player:** returns the newest `maxMatchesPerPlayer` matching matches and records all the others as known (a free row says how many). Set `maxMatchesPerPlayer` to `0` to get the whole career on the first check.
- **Later checks:** returns every matching match Tennis Abstract has added since, without the `maxMatchesPerPlayer` limit. If there is none, a free `no-new-matches` row says so.
- Matches that were not returned because the run reached your maximum charge are not remembered, so they come back as new on the next check.
- Head-to-head lines are not watched; put the first player in `players` and the second in `opponents` instead.
- Do not put the same players and filters in two schedules that run at the same time. The memory is saved by reading it again just before writing and merging, but Apify's key-value store has no atomic update, so overlapping runs can still overwrite each other.

#### Free note rows

These rows have `rowType: "note"`, a `status` and a plain-English `note`, and are never charged:

| `status` | Meaning |
|---|---|
| `ambiguous-player` | The name fits several men; the row lists them. Use the full name. |
| `player-not-found` | The name is not on Tennis Abstract's player list, or Tennis Abstract has no page for it. |
| `wta-not-available` | The name is on Tennis Abstract's women's list (see "Not included"). |
| `name-mismatch` | Tennis Abstract answered with a different player's page; nothing from it is returned. |
| `duplicate` | The same player, or the same two players (in either order), was asked for twice; returned once. |
| `no-matching-matches` | The player's page was read, and no match fits your filters (or the two players never met). |
| `no-new-matches` | Monitoring: nothing new since the last check. |
| `not-returned` | Older matching matches left out by `maxMatchesPerPlayer` (the row gives the number). |
| `partial-career` | Part of the player's career is in a file robots.txt excludes; only the page was read. |
| `unreadable` | A page could not be read after waiting and retrying. Nothing from it is guessed. |
| `budget-reached` | The run reached the maximum total charge you set; players after it were not read. |
| `invalid-input` / `invalid-head-to-head` | The input or a head-to-head line could not be used. |

### Speed and politeness

Tennis Abstract is an independent site, so this Actor reads one page at a time, waits at least 3 seconds between pages, follows the site's `Retry-After` when it answers HTTP 429 (too many requests) and then waits one second longer between pages (up to 8 seconds). It identifies itself with its own User-Agent and connects through Apify Proxy (datacenter), because plain cloud addresses get a browser check page; the check page itself is never solved or bypassed, and if it keeps coming back the player gets a free `unreadable` row. Each run reads the player list once and one page per player. Measured on 21 Sep 2026: one whole career (Novak Djokovic, 1,516 rows) took 11 seconds, two players with 20 matches each plus a head-to-head took 15 seconds, and 12 whole careers (11,290 rows) took 4 minutes. The default run timeout is 1 hour; if a run hits it, the rows returned so far are kept and only they are charged.

### Pricing

Pay per result: each match row and each head-to-head row is one result. Note rows are free. In monitoring mode each player checked also costs a small check fee, whether or not he has new matches. If a run reaches the maximum total charge you set, it stops before reading the next player and says so in a free row.

### FAQ

**Which players are covered?** Men (ATP), from the pages Tennis Abstract keeps for them, including Challenger, ITF Futures, qualifying and Davis Cup matches where Tennis Abstract has them.

**How fresh is the data?** As fresh as Tennis Abstract's pages, which are updated as tournaments progress. Use monitoring on a schedule to get new matches as they appear.

**Why does a name return a list instead of matches?** Because several men share it on Tennis Abstract. Use the full name or the Tennis Abstract id.

**Is this official?** No. It reads Tennis Abstract's public pages; it is not affiliated with or endorsed by Tennis Abstract or the ATP.

# Actor input Schema

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

ATP players whose match history you want, one per line: full name (Jannik Sinner), Tennis Abstract id (JannikSinner) or player page URL. Accents and case do not matter (novak djokovič finds Novak Djokovic). A name that fits several men (Sinner fits Jannik and Martin Sinner) or none comes back as a free row that lists the candidates or says so. Women are not available: Tennis Abstract keeps women's match histories in files its robots.txt asks crawlers not to read. Leave empty (and head-to-head empty) for a 10-match sample of Jannik Sinner.

## `headToHead` (type: `array`):

Pairs of players, one per line, written as "Player A vs Player B". Each line returns one head-to-head row (meetings, wins for each player, wins by surface, last meeting) followed by every meeting as a match row, seen from Player A. As on Tennis Abstract, meetings and wins include walkovers (counted in the walkovers column). Date, surface and level filters apply; opponents and results filters do not.

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

Only matches from tournaments that started on or after this date (YYYY-MM-DD). Tennis Abstract dates every match with its tournament's start date.

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

Only matches from tournaments that started on or before this date (YYYY-MM-DD).

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

Only matches on these surfaces. Leave empty for all.

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

Only matches at these tournament levels, as Tennis Abstract codes them. 'Other tour-level' is Tennis Abstract's catch-all for ATP 250/500 events, United Cup and Laver Cup, and in some years it also holds the Tour Finals or the Olympics. Leave empty for all.

## `opponents` (type: `array`):

Only matches against these opponents, one per line. A line matches an opponent whose name contains all its words, ignoring accents and case (Zverev matches Alexander and Mischa Zverev).

## `results` (type: `array`):

Only won or only lost matches. Leave empty for both.

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

Newest matching matches returned per player. 0 returns the whole career (Novak Djokovic: 1,516 matches on 21 Sep 2026). Leave empty for 100. In monitoring mode it only limits the first check of a player; later checks return every new match.

## `monitoringMode` (type: `boolean`):

Remember the matches already seen for each player (per set of filters) and return only matches Tennis Abstract adds later. The first check of a player returns the newest matches (see 'Matches per player') and records the rest as known. Every player checked costs a small check fee, with or without new matches. Do not put the same players and filters in two schedules that run at the same time: the memory has no atomic update, so overlapping runs can overwrite each other.

## `resetMonitoringState` (type: `boolean`):

Start monitoring from scratch for this set of filters: the next check of each player is treated as a first check.

## Actor input object example

```json
{
  "players": [
    "Jannik Sinner",
    "Carlos Alcaraz"
  ],
  "headToHead": [
    "Jannik Sinner vs Carlos Alcaraz"
  ],
  "maxMatchesPerPlayer": 20,
  "monitoringMode": false,
  "resetMonitoringState": false
}
```

# Actor output Schema

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

One row per ATP singles match (date, tournament, surface, level, round, result, score, opponent, ranks, serve and return counts), and one head-to-head row per requested pair. Names not found, unreadable pages, limits and 'no new match' come back as free rows that say why.

# 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",
        "Carlos Alcaraz"
    ],
    "headToHead": [
        "Jannik Sinner vs Carlos Alcaraz"
    ],
    "maxMatchesPerPlayer": 20
};

// Run the Actor and wait for it to finish
const run = await client.actor("neverempty/tennis-abstract-match-history").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",
        "Carlos Alcaraz",
    ],
    "headToHead": ["Jannik Sinner vs Carlos Alcaraz"],
    "maxMatchesPerPlayer": 20,
}

# Run the Actor and wait for it to finish
run = client.actor("neverempty/tennis-abstract-match-history").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",
    "Carlos Alcaraz"
  ],
  "headToHead": [
    "Jannik Sinner vs Carlos Alcaraz"
  ],
  "maxMatchesPerPlayer": 20
}' |
apify call neverempty/tennis-abstract-match-history --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,neverempty/tennis-abstract-match-history"
        }
    }
}
```

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/Fe1mC2zlHgrQPwXIh/builds/t7yfIeO3SoDjHc59r/openapi.json
