Tennis Player Career Scraper — Serve Stats, Surface Splits
Pricing
from $2.00 / 1,000 player profile with career totals
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
Maintained by CommunityActor 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
- 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.
- Press Start. The form as first opened — Sinner and Alcaraz, 50 matches each — costs $0.029 (see What a run costs).
- 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.
- 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:
{"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 (roundF), a walkover in the final included (markedwalkover: trueinlist).totalandfinalscount every level;tourLevelleaves out Challengers, ITF events and qualifying;byYearandbyLevelsplit them,listnames 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 (headToHeadOpponentshas 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 (lastMeetingis its tournament start date, with tournament, round, result and score next to it) andbySurface. 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,fromgives the first date) andtourLevelOnly(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 inmatchTiebreaks, not in games; a set cut short by a retirement or a default goes tounfinishedSets; walkovers are skipped.decidingSetsare 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 (tournamentsTotalhas the full count). The tournament id is the middle part ofsourceMatchId(540for Wimbledon), so a renamed event stays one entry;editions, the record,titles,finalsandbestRound(Wfor 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>"]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=xlsxgives the Career matches view as an Excel file, with column headers named after the fields (date,opponent,aces);view=overviewgives the Players view,view=headToHead,view=tournamentsandview=titlesthe career summary; addclean=trueto 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: rowswrites 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 ApifyClientclient = 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").itemsfor 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
matchKeytells 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:
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
maxMatchesPerPlayerkeeps 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,matchesAvailablecarries the full count, andcareerStatscovers only the returned window (its ownbasedOnMatches,firstMatchandlastMatchshow exactly what it was computed from). Raise the limit to cover the whole career.careerSummaryalways covers the whole filtered career.- The career summary keeps 200 opponents and 300 tournaments a player, those with the most
matches;
headToHeadOpponentsandtournamentsTotalgive 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: falseand a reason inhistoryNoteinstead 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 inmatchesAvailable. Played matches of the current week come withprovisional: 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 — 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 — 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). 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.