Tennis Abstract Data API avatar

Tennis Abstract Data API

Pricing

from $2.85 / 1,000 results

Go to Apify Store
Tennis Abstract Data API

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

Eli J

Maintained by Community

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

ColumnTypeUnit / form
recordTypestringLiteral match on a match row
tourstringATP or WTA
playerstring | nullDisplay name as the source page publishes it
playerSlugstringThe slug this run fetched
datestringTournament 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
tournamentstringTournament name as printed; current-season rows carry an ATP prefix
surfacestringHard, Clay, Grass or Carpet — the only four values in a full career capture
levelstringThe site's own raw level code, not glossed
roundstringThe site's own raw round code, not glossed
resultstringW or L, from the player's side
scorestringScore as printed, e.g. 7-6(5) 5-7 4-6 6-2 6-1
bestOfSetsinteger | nullSets (3 or 5)
minutesinteger | nullMatch duration in minutes
playerRank / playerSeed / playerEntryinteger | integer | string, nullableThe player's ranking, seed and entry code at the match, as the site writes them
opponentstringOpponent display name
opponentPlayerIdstring | nullThe site's own numeric player id for the opponent (ATP rows only; the key is absent from a WTA row)
opponentRank / opponentSeed / opponentEntryinteger | integer | string, nullableThe same three for the opponent
opponentHandstring | nullR or L
opponentBackhandstring | nullThe site's own backhand code, not glossed by the page
opponentBirthDatestring | nullDate, YYYY-MM-DD
opponentHeightCminteger | nullCentimetres (163–211 across a full career capture)
opponentCountrystring | nullThree-letter country code (SRB, ITA, ARG)
aces, doubleFaults, servePoints, firstServesIn, firstServePointsWon, secondServePointsWon, serviceGames, breakPointsSaved, breakPointsFacedinteger | nullThe 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, opponentBreakPointsFacedinteger | nullThe same nine counts from the opponent's serve — i.e. the player's return side
matchIdstringThe site's own id, <year>-<eventId>-<matchNum>
roundNumber / matchNumberinteger | nullThe site's round and match indexes (ATP rows only; the keys are absent from a WTA row)
chartSlugstring | nullPoint-by-point charting slug, where the match is charted
chartUrlstring | nullThe charting page URL built from that slug
sourceUrlstringThe exact URL this row was read from
scrapedAtstringISO-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:

ColumnTypeUnit / form
recordTypestringLiteral player on a bio row
tourstringATP or WTA
playerstring | nullDisplay name
playerSlugstringThe slug this run fetched
currentRankinteger | nullCurrent ranking the source publishes
peakRankinteger | nullCareer-high ranking the source publishes
birthDatestring | nullDate, YYYY-MM-DD
handstring | nullR or L
backhandstring | nullThe site's own backhand code, not glossed
countrystring | nullThree-letter country code
activeinteger | null1 active, 0 retired, as the source writes it
twitterstring | nullHandle, without the @
matchCountinteger | nullThe 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
sourceUrlstringThe exact URL the bio was read from
scrapedAtstringISO-8601 UTC, to the second

Why this Actor

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

Input

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

{
"players": ["NovakDjokovic", "IgaSwiatek", "@elo-top:10"],
"tour": "auto",
"span": "2026",
"surface": "Hard",
"result": "W",
"maxMatchesPerPlayer": 100,
"maxPlayers": 50,
"includePlayerRecord": true
}
FieldTypeDefaultDescription
playersarrayNone; nothing is fetched until you set itPlayer slugs (NovakDjokovic), pasted player-page URLs in either p= form, or discovery tokens (@elo-top:10, @leaders:1-50, @wta-leaders:1-50).
tourstringautoWhich 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.
spanstringAll matchesSeason 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.
surfacestringAll surfacesHard, Clay, Grass or Carpet.
levelstringAll levelsThe site's own raw level code, matched exactly. Suggestions are open, not enforced.
roundstringAll roundsThe site's own raw round code, matched exactly. Suggestions are open, not enforced.
tourneystringAll tournamentsCase-insensitive substring of the tournament name, so US Open keeps both US Open and ATP US Open.
resultstringWins and lossesW or L, the row's raw result value.
opponentstringAll opponentsHead-to-head filter over rows already fetched, written either as a slug or as a display name.
maxMatchesPerPlayerinteger100Matches kept per player, newest first, after the filters. 0 means every match.
maxPlayersinteger50Players 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.
includePlayerRecordbooleanfalseAdd one player record row per successfully read player.
proxyConfigurationobjectNo proxyOptional 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:

errorClassWhat it means
no-historyThe surface exists for that player and publishes no matches.
source-errorThe source's own script raised an error on that page. Reported as a fault, never as "no matches".
unreadable-pageThe site answered with a page that is not the one asked for.
blockedThe site refused the request.
transportThe request never completed after the built-in retry ladder.
unresolved-nameA display name from a leaders list did not resolve to a player page.
unsupported-targetAn entry, or an input value, the Actor cannot act on. Run-level entries carry slug null.
wrong-playerThe page answered with a different player than the slug asked for, and no row was taken from it.
charge-limitRun-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-uncheckedRun-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:

JobRowsCost
Default run: one player, cap 100100100 × $0.005 = $0.50
Recent-form slice, cap 222222 × $0.005 = $0.11
One head-to-head (Djokovic vs Nadal, filtered out of one fetch)6060 × $0.005 = $0.30
One full WTA career (Swiatek, 130 + 418)548548 × $0.005 = $2.74
One full ATP career (Djokovic, cap off)1,5161,516 × $0.005 = $7.58
Defaults at their ceiling: 50 players × 100 rows5,0005,000 × $0.005 = $25.00
Players cap at its maximum: 200 × 100 rows20,00020,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:

SurfacePathrobotsUsed for
ATP career pagecgi-bin/player-classic.cgiallowedEvery ATP match row and the ATP bio
WTA header pagecgi-bin/wplayer-classic.cgiallowedThe WTA column names
Elo report/reports/allowedThe @elo-top:N discovery token
WTA recent historyjsmatches/{slug}.jsdisallowed — fetched, and disclosed hereWTA rows and the WTA bio; the site serves WTA history from nowhere else
WTA older historyjsmatches/{slug}Career.jsdisallowed — fetched, and disclosed hereThe rest of a WTA career
Leader listsjsmatches/leadersource*.jsdisallowed — fetched, and disclosed hereThe @leaders: and @wta-leaders: tokens (names only)

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

Limits and things worth knowing

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

Integrations

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

Support

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