# Tennis Player Career Scraper — Serve Stats, Surface Splits (`scapypuppy/tennis-player-career-scraper`) Actor

Full career match history of ATP and WTA players: opponent, rank, round, surface, score, plus per-match ace rate, double fault rate, serve percentages, break points saved and dominance ratio. Win rates by surface and record against top-10 computed for you.

- **URL**: https://apify.com/scapypuppy/tennis-player-career-scraper.md
- **Developed by:** [Huyen Trang](https://apify.com/scapypuppy) (community)
- **Categories:** Sports
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 player profile with career totals

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 Player Career Scraper — Serve Stats, Surface Splits

**Career match history** of ATP and WTA players with the advanced numbers behind
each result: dominance ratio, aces and double faults, first and second serve
points, service games, break points saved and faced — for **both players in every match**,
not averaged over a season.

The input form starts at the 50 most recent matches per player, a quick first run;
left empty, `maxMatchesPerPlayer` is 500. Jannik Sinner has 565 career matches in the
source: at 500 the result is marked `truncated: true` — raise `maxMatchesPerPlayer`
to get all of them, with the split by surface, by tournament level and by year
already computed.

Win rates by surface and the record against top-10 opponents are computed for you, and so is a
**career summary** over every match that passes your filters: titles and finals by season and level,
head-to-head by opponent, splits, set and tiebreak records with hold and break rates, and a record at
each tournament. It comes with the profile at no extra charge.

**No login. No API key. No headless browser.**

### Quick start

1. In **Players**, type the names as written ("Jannik Sinner", "Iga Swiatek"), a last name that
   only one player has ("Swiatek"), or paste Tennis Abstract profile links.
2. Press **Start**. The form as first opened — Sinner and Alcaraz, 50 matches each — costs
   $0.029 (see **What a run costs**).
3. Open the **Output** tab. **Career matches** has one row per match with the player's serve
   numbers as columns; **Players** has one row per player with the profile and career totals;
   **Head-to-head**, **Tournaments** and **Titles** unfold the career summary.
4. For a spreadsheet, press **Export**, set **View** to **Career matches** and the format to
   **Excel**, then **Download**. Details in **Download as a spreadsheet**.

### What you get

| Capability | What it means for you |
|---|---|
| **Dominance ratio per match** | share of return points won divided by share of serve points lost — above 1.0 the player won more on return than they gave away on serve |
| **Serve profile per match** | aces, double faults, first serve in %, first and second serve points won, break points saved and faced — for both sides of the match |
| **Surface splits computed** | wins, losses, win rate and average dominance ratio for hard, clay and grass separately |
| **Record against top 10** | how many of those matches were against elite opponents, and how they went |
| **Rank context on both sides** | the player's rank and the opponent's rank at the time of the match |
| **Opponent profile per match** | hand, country and height, so left-handed and tall-server splits are one filter away |
| **Splits by level and by year** | Grand Slam, Masters, Tour, Challenger and Davis Cup records, plus a year-by-year table |
| **Walkovers and retirements flagged** | `walkover` and `retired` on every match; a walkover carries no minutes and no serve stats instead of zeros |
| **Player profile** | playing hand, one- or two-handed backhand, height, date of birth, doubles rank, last date at the peak rank, active flag, ATP, WTA and ITF ids |
| **Titles and finals** | by season and by level, the tour-level count apart, and every title with its date and surface |
| **Head-to-head by opponent** | meetings, wins, losses, the latest meeting and the record by surface, for up to 200 opponents |
| **Splits** | by round, best of 3 and best of 5, the last 52 weeks and tour-level matches only |
| **Sets, games, tiebreaks** | sets and games won and lost, tiebreaks, deciding sets, hold and break rates |
| **Record at each tournament** | editions, wins, losses, titles, finals and the best round, for up to 300 tournaments |
| **More per match** | best of 3 or 5, how both players entered the draw (Q, WC, LL), the opponent's age, birth date and id |
| **Profile only** | `maxMatchesPerPlayer: 0` returns the profile and every total for the profile price alone |
| **Last name works when unique** | "Swiatek" finds Iga Swiatek; a last name several players share ("Sinner", "Alcaraz") lists them, unbilled |
| **Filters before billing** | dates, surfaces, levels, rounds, result, opponent rank and opponent — you pay only for matches that pass |
| **Current Elo rating and rank** | the source's Elo, computed from match results, next to the official ranking |
| **Match keys and opponent slug** | `matchKey` and the source's `sourceMatchId` are the same in both players' careers, so meetings join and repeat runs deduplicate; `opponentSlug` is the opponent's id at the source |

### How this compares

| | This Actor | Other Store Actors (14 Oct 2026) |
|---|---|---|
| Profile and the 20 most recent matches | **$0.00705** | $0.008 a run on a Tennis Abstract Actor with 995 users in the last 30 days; it returns the 20 most recent matches |
| Profile and 500 matches | **$0.12705** | $0.45 (WTA players only) to $10.50 on the nine other Actors that return 500 matches of one player |
| Serve stats of both players in every match | **yes** | the player's side only on the Tennis Abstract Actor with 995 users |
| Filters applied before billing | **yes**: dates, surfaces, levels, rounds, result, opponent rank, opponent | the same set on one Tennis Abstract Actor with one user in the last 30 days; dates or surfaces at most on the others that filter |

Prices of other Actors are their Free-plan event prices on 14 October 2026, with the run
start where they charge one; the run totals of this Actor include its own $0.00005 start.

### Output

One record per player in the default nested layout (the rows layout below gives one
record per match instead). Taken from a local test run on 14 October 2026 with
`maxMatchesPerPlayer` at 500, shortened only by dropping repeated array items:

```json
{
  "player": "Jannik Sinner",
  "slug": "JannikSinner",
  "profileUrl": "https://www.tennisabstract.com/cgi-bin/player.cgi?p=JannikSinner",
  "fullName": "Jannik Sinner",
  "country": "ITA",
  "dateOfBirth": "2001-08-16",
  "hand": "right",
  "backhand": "two-handed",
  "heightCm": 191,
  "currentRank": 1,
  "peakRank": 1,
  "peakRankDate": "2024-06-10",
  "peakRankLastDate": "2026-10-05",
  "doublesRank": null,
  "doublesPeakRank": null,
  "currentElo": 2297,
  "currentEloRank": 1,
  "active": true,
  "atpId": "S0AG",
  "wtaPath": null,
  "itfId": null,
  "matches": [
    {
      "matchKey": "709cd4fc2038c724", "sourceMatchId": "2026-540-603",
      "date": "2026-06-29", "tournament": "Wimbledon", "surface": "Grass",
      "level": "Grand Slam", "levelCode": "G", "provisional": false,
      "round": "F", "bestOf": 5,
      "rank": 1, "seed": 1, "entry": null,
      "opponent": "Alexander Zverev", "opponentSlug": "AlexanderZverev", "opponentId": "100644",
      "opponentRank": 3, "opponentSeed": 2, "opponentEntry": null,
      "opponentHand": "right", "opponentCountry": "GER", "opponentHeightCm": 198,
      "opponentDateOfBirth": "1997-04-20", "opponentAgeAtMatch": 29.2,
      "won": true,
      "score": "6-7(7) 7-6(2) 6-3 6-4",
      "walkover": false,
      "retired": false,
      "durationMinutes": 226,
      "dominanceRatio": 1.237,
      "playerStats": {
        "aces": 15, "doubleFaults": 2, "servicePoints": 136,
        "firstServesIn": 87, "firstServePointsWon": 70, "secondServePointsWon": 32,
        "serviceGames": 22, "breakPointsSaved": 1, "breakPointsFaced": 1,
        "firstServePercent": 64, "firstServeWonPercent": 80.5, "secondServeWonPercent": 65.3
      },
      "opponentStats": {
        "aces": 17, "doubleFaults": 2, "servicePoints": 139,
        "firstServesIn": 105, "firstServePointsWon": 76, "secondServePointsWon": 20,
        "serviceGames": 21, "breakPointsSaved": 3, "breakPointsFaced": 5,
        "firstServePercent": 75.5, "firstServeWonPercent": 72.4, "secondServeWonPercent": 58.8
      }
    }
  ],
  "matchesAvailable": 565,
  "matchesMatchingFilters": 565,
  "filters": null,
  "truncated": true,
  "historyComplete": true,
  "historyNote": null,
  "note": null,
  "careerStats": {
    "basedOnMatches": 500,
    "matches": 500, "wins": 397, "losses": 103, "winRate": 0.794,
    "firstMatch": "2019-04-29", "lastMatch": "2026-06-29",
    "avgDominanceRatio": 1.448,
    "avgAcePercent": 8.611, "avgDoubleFaultPercent": 2.575,
    "winsVsTop10": 69, "matchesVsTop10": 106,
    "bySurface": [
      { "surface": "Hard", "matches": 335, "wins": 271, "losses": 64,
        "winRate": 0.809, "avgDominanceRatio": 1.456 }
    ],
    "byLevel": [
      { "level": "Masters 1000", "matches": 159, "wins": 126, "losses": 33, "winRate": 0.792 }
    ],
    "byYear": [
      { "year": 2026, "matches": 47, "wins": 44, "losses": 3, "winRate": 0.936 }
    ]
  },
  "careerSummary": {
    "basedOnMatches": 565,
    "titles": {
      "total": 35, "finals": 46, "tourLevel": { "titles": 30, "finals": 39 },
      "byYear": [{ "year": 2026, "titles": 6, "finals": 6 }],
      "byLevel": [{ "level": "ATP Tour", "titles": 13, "finals": 16 }],
      "list": [{ "date": "2026-06-29", "tournament": "Wimbledon", "level": "Grand Slam",
                 "surface": "Grass", "walkover": false }]
    },
    "splits": {
      "byRound": [{ "round": "F", "matches": 47, "wins": 36, "losses": 11, "winRate": 0.766 }],
      "byBestOf": {
        "3": { "matches": 443, "wins": 337, "losses": 106, "winRate": 0.761 },
        "5": { "matches": 122, "wins": 100, "losses": 22, "winRate": 0.82 }
      },
      "last52Weeks": { "from": "2025-10-15", "matches": 62, "wins": 59, "losses": 3, "winRate": 0.952 },
      "tourLevelOnly": { "matches": 464, "wins": 370, "losses": 94, "winRate": 0.797 }
    },
    "records": {
      "matchesWithScore": 554,
      "sets": { "won": 1029, "lost": 390 }, "games": { "won": 7808, "lost": 5805 },
      "tiebreaks": { "won": 139, "lost": 75 }, "matchTiebreaks": { "won": 0, "lost": 0 },
      "decidingSets": { "won": 97, "lost": 52 },
      "unfinishedSets": { "count": 15, "gamesWon": 26, "gamesLost": 32 },
      "matchesWithServeStats": 503,
      "serviceGames": 6197, "serviceGamesHeld": 5330, "holdPercent": 86,
      "returnGames": 6120, "returnGamesWon": 1743, "breakPercent": 28.5
    },
    "headToHead": [
      { "opponent": "Carlos Alcaraz", "opponentId": "207989", "opponentSlug": "CarlosAlcaraz",
        "matches": 18, "wins": 7, "losses": 11,
        "lastMeeting": "2026-04-05", "lastMeetingTournament": "Monte Carlo Masters",
        "lastMeetingRound": "F", "lastMeetingWon": true, "lastMeetingScore": "7-6(5) 6-3",
        "bySurface": [{ "surface": "Hard", "matches": 10, "wins": 3, "losses": 7 }] }
    ],
    "headToHeadOpponents": 235,
    "byTournament": [
      { "tournamentId": "580", "tournament": "Australian Open", "level": "Grand Slam",
        "surface": "Hard", "editions": 7, "matches": 32, "wins": 27, "losses": 5,
        "titles": 2, "finals": 2, "bestRound": "W" }
    ],
    "tournamentsTotal": 91
  },
  "dataSource": "Tennis Abstract (tennisabstract.com)",
  "scrapedAt": "2026-10-14T17:39:09.083Z",
  "tour": "atp"
}
```

`matchesMatchingFilters` equals `matchesAvailable` and `filters` is `null` when no
filter is set; see Filters below.

Per-match serve stats live in the nested `playerStats` and `opponentStats` objects —
one set of numbers for each side of the match. Career-average rates such as
`avgAcePercent` live in `careerStats`.

`hand` and `opponentHand` are `right`, `left` or `null` when the source does not know;
`backhand` is `one-handed` or `two-handed`;
`heightCm` is `null` when the source does not know it. `currentElo` and
`currentEloRank` are the source's own Elo rating and Elo position, read from
the same profile page — no extra request — and refreshed by the source roughly
weekly. Players the source does not rate come through with `null` there.

**The `score` string is written from the winner's side** — that is how the source
records it. When `won` is `false`, the score reads as the opponent's winning
scoreline: the sets your player took are the second number of each pair. The
string can also carry markers like `RET` (retired) or `W/O` (walkover).

**Walkovers and retirements.** `walkover: true` marks a match that was not played (`W/O` in
the score): its `durationMinutes`, `playerStats`, `opponentStats` and `dominanceRatio` are
`null`, where the source itself writes 0 minutes. Sinner's 565 matches hold 11 walkovers.
`retired: true` marks a match that ended with a retirement (`RET`): the part that was played
keeps its minutes and stats. Both count in `careerStats` with the result the source gives
them. A duration the source writes as 0 for a played match also comes through as `null`.

**More about each match.** `bestOf` is 3 or 5. `entry` and `opponentEntry` say how each player got
into the draw, in the source's codes: `Q` qualifier, `WC` wild card, `LL` lucky loser, `PR` protected
ranking, `SE` special exempt, `Alt` alternate; `null` is a direct entry. `opponentDateOfBirth` and
`opponentAgeAtMatch` (years, one decimal, on the tournament start date) describe the opponent;
`opponentId` is the opponent's id at the source, written for men's matches only (the women's files
have no such column). In Sinner's 565 matches `bestOf` is 3 in 443 and 5 in 122.

**The current week.** The source writes the tournament still under way as "ATP Shanghai" or "WTA
Wuhan" and, for women, often with level `W` until it assigns a category; once the week is over it
renames the tournament and may change the level. Such matches come with `provisional: true` and the
name without the prefix (`Shanghai`), and `matchKey` is computed from that name, so it stays the same
after the rename. Data of the current week are preliminary: the name and the level may still change.
In the local run of 14 October 2026 Alcaraz's two Shanghai matches of the week that started on 7
October came this way.

**Profile.** `peakRankLastDate` is the last date at the peak rank, `doublesRank` and
`doublesPeakRank` the doubles ranks (`null` where the source writes UNR, unranked: so it does for
Sinner and Swiatek, while Lukasz Kubot has 6 and 1), `active` the source's own flag, `atpId` the id
on the ATP website as the source writes it (men: `S0AG` for Sinner), `wtaPath` the source's
reference to the WTA profile (women: `28728/title/iga-swiatek`) and `itfId` the ITF id. The source
leaves some of them empty, `wtaPath` of Qinwen Zheng and Mirra Andreeva for one, and they come as
`null`.

`note` explains a profile with no match after the filters (see **What a run costs**) and is
`null` otherwise. Percent fields (`firstServePercent`, `avgAcePercent`) run from 0 to 100,
rates (`winRate`) from 0 to 1. The dataset schema of the Actor gives every field a title, a
description and, where a run had one, an example value.

### Career summary

`careerSummary` holds the totals of every match that passed the filters, not only the returned ones:
with `maxMatchesPerPlayer` at 50 it still covers the whole filtered career (`basedOnMatches`). It is
computed from the pages already loaded, with no extra request, and comes with the profile price. With
`computeCareerStats: false` it is `null`.

- **`titles`**: a title is a final won (`round` F), a walkover in the final included (marked
  `walkover: true` in `list`). `total` and `finals` count every level; `tourLevel` leaves out
  Challengers, ITF events and qualifying; `byYear` and `byLevel` split them, `list` names each title,
  newest first. Team events (Davis Cup, Billie Jean King Cup, ATP Cup, United Cup, Laver Cup) and the
  NextGen Finals give no title. Jannik Sinner: 35 titles from 46 finals, 30 of them at tour level; his
  2019 NextGen Finals win is not counted, his first tour-level title is Sofia 2020.
- **`headToHead`**: one entry per opponent, most meetings first, at most 200 (`headToHeadOpponents`
  has the full count). Men's opponents are told apart by the source's id, women's by the slug. Each
  entry has the meetings, wins, losses, the latest meeting (`lastMeeting` is its tournament start date,
  with tournament, round, result and score next to it) and `bySurface`. Sinner against Alcaraz: 18
  meetings, 7 wins, 11 losses.
- **`splits`**: `byRound`, `byBestOf` (`"3"` and `"5"`), `last52Weeks` (tournaments that started in
  the 364 days before the run, `from` gives the first date) and `tourLevelOnly` (no Challenger, ITF,
  Futures or qualifying, as the source's own All ATP filter), each with matches, wins, losses and win
  rate.
- **`records`**: read from the scores, which the source writes from the winner's side. Sets, games and
  tiebreaks count completed sets; a deciding tiebreak to 10 written as `[10-6]` counts as a set and in
  `matchTiebreaks`, not in games; a set cut short by a retirement or a default goes to
  `unfinishedSets`; walkovers are skipped. `decidingSets` are final sets played at one set all (or two
  all) that decided the match. Hold and break rates use only the matches with serve stats on both
  sides (`matchesWithServeStats`): hold = (service games - (break points faced - saved)) / service
  games, break = (opponent's break points faced - saved) / opponent's service games. Sinner: 86.0%
  of service games held and 28.5% of return games won over 503 such matches.
- **`byTournament`**: one entry per tournament, most matches first, at most 300 (`tournamentsTotal`
  has the full count). The tournament id is the middle part of `sourceMatchId` (`540` for Wimbledon),
  so a renamed event stays one entry; `editions`, the record, `titles`, `finals` and `bestRound` (`W`
  for a title, otherwise the furthest round).

Filters narrow the summary too: with `opponents: ["Carlos Alcaraz"]` Sinner's summary has one
head-to-head entry and counts the 4 titles he won in finals against Alcaraz.

In the rows layout the profile record carries `careerSummary` without its lists (`byYear`,
`byLevel`, `list`, `byRound`, `headToHead`, `byTournament`): a spreadsheet export is limited to 2,000
columns, and those lists alone would fill it. The nested layout, the default, has all of it, and the
**Head-to-head**, **Tournaments** and **Titles** views unfold it into rows.

### Match key and joins

**`matchKey`** is 16 hex characters of a hash of the tour, the tournament start date, the
tournament, the round and the slugs of both players in alphabetical order, so every match has
one. A meeting of two players you both requested has the same key in both careers: in a local
run on 14 October 2026 Jannik Sinner's 565 matches and Carlos Alcaraz's 477 held 18 meetings,
and all 18 had the same key on both sides, while no key repeated inside either career or among
Iga Swiatek's 555. Use it to drop duplicates when the same player comes back in a later run, or
to count each meeting once.

**`sourceMatchId`** is the source's own id of the match, such as `2026-540-603` (ITF events
have a longer form). It was present on every match of those three careers, unique within each
and the same on both sides of the 18 meetings; it is `null` where the source writes none.

**`opponentSlug`** is the opponent's slug at the source, built from the name by the same rules
as the players input ("Carlos Alcaraz" → `CarlosAlcaraz`): it equals `slug` in that opponent's
own record (compare them ignoring letter case: `slug` follows the case of your input) and
works as a `players` entry or an `opponents` filter as it is. The **Career matches** view
carries both, `slug` next to the player's name.

**Joins with Flashscore data.** Tennis Abstract has no Flashscore IDs, so a career joins with our
Flashscore-based Actors — the tennis match scraper and the ATP and WTA rankings of the League
Standings Actor — only by the player's name, and the names are written differently: "Jannik
Sinner" here, "Sinner Jannik" in the rankings, "Sinner J." in match records. Match on the
surname and the first initial, and check players who share them.

### Input

```json
{
  "players": ["Jannik Sinner", "Carlos Alcaraz", "Iga Swiatek"],
  "maxMatchesPerPlayer": 1000,
  "computeCareerStats": true
}
```

Names are matched as written. Profile links work too. Accents are dropped, and letters
such as Ł, Đ, Ø or ß are replaced by their usual Latin spelling: "Łukasz Kubot" finds
`LukaszKubot`, "Novak Đoković" finds `NovakDjokovic`. The same player given twice (by name
and by link, or in different letter case) is fetched and billed once.

**A last name works when only one player has it.** The Actor looks names up in the source's own
player list (79,580 names on 14 October 2026), the one its search box uses: "Swiatek" finds Iga
Swiatek, and "Sinner Jannik" or "JANNIK SINNER" find Jannik Sinner. When several players share the
name, nothing is fetched or billed for it: the record has the error `ambiguous name` and up to 10
`candidates` with their names, tours, slugs and profile links. In the list "Sinner" is Jannik, Martin
and Von Sinner, and "Zverev" is Mischa and Alexander, so use the full name for them. The list is a
1.7 MB file, loaded at most once a run: for a last name alone, for a name the source does not
know, and right away with three players or more, because it also tells the tour of each player
(see **Speed**). Players do not wait for it: one whose turn comes before the list has loaded is
fetched by the name as written. Among the best-known players a last name alone is often shared:
on 14 October 2026 "Alcaraz" matched 7 players in the list, "Zheng" 17, "Djokovic" and "Sinner" 3,
"Medvedev" 2, while "Swiatek", "Gauff" and "Sabalenka" matched one each. A name that turns out to be a player already returned in
the run ("Swiatek" and "Iga Swiatek") is skipped, not billed twice.

### Filters

`dateFrom`, `dateTo`, `surfaces`, `levels`, `rounds`, `result` (won or lost),
`opponentRankMax` and `opponents`. They are applied before `maxMatchesPerPlayer` and before billing: you
pay only for matches that pass. Career stats and the career summary are computed over the filtered
matches, and the record shows `matchesMatchingFilters` next to `matchesAvailable` and the filters it
applied in `filters`. Each match also carries the source's `levelCode`.

**Dates are tournament start dates.** The source dates every match of a tournament with
the day the tournament started, from the first round to the final: all seven of Sinner's
Wimbledon 2026 matches carry 2026-06-29. `dateFrom` and `dateTo` compare against that
date, so `dateFrom: "2026-07-01"` leaves Wimbledon out even though the final was played
in July.

**Level codes** are the source's own. Men: `G` Grand Slam, `M` Masters 1000, `A` ATP Tour,
`C` Challenger, `S` ITF Futures, `F` Tour Finals, `D` Davis Cup, `O` Olympics. Women: `G`
Grand Slam, `PM` WTA 1000, `P` WTA 500, `I` WTA 250, `C` WTA 125 (Sabalenka's Mumbai and Taipei
2017\), `W` a WTA tour event the source has not assigned a category yet (the tournament of the
current week, a few older entries such as Eastbourne 2021), `F` Tour Finals, `D` Billie Jean King
Cup, `O` Olympics. Other ITF events the source codes by prize money (`15`, `25`, `60`...); `level`
names them after the ITF World Tennis Tour, `ITF M15` for men and `ITF W25` for women, and
`levelCode` keeps the number. `ITF` selects every ITF event: the men's Futures (`S`), the
prize-money codes and the women's `CH` and `CC`. In a local run on 14 October 2026 `levels: ["ITF"]`
kept 32 of Alcaraz's 477 matches: 17 Futures and 15 `ITF M15`.

**Opponents.** `opponents` keeps only matches against the players listed: a name as the source
writes it ("Carlos Alcaraz"), a slug (`CarlosAlcaraz`, any letter case) or a profile link. It is
compared against `opponentSlug`, so a last name alone ("Zverev") matches nothing. In a local
run on 14 October 2026 `opponents: ["Carlos Alcaraz", "NovakDjokovic", "<Zverev's profile
link>"]` narrowed Sinner's 565 matches to 45 (18 against Alcaraz, 15 against Zverev, 12 against
Djokovic), billed $0.0133 with the start. An entry with no letters stops the run before the
first request.

**Round codes** are the source's own too: `F` final, `SF`, `QF`, `R16`, `R32`, `R64`,
`R128`, `RR` round robin (group stages of the Tour Finals and team cups), `BR` the
Olympic bronze medal match, `Q1` to `Q4` qualifying rounds. Write them in upper case as
listed: the platform rejects any other value before the run starts, lower-case `qf`
included.

```json
{
  "players": ["Jannik Sinner"],
  "dateFrom": "2024-01-01",
  "surfaces": ["Clay"],
  "opponentRankMax": 10,
  "maxMatchesPerPlayer": 10
}
```

In test runs on 13 and 14 October 2026 this input narrowed Sinner's 565 career matches to 12
(clay, from 2024, against the top 10); the cap of 10 then returned the 10 most recent with
`truncated: true` and `matchesMatchingFilters: 12`, billed for 10. The record carries the
applied filters (an excerpt of the record):

```json
{
  "matchesAvailable": 565,
  "matchesMatchingFilters": 12,
  "filters": {
    "dateFrom": "2024-01-01", "dateTo": null, "surfaces": ["Clay"], "levels": null,
    "rounds": null, "result": null, "opponentRankMax": 10, "opponents": null
  },
  "truncated": true
}
```

A filter value the Actor cannot read stops the run before the first request, so a typo
never bills a full career.

### Output layout

`outputLayout: nested` (the default) gives one record per player with the career matches
as an array inside it. `outputLayout: rows` gives one `player-profile` record plus one
`career-match` record per match, with the player name, slug and tour in each and a
`recordType` field; `outputLayout: rows` exports straight to CSV or a spreadsheet. Billing is
the same in both layouts. In the rows layout the profile record carries `careerSummary` without its
lists (see **Career summary**): in a local run of 8 players with 15 matches each the whole dataset
flattened to 183 columns.

The dataset views work in both layouts. In the nested layout **Career matches** unwinds the
matches of each player into one row per match, with the player's serve numbers
(`playerStats`) as columns, the opponent's slug and both match keys; the opponent's side stays in
`opponentStats` in JSON. In the rows layout the view lists the `career-match` records, and the
profile record shows as a row with the name only. **Players** lists one row per player with
its `slug`.

**Head-to-head** (one row per player and opponent), **Tournaments** (one row per player and
tournament) and **Titles** (one row per title) unfold `careerSummary` in the nested layout. In the
rows layout the profile record has no such lists, and these views show each record as a row with the
name only.

### Download as a spreadsheet

- **Export** on the run page: pick the view **Career matches** (or **Players**, **Head-to-head**,
  **Tournaments**, **Titles**) and **Excel**: one row per match, numbers as numbers.
  The default **All fields** puts each player on one row with every match and the career summary
  spread over the columns: Sinner and Alcaraz with 50 matches each flattened to 7,588 columns in a
  local run, and CSV and Excel files stop at 2,000 columns.
- **API**: `https://api.apify.com/v2/datasets/<datasetId>/items?view=matches&format=xlsx`
  gives the Career matches view as an Excel file, with column headers named after the fields
  (`date`, `opponent`, `aces`); `view=overview` gives the Players view, `view=headToHead`,
  `view=tournaments` and `view=titles` the career summary; add `clean=true` to drop empty rows.
- **Prefer Excel to CSV** for opening in Excel: in locales that use `;` as the list separator a
  CSV file opens as one column.
- `outputLayout: rows` writes one record per match instead, so even **All fields** exports
  flat; the profile record then carries the career summary without its lists.

### Tennis player stats API: call it from code

Any Apify client works. The **API** tab of this page has ready code with this Actor's full name
and your token; below, `<owner>` stands for the owner part of that name and `$APIFY_TOKEN` for
your own token. `maxTotalChargeUsd` caps what one run may cost: the run stops
before the next player once the cap is reached (see **What a run costs**). A synchronous call
waits for the run and returns the dataset items; a run longer than 300 seconds answers with
408, so keep synchronous calls small and use a client library for big runs.

```bash
curl -X POST "https://api.apify.com/v2/acts/<owner>~tennis-player-career-scraper/run-sync-get-dataset-items?view=matches&format=csv&maxTotalChargeUsd=0.05" \
  -H "Authorization: Bearer $APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"players": ["Jannik Sinner"], "maxMatchesPerPlayer": 50}'
```

Python, with [apify-client](https://docs.apify.com/api/client/python/docs):

```python
from apify_client import ApifyClient

client = ApifyClient("<APIFY_TOKEN>")
run = client.actor("<owner>/tennis-player-career-scraper").call(
    run_input={"players": ["Jannik Sinner"], "maxMatchesPerPlayer": 50},
    max_total_charge_usd=0.05,
)
print(run.status, run.status_message)
if run.status == "SUCCEEDED":
    rows = client.dataset(run.default_dataset_id).list_items(view="matches").items
    for row in rows:
        print(row["date"], row["tournament"], row["opponent"], row["score"], row.get("aces"))
```

JavaScript, with [apify-client](https://docs.apify.com/api/client/js/docs):

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

const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('<owner>/tennis-player-career-scraper').call(
    { players: ['Jannik Sinner'], maxMatchesPerPlayer: 50 },
    { maxTotalChargeUsd: 0.05 },
);
console.log(run.status, run.statusMessage);
if (run.status === 'SUCCEEDED') {
    const { items } = await client.dataset(run.defaultDatasetId).listItems({ view: 'matches' });
    for (const row of items) console.log(row.date, row.tournament, row.opponent, row.score, row.aces);
}
```

The status message is worth printing: it says what was output and why anything was not.

### Use it from an MCP client

Apify's MCP server at `https://mcp.apify.com` exposes Store Actors as tools to MCP clients
such as Cursor, VS Code or GitHub Copilot. The client can find this Actor with the
`search-actors` tool and run it with `call-actor`, or you can pin it as a tool of its own:
`https://mcp.apify.com?tools=<owner>/tennis-player-career-scraper`. `call-actor` returns
the run's status and storage ids rather than the rows; `get-dataset-items` reads the records.
Details: [Apify MCP server](https://docs.apify.com/platform/integrations/mcp).

### Schedules, webhooks and integrations

- **[Schedules](https://docs.apify.com/platform/schedules)** run the Actor or a saved task on a
  cron expression: a weekly run of the same players returns their latest matches again, and
  `matchKey` tells the new ones from those you already have.
- **[Webhooks](https://docs.apify.com/platform/integrations/webhooks)** call your URL when a run
  succeeds or fails.

### When a player returns nothing

A player that gives no data gets a record with `error` and `details` instead, and is not
billed. The record's `player` holds what you typed, so it shows in the **Players** view:

| `error` | What happened |
|---|---|
| `player not found` | neither the source's pages nor its player list know this name: check the spelling, or paste the profile link |
| `ambiguous name` | several players in the source's player list match the name; `candidates` lists up to 10 of them |
| `empty player name` | the entry holds no letters |
| `rate limited` | the source kept refusing requests (HTTP 429) after several retries; the player may well exist |
| `source unavailable` | the source did not answer after several retries |
| `parse failed` | the page loaded but its match table could not be read |

Every run ends with a status message: how many players and matches were output, how many
players were not and why (for example `1 player not found (Jannik Siner)`), players with no
match after the filters, and a stop on the spending limit. A run where no player loaded
because the source refused or did not answer **fails** with that explanation, so webhooks
and alerts see it; when the first three players all fail that way, it stops trying the rest.
A name the source does not know never fails a run: it succeeds with the breakdown in its
status. An empty `players` list or a filter value the Actor cannot read fails the run before
the first request. A proxy that refuses the connection (HTTP 407, for example a proxy group your
plan does not include or custom proxy URLs with wrong credentials) fails the run at once with a
message about the proxy, not about the source; a local test with a proxy that answered 407 stopped
after that single request.

### Limits

- **`maxMatchesPerPlayer` keeps the most recent matches: 500 when left empty, 50 in
  the input form as first opened.** Long careers run past 500: Sinner has 565
  matches in the source, Swiatek 555.
  When the limit cuts the history, the record says so — `truncated: true`,
  `matchesAvailable` carries the full count, and `careerStats` covers only the
  returned window (its own `basedOnMatches`, `firstMatch` and `lastMatch` show
  exactly what it was computed from). Raise the limit to cover the whole career.
  `careerSummary` always covers the whole filtered career.
- **The career summary keeps 200 opponents and 300 tournaments a player**, those with the most
  matches; `headToHeadOpponents` and `tournamentsTotal` give the full counts. A women's opponent is
  recognised by the slug of her name, so one who changed her name counts as two.
- **Both tours can return the full career.** The men's pages carry it directly.
  The women's pages split it between a recent file and a career archive — the
  Actor reads both and merges them: Swiatek returns her matches back to 2016.
  If the archive fails to load, the record is published with
  `historyComplete: false` and a reason in `historyNote` instead of silently
  passing off the last two seasons as the career.
- **Older matches have thinner stats.** Serve numbers appear from the point the source
  has them; matches without them come through with null instead of zero.
- **Singles only.** The source's player pages list singles matches.
- **Match dates are tournament start dates**, see Filters above.
- **Matches not played yet are left out.** The source lists the current week's draw
  with an empty result (`U`) before the match is played; those rows are neither
  returned nor billed and do not count in `matchesAvailable`. Played matches of the current
  week come with `provisional: true` (see **Output**).

### Data source

Data comes from the public pages of **Tennis Abstract** (tennisabstract.com) and, for the name
lookup, the player list its search box loads. Each record names it in `dataSource`. You are responsible for using the output in line with the
source's terms and the law that applies to you.

### Speed

One request per player for the men's tour, no browser. A player on the women's tour takes up to
three when the tour is known beforehand: the profile page and the recent-matches file together,
then the career archive when the file says there is one. The tour comes from the source's player
list, loaded once with three players or more (see **Input**) and used once it is in, or from a
career returned earlier in the run in which the player was an opponent; otherwise the men's page is
tried first, as before, and a woman takes up to four requests. Up to three players load at a time,
one at a time after the source first refuses a request (HTTP 429), and the records keep the order of
your input. In local runs on 14 October 2026 with 8 players (4 men listed first, then 4 women) no
request went to the men's page for the women: the list had loaded before their turn. From a single
address without a proxy the source refused part of the player pages by its rate limit when six
requests went at once, and the retries waited about 10 seconds each. The run log ends with a line of the
requests made, the refusals (HTTP 429) and the seconds spent waiting before retries. The run starts with 512 MB of memory.

In cloud runs of build 1.0 on 13 and 14 October 2026, before these changes, most men's players took
0.3 to 3 seconds and women's players 2.6 to 19 seconds (Iga Swiatek, with the career archive, up to
18.7 seconds); a retry after the source refuses a request adds several seconds.

### What a run costs

Billing is per row: **$0.002 per player profile** plus **$0.00025 per career match**
returned. The match rows are the bill, so `maxMatchesPerPlayer` is the control that
matters:

| Depth | Matches | Cost per player |
|---|---:|---:|
| Profile only (`maxMatchesPerPlayer: 0`) | 0 | $0.002 |
| Input form as first opened | 50 | $0.0145 |
| Recent form | 100 | $0.027 |
| Default cap (field left empty) | 500 | $0.127 |
| A whole top-tier career | 1 000 | $0.252 |

The platform also charges $0.00005 when a run starts. The form as first opened (Sinner and
Alcaraz, 50 matches each) costs $0.029 a run.

The profile price covers `careerStats` and the whole `careerSummary`. **Profile only:** with
`maxMatchesPerPlayer: 0` no match is returned or billed, and the profile comes with `careerStats` and
`careerSummary` over every match that passed the filters for $0.002 a player; in a local run on 14
October 2026 Sinner and Swiatek this way were billed two profiles and nothing else.

A player whose page cannot be read is published with an error and is not billed. **A player
whose matches the filters leave out entirely is billed for the profile ($0.002)**: the profile
was fetched and read, and the record comes with the profile, `matches: []` and a `note`. The
run's status message lists such players.

**Run minimum.** A run that reads the source and returns little — only players the source
does not know, for example — still uses compute, storage and traffic on the platform. Such a
run is topped up with the `run-minimum` event ($0.00001 a unit) to 1.5 times its platform
usage at Apify's list prices (compute after the first 5 seconds, which Apify covers). A run
that fails, is aborted gracefully or reaches its timeout is topped up only to 1.25 times the
usage, which covers what the run cost and no more; an abort without
the graceful option (the default in Console and the API) stops the run at once, and it pays
only what was charged along the way. Everything the run was charged, the start included,
counts toward the minimum, so a run whose players cover its usage pays no final top-up, and a
run with nothing to bill usually comes to a few hundredths of a cent. In a run longer than a
minute, part of the top-up can be charged along the way, right after players that returned
nothing billable, so that a crash later on cannot leave it unpaid. That part is capped at 1.25
times the usage so far and counts toward the final top-up, but it is **not refunded** if later
players return data that would have covered the run. If you pick residential proxies in the
Proxy field, their traffic counts at Apify's $8 per GB. The amount and the reason are written
to the run's status message and the log. Users on the Apify free plan never pay it, and the
minimum never goes past your maximum cost per run: before each player the run checks that the
minimum for more work still fits and otherwise stops and says so, and a page that does not
answer is not retried once the retry would no longer fit.

**The maximum charge you set for a run is respected match by match.** When the budget
runs out, the last player is returned only with the matches that were billed (marked
`truncated: true`, with `careerStats` computed over them), and the run stops: the next
player is neither fetched nor returned. When the budget left does not cover a profile
together with at least one match, that player is neither returned nor billed. The run's
status message says how many players were output and how many were not processed.

### FAQ

**Is there a tennis player stats API with match-by-match serve and return numbers?**
Yes. Pass player names in `players` and get their career match by match, with aces, double faults, first and
second serve points, break points saved and faced and dominance ratio for both players in every
match. Call it from any language over HTTP and get JSON, CSV or Excel; see **Tennis player stats
API: call it from code**.

**A player comes back as "player not found". What now?**
Check the spelling: the Actor has already looked the name up in the source's player list. Paste the
profile link, `https://www.tennisabstract.com/cgi-bin/player.cgi?p=JannikSinner`, if the name is
written differently there. The error record is free.

**Can I type only the last name?**
Yes, when one player has it: "Swiatek" finds Iga Swiatek. Many top players share their last name
with others in the source's list: "Sinner" returns the error `ambiguous name` with Jannik, Martin
and Von Sinner as `candidates`, unbilled; write "Jannik Sinner" then.

**Why do `careerStats` and `careerSummary` cover different matches?**
`careerStats` covers the returned matches, `careerSummary` every match after the filters
(`basedOnMatches` in each says how many). With `maxMatchesPerPlayer: 0` both cover the whole
filtered career.

**How many titles does a player have at tour level?**
`careerSummary.titles.tourLevel.titles`; `titles.total` also counts Challenger and ITF titles, and
`titles.byLevel` splits them. The **Titles** view lists each one.

**Why does `careerStats` differ from the official win-loss record?**
It is computed over the returned matches only: after the filters and the
`maxMatchesPerPlayer` cut (`basedOnMatches` says how many). Walkovers count with the result
the source gives them; filter them out of `matches` with the `walkover` flag if you need the
played record. For a record without Challenger, ITF and qualifying matches, take
`careerSummary.splits.tourLevelOnly`.

**How do I get one row per match in Excel or CSV?**
Press **Export**, pick the view **Career matches** and **Excel** (`view=matches` through the
API); see **Download as a spreadsheet**. `outputLayout: rows` writes one record per match.

**How do I get the matches between two players?**
Request one of them and put the other in `opponents`: you pay for the profile and those matches
only, and `careerSummary.headToHead` sums them up. With `maxMatchesPerPlayer: 0` you get the summary
alone for the profile price. When both are requested, their meetings carry the same `matchKey` in
both careers.

**How do I avoid duplicates across runs?**
Keep `matchKey` (or `sourceMatchId`) as the key of your table: the same match gets the same key
in every run, as long as the source writes its tournament and round the same way.

**Is a player with no matches after the filters billed?**
Yes, for the profile only: $0.002. The record carries the profile and a `note`.

**What does `truncated` mean?**
The history was cut by `maxMatchesPerPlayer` or by the maximum cost of the run: the record
holds the most recent matches, and `matchesMatchingFilters` says how many there were.

**Why does a run that returned little show a `run-minimum` charge?**
Reading the source costs compute, storage and traffic even when nothing billable comes back.
Such a run is topped up to 1.5 times what it used on the platform, or 1.25 times when it fails,
is aborted gracefully or reaches its timeout; see **Run minimum** above.

**Do I need residential proxies?**
No. The default Apify datacenter proxy is enough.

### Related Actors

- [Tennis Scraper — ATP, WTA | Live Scores, Odds, Point by Point](https://apify.com/scapypuppy/tennis-scraper-atp-wta) — ATP and WTA matches by day with live scores, point by point, statistics,
  head-to-head and odds.
- [League Standings & Top Scorers Scraper — ATP, WTA](https://apify.com/scapypuppy/league-standings-top-scorers) — ATP and WTA rankings with Elo ratings, and league tables for team sports.

### Support

Open an issue in the **Issues** tab of this Actor's page. Paste the link to the run (from the
run's page in Console): an Actor issue with a run link shares that run, its input, log and
storages with the developer automatically, and nothing else in your account
([Apify docs](https://docs.apify.com/account/collaboration/general-resource-access)). Say what
you expected and what you got instead. Do not post your API token or any other secret.

### Disclaimer

This is an **unofficial** Actor, not affiliated with or endorsed by Tennis Abstract, the
ATP, the WTA or any tournament. All trademarks belong to their respective owners.

# Changelog

This Actor's version history is a separate document: https://apify.com/scapypuppy/tennis-player-career-scraper/changelog.md

# Actor input Schema

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

Player names as written, such as "Jannik Sinner" or "Iga Swiatek", a last name that only one player has ("Swiatek"), or Tennis Abstract profile links. A last name several players share returns their names instead, unbilled. The same player given twice is fetched and billed once.

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

Most recent career matches kept per player. Each match is billed, so this is your cost cap; left empty, 500 is used. A cut career is marked truncated. 0 returns the profile with career totals only, billed as one profile.

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

Keep matches from tournaments that started on or after this date: the source dates each match by its tournament's start day.

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

Keep matches of tournaments that started on or before this date (tournament start date).

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

Keep only matches on these surfaces. Empty means all surfaces.

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

Keep only these tournament levels, in the source's codes (men's and women's differ, see option names). ITF selects every ITF event, such as M15, W25 or Futures. Empty means all.

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

Keep only these rounds. Empty means all rounds.

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

Keep only matches the player won, only the ones lost, or all.

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

Keep only matches against opponents ranked at this position or better at the time, for example 10 for top-10 opponents. Unranked opponents are left out.

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

Keep only matches against these players: names as the source writes them ("Carlos Alcaraz"), slugs (CarlosAlcaraz) or profile links. A last name alone is not enough. Empty means every opponent.

## `computeCareerStats` (type: `boolean`):

careerStats: win rate overall and by surface, level and year, dominance ratio, ace and double fault rates, record against the top 10. careerSummary: titles and finals, head-to-head by opponent, splits, sets, games, tiebreaks, hold and break rates, and a summary by tournament, over every match that passes the filters. Both come with the profile at no extra charge.

## `outputLayout` (type: `string`):

Nested: one record per player with the matches inside; the Career matches view still shows one row per match. Rows: a profile record plus one record per match (recordType field), ready for CSV. Billing is the same.

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

Datacenter proxies are enough; residential proxies are not required.

## Actor input object example

```json
{
  "players": [
    "Jannik Sinner",
    "Carlos Alcaraz"
  ],
  "maxMatchesPerPlayer": 50,
  "result": "all",
  "computeCareerStats": true,
  "outputLayout": "nested",
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

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

One row per career match: tournament, surface, level, round, best of, opponent with slug, id, rank, entry and age, result, score, walkover and retirement flags, minutes, dominance ratio, the player's serve stats, the current-week mark and the match keys.

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

One row per player: profile, current and peak rank, Elo, matches in the source and after filters, career totals, and the error and details of a player that was not returned.

## `headToHead` (type: `string`):

One row per player and opponent: meetings, wins, losses and the latest meeting, from careerSummary (nested layout).

## `tournaments` (type: `string`):

One row per player and tournament: editions, record, titles, finals and best round, from careerSummary (nested layout).

## `titles` (type: `string`):

One row per title with the tournament start date, level and surface, from careerSummary (nested layout).

# 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"
    ],
    "maxMatchesPerPlayer": 50
};

// Run the Actor and wait for it to finish
const run = await client.actor("scapypuppy/tennis-player-career-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",
        "Carlos Alcaraz",
    ],
    "maxMatchesPerPlayer": 50,
}

# Run the Actor and wait for it to finish
run = client.actor("scapypuppy/tennis-player-career-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",
    "Carlos Alcaraz"
  ],
  "maxMatchesPerPlayer": 50
}' |
apify call scapypuppy/tennis-player-career-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scapypuppy/tennis-player-career-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/Cb1JgoD4LuvAwoEdY/builds/S8SQOhbX9mJsWMgTU/openapi.json
