# Tennis Abstract Data API (`danthedataman/tennis-abstract-data-api`) Actor

Export ATP and WTA player match histories from Tennis Abstract, including scores, opponents, surfaces, and available serve and return statistics. Filter by season, surface or head-to-head opponent; export JSON, CSV or Excel.

- **URL**: https://apify.com/danthedataman/tennis-abstract-data-api.md
- **Developed by:** [Eli J](https://apify.com/danthedataman) (community)
- **Categories:** Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.85 / 1,000 results

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?

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

## Tennis Abstract Data API

Pull a tennis player's **whole career match log** off tennisabstract.com as flat
typed rows: date, tournament, surface, level, round, score, opponent, and the
raw per-match serve and return counts the site stores. ATP and WTA, many
players in one run, with season, surface, level, round, tournament and
head-to-head filters applied to rows already fetched rather than re-downloaded.

One measured example: Novak Djokovic's page is **1,516 matches** spanning
2003-01-06 to 2026-08-31, and the Actor takes all of them in a single request.
Export to JSON, CSV or Excel, or read them straight off the API.

A run started with no players fetches nothing, writes no result rows and
incurs no result-row charge, and exits cleanly; the automatic start event is
charged once per run, as Pricing says.

### What you get

One flat row per match (`recordType: "match"`). Every count is the raw number
the source stores, never a rate this Actor computed; every absent value a row
carries is `null`, never `0`. An ATP row is 51 columns wide and a WTA row 48:
the three ATP-only columns are absent from a WTA row rather than `null`.

| Column | Type | Unit / form |
| --- | --- | --- |
| `recordType` | string | Literal `match` on a match row |
| `tour` | string | `ATP` or `WTA` |
| `player` | string | null | Display name as the source page publishes it |
| `playerSlug` | string | The slug this run fetched |
| `date` | string | Tournament start date, `YYYY-MM-DD` — the source dates every row of an event alike, so the per-match date is in `chartSlug` where the match is charted |
| `tournament` | string | Tournament name as printed; current-season rows carry an `ATP ` prefix |
| `surface` | string | `Hard`, `Clay`, `Grass` or `Carpet` — the only four values in a full career capture |
| `level` | string | The site's own raw level code, not glossed |
| `round` | string | The site's own raw round code, not glossed |
| `result` | string | `W` or `L`, from the player's side |
| `score` | string | Score as printed, e.g. `7-6(5) 5-7 4-6 6-2 6-1` |
| `bestOfSets` | integer | null | Sets (`3` or `5`) |
| `minutes` | integer | null | Match duration in minutes |
| `playerRank` / `playerSeed` / `playerEntry` | integer | integer | string, nullable | The player's ranking, seed and entry code at the match, as the site writes them |
| `opponent` | string | Opponent display name |
| `opponentPlayerId` | string | null | The site's own numeric player id for the opponent (ATP rows only; the key is absent from a WTA row) |
| `opponentRank` / `opponentSeed` / `opponentEntry` | integer | integer | string, nullable | The same three for the opponent |
| `opponentHand` | string | null | `R` or `L` |
| `opponentBackhand` | string | null | The site's own backhand code, not glossed by the page |
| `opponentBirthDate` | string | null | Date, `YYYY-MM-DD` |
| `opponentHeightCm` | integer | null | Centimetres (163–211 across a full career capture) |
| `opponentCountry` | string | null | Three-letter country code (`SRB`, `ITA`, `ARG`) |
| `aces`, `doubleFaults`, `servePoints`, `firstServesIn`, `firstServePointsWon`, `secondServePointsWon`, `serviceGames`, `breakPointsSaved`, `breakPointsFaced` | integer | null | The player's own serve counts. Raw counts, never rates; the whole block is `null` together when the source recorded none |
| `opponentAces`, `opponentDoubleFaults`, `opponentServePoints`, `opponentFirstServesIn`, `opponentFirstServePointsWon`, `opponentSecondServePointsWon`, `opponentServiceGames`, `opponentBreakPointsSaved`, `opponentBreakPointsFaced` | integer | null | The same nine counts from the opponent's serve — i.e. the player's return side |
| `matchId` | string | The site's own id, `<year>-<eventId>-<matchNum>` |
| `roundNumber` / `matchNumber` | integer | null | The site's round and match indexes (ATP rows only; the keys are absent from a WTA row) |
| `chartSlug` | string | null | Point-by-point charting slug, where the match is charted |
| `chartUrl` | string | null | The charting page URL built from that slug |
| `sourceUrl` | string | The exact URL this row was read from |
| `scrapedAt` | string | ISO-8601 UTC, to the second |

Turn on `includePlayerRecord` and each successfully read player also gets one
bio row (`recordType: "player"`). The source publishes the nine bio fields on
the ATP career page and on the WTA shortlist file only, so a WTA target whose
history came from the career file alone still gets its row, with those nine
fields `null` and an `identity-unchecked` note on the target — nothing is
filled in from another page:

| Column | Type | Unit / form |
| --- | --- | --- |
| `recordType` | string | Literal `player` on a bio row |
| `tour` | string | `ATP` or `WTA` |
| `player` | string | null | Display name |
| `playerSlug` | string | The slug this run fetched |
| `currentRank` | integer | null | Current ranking the source publishes |
| `peakRank` | integer | null | Career-high ranking the source publishes |
| `birthDate` | string | null | Date, `YYYY-MM-DD` |
| `hand` | string | null | `R` or `L` |
| `backhand` | string | null | The site's own backhand code, not glossed |
| `country` | string | null | Three-letter country code |
| `active` | integer | null | `1` active, `0` retired, as the source writes it |
| `twitter` | string | null | Handle, without the `@` |
| `matchCount` | integer | null | The rows the source served for that player, before your filters and cap — the **published career total** when every history surface answered, not a count of the rows this run delivered |
| `sourceUrl` | string | The exact URL the bio was read from |
| `scrapedAt` | string | ISO-8601 UTC, to the second |

### Why this Actor

- **A whole career in one request, not a recent-form slice.** The site's own
  recent-results fragment carries 22 rows; the career page this Actor reads
  carries 1,516 for the same player, in a single HTTPS GET.
- **WTA as well as ATP.** A WTA career is assembled the way the site's own page
  assembles it — the shortlist file plus the older-history file — which for Iga
  Swiatek is 130 + 418 = **548 rows** of full history.
- **Many players per run.** Pass a list of slugs, paste player-page URLs, or use
  a discovery token: `@elo-top:10` takes the top ten slugs from the site's own
  Elo report (551 ranked players on the measured capture), `@leaders:1-50` and
  `@wta-leaders:1-50` take the site's leader lists.
- **Head-to-head is a filter, not a second download.** The site's own
  head-to-head page returns a byte-identical match array to the plain career
  page, so this Actor filters rows it already has: Djokovic vs Nadal is 60 rows
  out of the 1,516 already fetched, at no extra request.
- **The page's own column names drive the parse.** Rows are mapped by the
  header array the page publishes, never by hard-coded positions, so a column
  the site inserts cannot silently shift your data one field to the left.
- **Identity is checked, not assumed.** The source answers `200` for a slug it
  does not have and serves a *different* player's page — measured: requesting
  `IgaSwiatek` from the ATP path returned Benoit Paire's complete page. Every
  page is compared against the name it publishes for itself, and a mismatch is
  reported instead of billed.
- **Failures never become rows.** A page that cannot be read goes to the run's
  `ERRORS` record, not to your dataset, so a failure can never appear on your
  bill as a result.
- **An empty result is an answer.** No players, filters that match nothing, or
  an input value the Actor cannot act on: the run finishes cleanly with
  guidance in the log instead of a red run and a stack trace.

### Input

Nothing is required, and nothing is assumed: with no `players` the run fetches
nothing, writes no result rows and incurs no result-row charge rather than
picking a player for you; the automatic start event is charged once per run, as
Pricing says.

```json
{
  "players": ["NovakDjokovic", "IgaSwiatek", "@elo-top:10"],
  "tour": "auto",
  "span": "2026",
  "surface": "Hard",
  "result": "W",
  "maxMatchesPerPlayer": 100,
  "maxPlayers": 50,
  "includePlayerRecord": true
}
```

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `players` | array | None; nothing is fetched until you set it | Player slugs (`NovakDjokovic`), pasted player-page URLs in either `p=` form, or discovery tokens (`@elo-top:10`, `@leaders:1-50`, `@wta-leaders:1-50`). |
| `tour` | string | `auto` | Which surface family to read a player from. `auto` tries ATP first, then WTA, and accepts the first page whose own published name matches the slug. |
| `span` | string | All matches | Season filter, in the site's own vocabulary: `Career`, `Last 52`, or a four-digit season. `Last 52` counts back from the run clock for an active player and from the player's newest match otherwise, which is the page's own rule. It matches on the row's `date`, which is the tournament's start date, so a season is a tournament-start season. |
| `surface` | string | All surfaces | `Hard`, `Clay`, `Grass` or `Carpet`. |
| `level` | string | All levels | The site's own raw level code, matched exactly. Suggestions are open, not enforced. |
| `round` | string | All rounds | The site's own raw round code, matched exactly. Suggestions are open, not enforced. |
| `tourney` | string | All tournaments | Case-insensitive substring of the tournament name, so `US Open` keeps both `US Open` and `ATP US Open`. |
| `result` | string | Wins and losses | `W` or `L`, the row's raw result value. |
| `opponent` | string | All opponents | Head-to-head filter over rows already fetched, written either as a slug or as a display name. |
| `maxMatchesPerPlayer` | integer | `100` | Matches kept per player, newest first, after the filters. 0 means every match. |
| `maxPlayers` | integer | `50` | Players fetched per run, applied after the entries are resolved. Maximum 200; a higher value is clamped to 200 with a warning in the log rather than refused. |
| `includePlayerRecord` | boolean | `false` | Add one player record row per successfully read player. |
| `proxyConfiguration` | object | No proxy | Optional Apify Proxy settings. No group is set by default; the datacenter group (`BUYPROXIES94952`) is the first thing to try if a run is ever blocked. |

The platform prefill is `{"players": ["NovakDjokovic"]}`; read **Pricing**
below for the confirmed result-row charge and the automatic start event before
you run. The input this Actor's acceptance and platform runs use is checked in
at `tests/input-prefill.json`, and a unit test asserts it equals the input
schema's own `players` prefill.

#### Filters the Actor does not expose

The source page offers a wider filter vocabulary than the eight fields above —
minimum-matches and minimum-years thresholds, sets, scores, entry and ranking
bands, hand, age, height, country. They are aggregates or menu state rather
than per-row predicates, so they are not shipped; ask for one on the Issues tab
and it can be added as a row filter.

### Output

One dataset item per match, in the order the source publishes them (newest
first):

```json
{
  "recordType": "match",
  "tour": "ATP",
  "player": "Novak Djokovic",
  "playerSlug": "NovakDjokovic",
  "date": "2026-06-29",
  "tournament": "Wimbledon",
  "surface": "Grass",
  "level": "G",
  "round": "SF",
  "result": "L",
  "score": "6-4 6-4 6-4",
  "bestOfSets": 5,
  "minutes": 140,
  "playerRank": 8,
  "playerSeed": 7,
  "playerEntry": null,
  "opponent": "Jannik Sinner",
  "opponentPlayerId": "206173",
  "opponentRank": 1,
  "opponentSeed": 1,
  "opponentEntry": null,
  "opponentHand": "R",
  "opponentBackhand": "2",
  "opponentBirthDate": "2001-08-16",
  "opponentHeightCm": 191,
  "opponentCountry": "ITA",
  "aces": 8,
  "doubleFaults": 3,
  "servePoints": 105,
  "firstServesIn": 67,
  "firstServePointsWon": 51,
  "secondServePointsWon": 13,
  "serviceGames": 15,
  "breakPointsSaved": 10,
  "breakPointsFaced": 13,
  "opponentAces": 16,
  "opponentDoubleFaults": 0,
  "opponentServePoints": 79,
  "opponentFirstServesIn": 51,
  "opponentFirstServePointsWon": 45,
  "opponentSecondServePointsWon": 17,
  "opponentServiceGames": 15,
  "opponentBreakPointsSaved": 1,
  "opponentBreakPointsFaced": 1,
  "matchId": "2026-540-601",
  "roundNumber": null,
  "matchNumber": null,
  "chartSlug": "20260710-M-Wimbledon-SF-Novak_Djokovic-Jannik_Sinner",
  "chartUrl": "https://www.tennisabstract.com/charting/20260710-M-Wimbledon-SF-Novak_Djokovic-Jannik_Sinner.html",
  "sourceUrl": "https://www.tennisabstract.com/cgi-bin/player-classic.cgi?p=NovakDjokovic",
  "scrapedAt": "2026-09-11T04:31:07+00:00"
}
```

With `includePlayerRecord` on, one extra row per player:

```json
{
  "recordType": "player",
  "tour": "ATP",
  "player": "Novak Djokovic",
  "playerSlug": "NovakDjokovic",
  "currentRank": 5,
  "peakRank": 1,
  "birthDate": "1987-05-22",
  "hand": "R",
  "backhand": "2",
  "country": "SRB",
  "active": 1,
  "twitter": "DjokerNole",
  "matchCount": 1516,
  "sourceUrl": "https://www.tennisabstract.com/cgi-bin/player-classic.cgi?p=NovakDjokovic",
  "scrapedAt": "2026-09-11T04:31:07+00:00"
}
```

#### When something goes wrong

Nothing that failed is ever written to your dataset. Instead the run's `ERRORS`
key-value record collects one entry per affected target, each with the same
four keys:

```json
{
  "target": "IgaSwiatek",
  "slug": "IgaSwiatek",
  "errorClass": "wrong-player",
  "message": "The page published its own name as 'Benoit Paire', not the requested slug."
}
```

`errorClass` is one of ten values — eight about a single page, two about the
run as a whole:

| `errorClass` | What it means |
| --- | --- |
| `no-history` | The surface exists for that player and publishes no matches. |
| `source-error` | The source's own script raised an error on that page. Reported as a fault, never as "no matches". |
| `unreadable-page` | The site answered with a page that is not the one asked for. |
| `blocked` | The site refused the request. |
| `transport` | The request never completed after the built-in retry ladder. |
| `unresolved-name` | A display name from a leaders list did not resolve to a player page. |
| `unsupported-target` | An entry, or an input value, the Actor cannot act on. Run-level entries carry `slug` null. |
| `wrong-player` | The page answered with a different player than the slug asked for, and no row was taken from it. |
| `charge-limit` | Run-level: the run stopped at your account's spending limit with targets still unfetched. The entry names how many and which, and `target` and `slug` are both null. |
| `identity-unchecked` | Run-level **note**, not a failure: a note beside delivered rows. The surface they came from publishes no player name, so they could not be checked against the slug you asked for. |

An input the Actor cannot act on — an unknown `tour`, a fractional cap, an
unparseable span — ends the run as a **SUCCEEDED run with an empty dataset**,
one `unsupported-target` entry in `ERRORS` and no run status message. If you
watch for `FAILED` to detect a bad input, you will not see one; check
`ERRORS` and the item count instead.

### Pricing

Two charges, both automatic:

- **Result rows — $0.005 per row** written to the dataset (FREE-tier price;
  lower per-row prices apply on paid Apify plans).
- **Actor Start** — a per-run start fee (Apify's default unless the operator
  sets one), charged once per run. The Actor's memory ceiling is 1024 MB, and
  Apify charges this event once up to and including 1 GB of RAM and once more
  per extra GB, so the ceiling keeps it to exactly one start event per run.

The run's compute and its result writes are covered by those event prices, so
platform usage is not billed on top. Pages that fail are never written to the
dataset and so are never billed as rows; a run that matches nothing is charged
no result rows at all. Storage retention and later reads of the dataset are
billed according to your plan.

Worked examples — every row count below is one measured in a saved capture or
fixed by a cap, priced at $0.005 per row:

| Job | Rows | Cost |
| --- | ---: | ---: |
| Default run: one player, cap 100 | 100 | 100 × $0.005 = $0.50 |
| Recent-form slice, cap 22 | 22 | 22 × $0.005 = $0.11 |
| One head-to-head (Djokovic vs Nadal, filtered out of one fetch) | 60 | 60 × $0.005 = $0.30 |
| One full WTA career (Swiatek, 130 + 418) | 548 | 548 × $0.005 = $2.74 |
| One full ATP career (Djokovic, cap off) | 1,516 | 1,516 × $0.005 = $7.58 |
| Defaults at their ceiling: 50 players × 100 rows | 5,000 | 5,000 × $0.005 = $25.00 |
| Players cap at its maximum: 200 × 100 rows | 20,000 | 20,000 × $0.005 = $100.00 |

Two ceilings bound every run. `maxPlayers` (default 50, maximum 200) is applied
**after** discovery, so a token can never fan out past it: `@elo-top:551` left
at the defaults fetches 50 players, not 551. `maxMatchesPerPlayer` (default
100\) bounds the rows per player; setting it to `0` means a full career, which
on the largest measured career is 1,516 rows for that one player. Raise either
deliberately: with the per-player cap off, 200 players is 200 full careers.

Beyond those, your own Apify spending limit binds: when it is reached the run
stops fetching further players, writes a `charge-limit` entry to `ERRORS`
naming every target it did not fetch, and logs the same warning, so a run that
ends short always says why.

### Data sources and robots

Every row comes from `www.tennisabstract.com`, a public statistics site, and
every request is one ordinary HTTPS GET made the way a browser rendering that
page makes it, at least 1.5 seconds apart. No login, no paywall, no API key,
and nothing but professional match statistics — no personal data about private
individuals.

Tennis Abstract publishes no terms page reachable from its home page. This
Actor reads only surfaces its `robots.txt` allows where one exists for the
data, and demotes and discloses the ones it disallows. The site's robots file
is 77 bytes and disallows exactly three directories under `User-agent: *`:
`/jsfrags/`, `/jsmatches/` and `/jsplayers/`. Per family:

| Surface | Path | robots | Used for |
| --- | --- | --- | --- |
| ATP career page | `cgi-bin/player-classic.cgi` | allowed | Every ATP match row and the ATP bio |
| WTA header page | `cgi-bin/wplayer-classic.cgi` | allowed | The WTA column names |
| Elo report | `/reports/` | allowed | The `@elo-top:N` discovery token |
| WTA recent history | `jsmatches/{slug}.js` | **disallowed — fetched, and disclosed here** | WTA rows and the WTA bio; the site serves WTA history from nowhere else |
| WTA older history | `jsmatches/{slug}Career.js` | **disallowed — fetched, and disclosed here** | The rest of a WTA career |
| Leader lists | `jsmatches/leadersource*.js` | **disallowed — fetched, and disclosed here** | The `@leaders:` and `@wta-leaders:` tokens (names only) |

Those three disallowed fetches are all under `/jsmatches/`. Of the three
disallowed directories, that is the only one this Actor reads: nothing under
`/jsplayers/` is fetched, and `/jsfrags/` is **never fetched at all** — it is
the page's recent-results fragment, and everything in it is already on the
allowed career page.

### Limits and things worth knowing

- **Raw counts, not rates.** The columns are the numbers the source stores. The
  page's displayed percentages are computed at render time, and this Actor does
  not invent them — derive them yourself from the columns:
  first-serve-in percentage is `firstServesIn / servePoints`, first-serve win
  percentage `firstServePointsWon / firstServesIn`, second-serve win percentage
  `secondServePointsWon / (servePoints - firstServesIn)`, break points saved
  `breakPointsSaved / breakPointsFaced`, ace rate `aces / servePoints`, and the
  return-side equivalents from the matching `opponent*` columns.
- **About one match in ten carries no statistics at all.** On a full career
  capture, 150 of 1,516 rows have an empty serve/return block — walkovers and
  older matches the source never recorded. The whole block is `null` on those
  rows, together, rather than zero-filled: a zero ace count would be a false
  statistic. `minutes` is missing on rather more, 206 of 1,516.
- **Three columns are ATP-only.** `opponentPlayerId`, `roundNumber` and
  `matchNumber` are filled on ATP rows and are **absent from a WTA row**, which
  is why a WTA row is 48 columns wide against an ATP row's 51: the WTA header
  publishes 44 column names against the ATP page's 47 plus the unnamed
  opponent-id column. Read them with a key lookup that tolerates a missing key,
  not one that expects `null`. A WTA-only run sees three empty columns in the
  default dataset view, because the view names them; that is the source, not a
  fault.
- **The opponent id column is unnamed by the source.** The site's own header
  array does not name the last ATP column. It was identified as the opponent's
  site player id by measurement — on a full career capture it takes 394
  distinct values against exactly 394 distinct opponents, with no conflicts,
  and the same ids appear in the site's own player-page links.
- **`level`, `round` and the backhand codes are the site's own, unglossed.**
  The page names some of them and not others: `A`, `F` and `O` levels and `BR`,
  `Q1`, `Q2`, `Q3` rounds appear in the data with no label anywhere on the
  site, so they are passed through as the codes they are rather than under a
  meaning this Actor invented. Filter values are matched exactly, and the
  suggestion lists are suggestions, not a closed set.
- **`matchCount` on a player row counts the rows the source served** for that
  player, before your filters and cap. On the ATP career page, and on a WTA
  player whose two history files both answer, that is the career total the
  source published; when only one of the two WTA files answers, it is the total
  of the surfaces that did. Either way it is the count of rows the source
  served for that slug, not a count of the rows this run delivered — the rows
  this run delivered are the match rows in the dataset.
- **Doubles are not included.** The measured player's doubles history file
  answers `404` and the career page never fills its doubles array, so there is
  no doubles data behind a doubles switch. It will ship if and when the source
  is measured serving it.
- **A live source fault is reported as a fault.** The site's tournament CGI can
  answer `200` with a Python traceback in the body; that is classified
  `source-error`, not silently turned into "no matches".
- **An identity note is not a failure.** When rows come from a surface that
  publishes no player name, the rows the site served are accepted and the
  target gets an `identity-unchecked` note beside the rows delivered. The
  run's summary line counts the targets it attempted, so a target skipped by
  the spending limit is named in the `charge-limit` entry and its warning
  rather than in that count.
- **A run started with no players returns nothing and incurs no result-row
  charge**, by design: the field has no default, so an API call that omits it is
  never billed for a career it did not ask for. The automatic start event is
  charged once per run, as Pricing says.
- **The source is one person's site.** Pages are fetched at least 1.5 seconds
  apart and `maxPlayers` is capped at 200, deliberately.

### Integrations

Works with everything on the Apify platform: scheduled runs, webhooks, the REST
API, the MCP server, Zapier, Make, n8n, Slack, Google Sheets, Airbyte, and
LangChain / LlamaIndex.

### Support

Found a bug, need a column, or want one of the source's other filters exposed?
Open an issue on the Actor's **Issues** tab and I'll take a look.

# Actor input Schema

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

Who to fetch. Each entry is a Tennis Abstract player slug (<code>NovakDjokovic</code>), a pasted player-page URL in either form (<code>player-classic.cgi?p=NovakDjokovic</code>, <code>player.cgi?p=104925/Novak-Djokovic</code>, <code>wplayer-classic.cgi?p=IgaSwiatek</code>), or a discovery token: <code>@elo-top:10</code> takes the top N slugs from the site's own Elo report, <code>@leaders:1-50</code> and <code>@wta-leaders:1-50</code> take the site's leader lists. Left empty the run fetches nothing, writes no result rows and incurs no result-row charge (the automatic start event is charged once per run), and exits with a note in the log — it is never back-filled with a player you did not ask for.

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

Which surface family to read a player from. <b>Auto</b> tries the ATP page first and the WTA pages second, and accepts the first whose own published name matches the slug you asked for — the ATP path answers 200 for a WTA slug with a different player's page, so the name check is what makes auto safe.

## `span` (type: `string`):

Keep only matches in this span, using the site's own <code>Time Span</code> vocabulary: <code>Career</code>, <code>Last 52</code>, or a four-digit season. <code>Last 52</code> counts back from the run clock for an active player and from the player's newest match otherwise, which is the page's own rule. Leave empty to keep every match.

## `surface` (type: `string`):

Keep only matches on this surface. The four values are the site's own <code>Surface</code> control and are the only four measured in a full ATP career capture. Leave empty to keep every surface.

## `level` (type: `string`):

Keep only matches at this level, matched exactly against the site's own raw code. The site's <code>levdict</code> names <code>G M C Q D J S</code>; the rows also carry <code>A</code>, <code>F</code> and <code>O</code>, which the page itself never names, so they are offered as the codes they are rather than under a label we invented. Any other code the source emits is accepted too. Leave empty to keep every level.

## `round` (type: `string`):

Keep only matches in this round, matched exactly against the site's own raw code. The site's <code>rddict</code> names <code>F SF QF R16 R32 R64 R128 RR</code>; the rows also carry <code>BR</code> and <code>Q1</code>-<code>Q3</code>, which the page itself never names. Any other code the source emits is accepted too. Leave empty to keep every round.

## `tourney` (type: `string`):

Keep only matches whose tournament name contains this text, case-insensitively. A substring rather than an exact match because the site prefixes current-season rows — <code>US Open</code> keeps both <code>US Open</code> and <code>ATP US Open</code>. Type any tournament; the suggestions are names measured in a saved capture. Leave empty to keep every tournament.

## `result` (type: `string`):

Keep only wins or only losses, matched against the row's raw <code>W</code> / <code>L</code> value. Leave empty to keep both.

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

Keep only matches against this opponent. Written either way — <code>RafaelNadal</code> or <code>Rafael Nadal</code> — because the comparison is made on the site's own slug form of the name. This is a filter over rows already fetched, not a second download, so a head-to-head costs the same as the career it came from. Leave empty to keep every opponent.

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

Keep at most this many matches per player, newest first, after the filters above have run. <b>0 means every match</b> — a full ATP career is around 1,500 rows, so the cap is on by default and 0 is the deliberate way to ask for all of them.

## `maxPlayers` (type: `integer`):

Fetch at most this many players in one run, applied after the entries above are resolved, so a discovery token can never fan out past it — <code>@elo-top:200</code> with this left at 50 fetches 50 players. The source is one person's site and pages are fetched at least 1.5 seconds apart.

## `includePlayerRecord` (type: `boolean`):

Add one <code>recordType: "player"</code> row per successfully read player, carrying current and peak rank, birth date, hand, backhand, country, active flag, Twitter handle and the career match count the source publishes. Off by default: it is one extra billable row per player, and the bio would otherwise be repeated on every match row.

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

Optional. No proxy group is set by default: measured 2026-09-11, this source returned the identical page through Apify's datacenter group, through the residential group and with no proxy at all, so none is needed for correctness. Turn one on if a run is blocked.

## Actor input object example

```json
{
  "players": [
    "NovakDjokovic",
    "@elo-top:10"
  ],
  "tour": "auto",
  "opponent": "RafaelNadal",
  "maxMatchesPerPlayer": 100,
  "maxPlayers": 50,
  "includePlayerRecord": false
}
```

# Actor output Schema

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

No description

## `errors` (type: `string`):

Per-target failures and run-level notes, when present; never result rows.

# 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": [
        "NovakDjokovic"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("danthedataman/tennis-abstract-data-api").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": ["NovakDjokovic"] }

# Run the Actor and wait for it to finish
run = client.actor("danthedataman/tennis-abstract-data-api").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": [
    "NovakDjokovic"
  ]
}' |
apify call danthedataman/tennis-abstract-data-api --silent --output-dataset

```

## MCP server setup

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

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/REPVxR0X4aLaye5LI/builds/qT1YicL2yaIgMSVgM/openapi.json
