Tennis Abstract Data API
Pricing
from $2.85 / 1,000 results
Tennis Abstract Data API
Export ATP and WTA player match histories from Tennis Abstract, including scores, opponents, surfaces, and available serve and return statistics. Filter by season, surface or head-to-head opponent; export JSON, CSV or Excel.
Pricing
from $2.85 / 1,000 results
Rating
0.0
(0)
Developer
Eli J
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
3 days ago
Last modified
Categories
Share
Pull a tennis player's whole career match log off tennisabstract.com as flat typed rows: date, tournament, surface, level, round, score, opponent, and the raw per-match serve and return counts the site stores. ATP and WTA, many players in one run, with season, surface, level, round, tournament and head-to-head filters applied to rows already fetched rather than re-downloaded.
One measured example: Novak Djokovic's page is 1,516 matches spanning 2003-01-06 to 2026-08-31, and the Actor takes all of them in a single request. Export to JSON, CSV or Excel, or read them straight off the API.
A run started with no players fetches nothing, writes no result rows and incurs no result-row charge, and exits cleanly; the automatic start event is charged once per run, as Pricing says.
What you get
One flat row per match (recordType: "match"). Every count is the raw number
the source stores, never a rate this Actor computed; every absent value a row
carries is null, never 0. An ATP row is 51 columns wide and a WTA row 48:
the three ATP-only columns are absent from a WTA row rather than null.
| Column | Type | Unit / form |
|---|---|---|
recordType | string | Literal match on a match row |
tour | string | ATP or WTA |
player | string | null | Display name as the source page publishes it |
playerSlug | string | The slug this run fetched |
date | string | Tournament start date, YYYY-MM-DD — the source dates every row of an event alike, so the per-match date is in chartSlug where the match is charted |
tournament | string | Tournament name as printed; current-season rows carry an ATP prefix |
surface | string | Hard, Clay, Grass or Carpet — the only four values in a full career capture |
level | string | The site's own raw level code, not glossed |
round | string | The site's own raw round code, not glossed |
result | string | W or L, from the player's side |
score | string | Score as printed, e.g. 7-6(5) 5-7 4-6 6-2 6-1 |
bestOfSets | integer | null | Sets (3 or 5) |
minutes | integer | null | Match duration in minutes |
playerRank / playerSeed / playerEntry | integer | integer | string, nullable | The player's ranking, seed and entry code at the match, as the site writes them |
opponent | string | Opponent display name |
opponentPlayerId | string | null | The site's own numeric player id for the opponent (ATP rows only; the key is absent from a WTA row) |
opponentRank / opponentSeed / opponentEntry | integer | integer | string, nullable | The same three for the opponent |
opponentHand | string | null | R or L |
opponentBackhand | string | null | The site's own backhand code, not glossed by the page |
opponentBirthDate | string | null | Date, YYYY-MM-DD |
opponentHeightCm | integer | null | Centimetres (163–211 across a full career capture) |
opponentCountry | string | null | Three-letter country code (SRB, ITA, ARG) |
aces, doubleFaults, servePoints, firstServesIn, firstServePointsWon, secondServePointsWon, serviceGames, breakPointsSaved, breakPointsFaced | integer | null | The player's own serve counts. Raw counts, never rates; the whole block is null together when the source recorded none |
opponentAces, opponentDoubleFaults, opponentServePoints, opponentFirstServesIn, opponentFirstServePointsWon, opponentSecondServePointsWon, opponentServiceGames, opponentBreakPointsSaved, opponentBreakPointsFaced | integer | null | The same nine counts from the opponent's serve — i.e. the player's return side |
matchId | string | The site's own id, <year>-<eventId>-<matchNum> |
roundNumber / matchNumber | integer | null | The site's round and match indexes (ATP rows only; the keys are absent from a WTA row) |
chartSlug | string | null | Point-by-point charting slug, where the match is charted |
chartUrl | string | null | The charting page URL built from that slug |
sourceUrl | string | The exact URL this row was read from |
scrapedAt | string | ISO-8601 UTC, to the second |
Turn on includePlayerRecord and each successfully read player also gets one
bio row (recordType: "player"). The source publishes the nine bio fields on
the ATP career page and on the WTA shortlist file only, so a WTA target whose
history came from the career file alone still gets its row, with those nine
fields null and an identity-unchecked note on the target — nothing is
filled in from another page:
| Column | Type | Unit / form |
|---|---|---|
recordType | string | Literal player on a bio row |
tour | string | ATP or WTA |
player | string | null | Display name |
playerSlug | string | The slug this run fetched |
currentRank | integer | null | Current ranking the source publishes |
peakRank | integer | null | Career-high ranking the source publishes |
birthDate | string | null | Date, YYYY-MM-DD |
hand | string | null | R or L |
backhand | string | null | The site's own backhand code, not glossed |
country | string | null | Three-letter country code |
active | integer | null | 1 active, 0 retired, as the source writes it |
twitter | string | null | Handle, without the @ |
matchCount | integer | null | The rows the source served for that player, before your filters and cap — the published career total when every history surface answered, not a count of the rows this run delivered |
sourceUrl | string | The exact URL the bio was read from |
scrapedAt | string | ISO-8601 UTC, to the second |
Why this Actor
- A whole career in one request, not a recent-form slice. The site's own recent-results fragment carries 22 rows; the career page this Actor reads carries 1,516 for the same player, in a single HTTPS GET.
- WTA as well as ATP. A WTA career is assembled the way the site's own page assembles it — the shortlist file plus the older-history file — which for Iga Swiatek is 130 + 418 = 548 rows of full history.
- Many players per run. Pass a list of slugs, paste player-page URLs, or use
a discovery token:
@elo-top:10takes the top ten slugs from the site's own Elo report (551 ranked players on the measured capture),@leaders:1-50and@wta-leaders:1-50take the site's leader lists. - Head-to-head is a filter, not a second download. The site's own head-to-head page returns a byte-identical match array to the plain career page, so this Actor filters rows it already has: Djokovic vs Nadal is 60 rows out of the 1,516 already fetched, at no extra request.
- The page's own column names drive the parse. Rows are mapped by the header array the page publishes, never by hard-coded positions, so a column the site inserts cannot silently shift your data one field to the left.
- Identity is checked, not assumed. The source answers
200for a slug it does not have and serves a different player's page — measured: requestingIgaSwiatekfrom the ATP path returned Benoit Paire's complete page. Every page is compared against the name it publishes for itself, and a mismatch is reported instead of billed. - Failures never become rows. A page that cannot be read goes to the run's
ERRORSrecord, not to your dataset, so a failure can never appear on your bill as a result. - An empty result is an answer. No players, filters that match nothing, or an input value the Actor cannot act on: the run finishes cleanly with guidance in the log instead of a red run and a stack trace.
Input
Nothing is required, and nothing is assumed: with no players the run fetches
nothing, writes no result rows and incurs no result-row charge rather than
picking a player for you; the automatic start event is charged once per run, as
Pricing says.
{"players": ["NovakDjokovic", "IgaSwiatek", "@elo-top:10"],"tour": "auto","span": "2026","surface": "Hard","result": "W","maxMatchesPerPlayer": 100,"maxPlayers": 50,"includePlayerRecord": true}
| Field | Type | Default | Description |
|---|---|---|---|
players | array | None; nothing is fetched until you set it | Player slugs (NovakDjokovic), pasted player-page URLs in either p= form, or discovery tokens (@elo-top:10, @leaders:1-50, @wta-leaders:1-50). |
tour | string | auto | Which surface family to read a player from. auto tries ATP first, then WTA, and accepts the first page whose own published name matches the slug. |
span | string | All matches | Season filter, in the site's own vocabulary: Career, Last 52, or a four-digit season. Last 52 counts back from the run clock for an active player and from the player's newest match otherwise, which is the page's own rule. It matches on the row's date, which is the tournament's start date, so a season is a tournament-start season. |
surface | string | All surfaces | Hard, Clay, Grass or Carpet. |
level | string | All levels | The site's own raw level code, matched exactly. Suggestions are open, not enforced. |
round | string | All rounds | The site's own raw round code, matched exactly. Suggestions are open, not enforced. |
tourney | string | All tournaments | Case-insensitive substring of the tournament name, so US Open keeps both US Open and ATP US Open. |
result | string | Wins and losses | W or L, the row's raw result value. |
opponent | string | All opponents | Head-to-head filter over rows already fetched, written either as a slug or as a display name. |
maxMatchesPerPlayer | integer | 100 | Matches kept per player, newest first, after the filters. 0 means every match. |
maxPlayers | integer | 50 | Players fetched per run, applied after the entries are resolved. Maximum 200; a higher value is clamped to 200 with a warning in the log rather than refused. |
includePlayerRecord | boolean | false | Add one player record row per successfully read player. |
proxyConfiguration | object | No proxy | Optional Apify Proxy settings. No group is set by default; the datacenter group (BUYPROXIES94952) is the first thing to try if a run is ever blocked. |
The platform prefill is {"players": ["NovakDjokovic"]}; read Pricing
below for the confirmed result-row charge and the automatic start event before
you run. The input this Actor's acceptance and platform runs use is checked in
at tests/input-prefill.json, and a unit test asserts it equals the input
schema's own players prefill.
Filters the Actor does not expose
The source page offers a wider filter vocabulary than the eight fields above — minimum-matches and minimum-years thresholds, sets, scores, entry and ranking bands, hand, age, height, country. They are aggregates or menu state rather than per-row predicates, so they are not shipped; ask for one on the Issues tab and it can be added as a row filter.
Output
One dataset item per match, in the order the source publishes them (newest first):
{"recordType": "match","tour": "ATP","player": "Novak Djokovic","playerSlug": "NovakDjokovic","date": "2026-06-29","tournament": "Wimbledon","surface": "Grass","level": "G","round": "SF","result": "L","score": "6-4 6-4 6-4","bestOfSets": 5,"minutes": 140,"playerRank": 8,"playerSeed": 7,"playerEntry": null,"opponent": "Jannik Sinner","opponentPlayerId": "206173","opponentRank": 1,"opponentSeed": 1,"opponentEntry": null,"opponentHand": "R","opponentBackhand": "2","opponentBirthDate": "2001-08-16","opponentHeightCm": 191,"opponentCountry": "ITA","aces": 8,"doubleFaults": 3,"servePoints": 105,"firstServesIn": 67,"firstServePointsWon": 51,"secondServePointsWon": 13,"serviceGames": 15,"breakPointsSaved": 10,"breakPointsFaced": 13,"opponentAces": 16,"opponentDoubleFaults": 0,"opponentServePoints": 79,"opponentFirstServesIn": 51,"opponentFirstServePointsWon": 45,"opponentSecondServePointsWon": 17,"opponentServiceGames": 15,"opponentBreakPointsSaved": 1,"opponentBreakPointsFaced": 1,"matchId": "2026-540-601","roundNumber": null,"matchNumber": null,"chartSlug": "20260710-M-Wimbledon-SF-Novak_Djokovic-Jannik_Sinner","chartUrl": "https://www.tennisabstract.com/charting/20260710-M-Wimbledon-SF-Novak_Djokovic-Jannik_Sinner.html","sourceUrl": "https://www.tennisabstract.com/cgi-bin/player-classic.cgi?p=NovakDjokovic","scrapedAt": "2026-09-11T04:31:07+00:00"}
With includePlayerRecord on, one extra row per player:
{"recordType": "player","tour": "ATP","player": "Novak Djokovic","playerSlug": "NovakDjokovic","currentRank": 5,"peakRank": 1,"birthDate": "1987-05-22","hand": "R","backhand": "2","country": "SRB","active": 1,"twitter": "DjokerNole","matchCount": 1516,"sourceUrl": "https://www.tennisabstract.com/cgi-bin/player-classic.cgi?p=NovakDjokovic","scrapedAt": "2026-09-11T04:31:07+00:00"}
When something goes wrong
Nothing that failed is ever written to your dataset. Instead the run's ERRORS
key-value record collects one entry per affected target, each with the same
four keys:
{"target": "IgaSwiatek","slug": "IgaSwiatek","errorClass": "wrong-player","message": "The page published its own name as 'Benoit Paire', not the requested slug."}
errorClass is one of ten values — eight about a single page, two about the
run as a whole:
errorClass | What it means |
|---|---|
no-history | The surface exists for that player and publishes no matches. |
source-error | The source's own script raised an error on that page. Reported as a fault, never as "no matches". |
unreadable-page | The site answered with a page that is not the one asked for. |
blocked | The site refused the request. |
transport | The request never completed after the built-in retry ladder. |
unresolved-name | A display name from a leaders list did not resolve to a player page. |
unsupported-target | An entry, or an input value, the Actor cannot act on. Run-level entries carry slug null. |
wrong-player | The page answered with a different player than the slug asked for, and no row was taken from it. |
charge-limit | Run-level: the run stopped at your account's spending limit with targets still unfetched. The entry names how many and which, and target and slug are both null. |
identity-unchecked | Run-level note, not a failure: a note beside delivered rows. The surface they came from publishes no player name, so they could not be checked against the slug you asked for. |
An input the Actor cannot act on — an unknown tour, a fractional cap, an
unparseable span — ends the run as a SUCCEEDED run with an empty dataset,
one unsupported-target entry in ERRORS and no run status message. If you
watch for FAILED to detect a bad input, you will not see one; check
ERRORS and the item count instead.
Pricing
Two charges, both automatic:
- Result rows — $0.005 per row written to the dataset (FREE-tier price; lower per-row prices apply on paid Apify plans).
- Actor Start — a per-run start fee (Apify's default unless the operator sets one), charged once per run. The Actor's memory ceiling is 1024 MB, and Apify charges this event once up to and including 1 GB of RAM and once more per extra GB, so the ceiling keeps it to exactly one start event per run.
The run's compute and its result writes are covered by those event prices, so platform usage is not billed on top. Pages that fail are never written to the dataset and so are never billed as rows; a run that matches nothing is charged no result rows at all. Storage retention and later reads of the dataset are billed according to your plan.
Worked examples — every row count below is one measured in a saved capture or fixed by a cap, priced at $0.005 per row:
| Job | Rows | Cost |
|---|---|---|
| Default run: one player, cap 100 | 100 | 100 × $0.005 = $0.50 |
| Recent-form slice, cap 22 | 22 | 22 × $0.005 = $0.11 |
| One head-to-head (Djokovic vs Nadal, filtered out of one fetch) | 60 | 60 × $0.005 = $0.30 |
| One full WTA career (Swiatek, 130 + 418) | 548 | 548 × $0.005 = $2.74 |
| One full ATP career (Djokovic, cap off) | 1,516 | 1,516 × $0.005 = $7.58 |
| Defaults at their ceiling: 50 players × 100 rows | 5,000 | 5,000 × $0.005 = $25.00 |
| Players cap at its maximum: 200 × 100 rows | 20,000 | 20,000 × $0.005 = $100.00 |
Two ceilings bound every run. maxPlayers (default 50, maximum 200) is applied
after discovery, so a token can never fan out past it: @elo-top:551 left
at the defaults fetches 50 players, not 551. maxMatchesPerPlayer (default
100) bounds the rows per player; setting it to 0 means a full career, which
on the largest measured career is 1,516 rows for that one player. Raise either
deliberately: with the per-player cap off, 200 players is 200 full careers.
Beyond those, your own Apify spending limit binds: when it is reached the run
stops fetching further players, writes a charge-limit entry to ERRORS
naming every target it did not fetch, and logs the same warning, so a run that
ends short always says why.
Data sources and robots
Every row comes from www.tennisabstract.com, a public statistics site, and
every request is one ordinary HTTPS GET made the way a browser rendering that
page makes it, at least 1.5 seconds apart. No login, no paywall, no API key,
and nothing but professional match statistics — no personal data about private
individuals.
Tennis Abstract publishes no terms page reachable from its home page. This
Actor reads only surfaces its robots.txt allows where one exists for the
data, and demotes and discloses the ones it disallows. The site's robots file
is 77 bytes and disallows exactly three directories under User-agent: *:
/jsfrags/, /jsmatches/ and /jsplayers/. Per family:
| Surface | Path | robots | Used for |
|---|---|---|---|
| ATP career page | cgi-bin/player-classic.cgi | allowed | Every ATP match row and the ATP bio |
| WTA header page | cgi-bin/wplayer-classic.cgi | allowed | The WTA column names |
| Elo report | /reports/ | allowed | The @elo-top:N discovery token |
| WTA recent history | jsmatches/{slug}.js | disallowed — fetched, and disclosed here | WTA rows and the WTA bio; the site serves WTA history from nowhere else |
| WTA older history | jsmatches/{slug}Career.js | disallowed — fetched, and disclosed here | The rest of a WTA career |
| Leader lists | jsmatches/leadersource*.js | disallowed — fetched, and disclosed here | The @leaders: and @wta-leaders: tokens (names only) |
Those three disallowed fetches are all under /jsmatches/. Of the three
disallowed directories, that is the only one this Actor reads: nothing under
/jsplayers/ is fetched, and /jsfrags/ is never fetched at all — it is
the page's recent-results fragment, and everything in it is already on the
allowed career page.
Limits and things worth knowing
- Raw counts, not rates. The columns are the numbers the source stores. The
page's displayed percentages are computed at render time, and this Actor does
not invent them — derive them yourself from the columns:
first-serve-in percentage is
firstServesIn / servePoints, first-serve win percentagefirstServePointsWon / firstServesIn, second-serve win percentagesecondServePointsWon / (servePoints - firstServesIn), break points savedbreakPointsSaved / breakPointsFaced, ace rateaces / servePoints, and the return-side equivalents from the matchingopponent*columns. - About one match in ten carries no statistics at all. On a full career
capture, 150 of 1,516 rows have an empty serve/return block — walkovers and
older matches the source never recorded. The whole block is
nullon those rows, together, rather than zero-filled: a zero ace count would be a false statistic.minutesis missing on rather more, 206 of 1,516. - Three columns are ATP-only.
opponentPlayerId,roundNumberandmatchNumberare filled on ATP rows and are absent from a WTA row, which is why a WTA row is 48 columns wide against an ATP row's 51: the WTA header publishes 44 column names against the ATP page's 47 plus the unnamed opponent-id column. Read them with a key lookup that tolerates a missing key, not one that expectsnull. A WTA-only run sees three empty columns in the default dataset view, because the view names them; that is the source, not a fault. - The opponent id column is unnamed by the source. The site's own header array does not name the last ATP column. It was identified as the opponent's site player id by measurement — on a full career capture it takes 394 distinct values against exactly 394 distinct opponents, with no conflicts, and the same ids appear in the site's own player-page links.
level,roundand the backhand codes are the site's own, unglossed. The page names some of them and not others:A,FandOlevels andBR,Q1,Q2,Q3rounds appear in the data with no label anywhere on the site, so they are passed through as the codes they are rather than under a meaning this Actor invented. Filter values are matched exactly, and the suggestion lists are suggestions, not a closed set.matchCounton a player row counts the rows the source served for that player, before your filters and cap. On the ATP career page, and on a WTA player whose two history files both answer, that is the career total the source published; when only one of the two WTA files answers, it is the total of the surfaces that did. Either way it is the count of rows the source served for that slug, not a count of the rows this run delivered — the rows this run delivered are the match rows in the dataset.- Doubles are not included. The measured player's doubles history file
answers
404and the career page never fills its doubles array, so there is no doubles data behind a doubles switch. It will ship if and when the source is measured serving it. - A live source fault is reported as a fault. The site's tournament CGI can
answer
200with a Python traceback in the body; that is classifiedsource-error, not silently turned into "no matches". - An identity note is not a failure. When rows come from a surface that
publishes no player name, the rows the site served are accepted and the
target gets an
identity-uncheckednote beside the rows delivered. The run's summary line counts the targets it attempted, so a target skipped by the spending limit is named in thecharge-limitentry and its warning rather than in that count. - A run started with no players returns nothing and incurs no result-row charge, by design: the field has no default, so an API call that omits it is never billed for a career it did not ask for. The automatic start event is charged once per run, as Pricing says.
- The source is one person's site. Pages are fetched at least 1.5 seconds
apart and
maxPlayersis capped at 200, deliberately.
Integrations
Works with everything on the Apify platform: scheduled runs, webhooks, the REST API, the MCP server, Zapier, Make, n8n, Slack, Google Sheets, Airbyte, and LangChain / LlamaIndex.
Support
Found a bug, need a column, or want one of the source's other filters exposed? Open an issue on the Actor's Issues tab and I'll take a look.