Tennis Player Career Scraper — Serve Stats, Surface Splits avatar

Tennis Player Career Scraper — Serve Stats, Surface Splits

Pricing

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

Go to Apify Store
Tennis Player Career Scraper — Serve Stats, Surface Splits

Tennis Player Career Scraper — Serve Stats, Surface Splits

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.

Pricing

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

Rating

0.0

(0)

Developer

Huyen Trang

Huyen Trang

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

4 hours ago

Last modified

Categories

Share

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

CapabilityWhat it means for you
Dominance ratio per matchshare 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 matchaces, double faults, first serve in %, first and second serve points won, break points saved and faced — for both sides of the match
Surface splits computedwins, losses, win rate and average dominance ratio for hard, clay and grass separately
Record against top 10how many of those matches were against elite opponents, and how they went
Rank context on both sidesthe player's rank and the opponent's rank at the time of the match
Opponent profile per matchhand, country and height, so left-handed and tall-server splits are one filter away
Splits by level and by yearGrand Slam, Masters, Tour, Challenger and Davis Cup records, plus a year-by-year table
Walkovers and retirements flaggedwalkover and retired on every match; a walkover carries no minutes and no serve stats instead of zeros
Player profileplaying 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 finalsby season and by level, the tour-level count apart, and every title with its date and surface
Head-to-head by opponentmeetings, wins, losses, the latest meeting and the record by surface, for up to 200 opponents
Splitsby round, best of 3 and best of 5, the last 52 weeks and tour-level matches only
Sets, games, tiebreakssets and games won and lost, tiebreaks, deciding sets, hold and break rates
Record at each tournamenteditions, wins, losses, titles, finals and the best round, for up to 300 tournaments
More per matchbest of 3 or 5, how both players entered the draw (Q, WC, LL), the opponent's age, birth date and id
Profile onlymaxMatchesPerPlayer: 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 billingdates, surfaces, levels, rounds, result, opponent rank and opponent — you pay only for matches that pass
Current Elo rating and rankthe source's Elo, computed from match results, next to the official ranking
Match keys and opponent slugmatchKey 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 ActorOther 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 matchyesthe player's side only on the Tennis Abstract Actor with 995 users
Filters applied before billingyes: dates, surfaces, levels, rounds, result, opponent rank, opponentthe 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:

{
"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

{
"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.

{
"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):

{
"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.

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:

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:

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.

Schedules, webhooks and integrations

  • 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 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:

errorWhat happened
player not foundneither the source's pages nor its player list know this name: check the spelling, or paste the profile link
ambiguous nameseveral players in the source's player list match the name; candidates lists up to 10 of them
empty player namethe entry holds no letters
rate limitedthe source kept refusing requests (HTTP 429) after several retries; the player may well exist
source unavailablethe source did not answer after several retries
parse failedthe 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:

DepthMatchesCost per player
Profile only (maxMatchesPerPlayer: 0)0$0.002
Input form as first opened50$0.0145
Recent form100$0.027
Default cap (field left empty)500$0.127
A whole top-tier career1 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.

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