Tennis Data API — ATP, WTA & ITF Results, Stats, H2H & Rankings

Design notes for a cross-tour tennis index — today's live ATP and WTA singles board, completed match results, re-aggregatable serve and return statistics, head-to-head records, ranking snapshots and a revision feed, with the coverage reported on every run instead of promised on a page.

Design notes for tennis-data-index, an Apify Actor that reports the ATP and WTA singles matches on court right now and answers queries about completed ATP, WTA and ITF singles matches, from one cross-tour index. The live board is the zero-config default — an empty input returns today’s matches, with ITF opt-in — and completed-match history, head-to-head records, player statistics, rankings, the change feed and name lookup sit behind the same row contract. I built it for analytical pipelines and agents that need a repeatable query rather than another page to scrape: one contract for results, identities, statistics, rankings and corrections, with the limits stated in the output of every run.

What this is

Today’s ATP and WTA singles board, read on demand from the tours’ own published boards, sitting on top of an index of completed singles matches that is refreshed several times a day and queried by player, tournament and date window, narrowed further by tour, round, draw stage and match status. Seven query modes share it:

  • live — the default — returns today’s ATP and WTA singles board: the set score, the game in progress, the server, the court and the round, for matches scheduled, on court and already finished today. It needs no IDs and no dates, ITF is opt-in, and rows update within about a minute of the tours publishing them.
  • lookup turns a player or tournament name into the index ID the other modes take; every other mode also accepts an exact name wherever it accepts an ID.
  • matches returns match rows — date, tournament edition, draw, round, winner and loser, score, and each side’s serve and return statistics where the tour published them.
  • h2h returns the indexed meetings between two players plus a summary of the full filtered set: the record, wins for each side, and a by-surface split.
  • player_stats returns one player’s aggregate — raw serve and return sums plus derived ratios — optionally restricted to opponents inside a ranking band.
  • rankings returns a tour’s indexed ranking snapshot for a date, optionally cut off at a position.
  • changes returns revisions after an integer checkpoint, including removals and merges, so a local analytical copy can stay in step.

All modes write one dataset using a superset row shape discriminated by record_type; fields that don’t apply to a row are null rather than absent. The requested query stays visible in mode. The OUTPUT key-value record carries delivery counts, the coverage the run actually ran against, the index build timestamp, and continuation details when a query was truncated.

The boundary is deliberate and it is the first thing to check for fit: no odds, no point-by-point, no doubles, and no universal statistics guarantee. live is a board, not a point feed — it reports the state of each match (sets, the game in progress, the server, the court, the round, and whether the match is scheduled, on court or already finished today), never every point as it is played. Match queries default to the completed and retired statuses, and player aggregates require that pair.

Coverage today

Coverage differs by tour and is still being backfilled, so it belongs in a table with a date on it rather than in an adjective. This is the state of the index as of September 2026:

Tour Indexed seasons Per-match statistics Notes
ATP 2020 through 2022 complete; 2023 through 2025 currently hold that year’s US Open only; 2026 indexed from September Yes for 2020–2022 and for the 2026 rows The rest of 2023–2025 is arriving as the archive backfill runs
WTA 2020 through 2026, continuous Not currently published into the index Main draw and qualifying where the source files them
ITF 2020 through 2025 continuous; 2026 indexed from September Not currently published into the index The largest layer by row count, by a wide margin
Rankings ATP snapshots from 2026-08-31, WTA from a week later Not applicable New snapshots accumulate as each table publishes

Two qualifications that matter more than the counts. Challenger events are filed under ATP because the archive publishes no level to separate them on, so asking for Challenger returns ATP main tour as well and the run says so in a free notice. And surface labels are not yet carried on match rows, so a surface filter currently matches nothing and the by-surface split inside a head-to-head summary stays empty — the filter is in the contract ahead of the data, and the run reports that rather than returning a wrong number.

Coverage being enumerable is the point. A run reports the exact coverage it queried, and a request naming data the index doesn’t hold yet comes back as a free notice naming the covered span instead of an empty list that looks like an answer.

Why I built it this way

The live board reads the tours, and stays a board

Live rows are read on demand from the tours’ own published boards and from nowhere else. A second-hand scoreboard adds a hop that can lag or disagree without saying which; reading what the tour itself publishes keeps a row about as fresh as the board — within about a minute — and keeps attribution answerable.

It is a board and not a point feed on purpose. A point stream is a different product with a different failure mode: a held-open connection, a backlog to replay after a drop, and a cost that runs whether or not anyone is reading. A board answers the question a query-shaped caller actually asks — what is the state of this match right now — in one call.

ITF is opt-in rather than on by default because its daily volume dwarfs the two main tours together, and a default board should be readable without a filter.

Live rows carry no data charge because the board is the on-ramp to everything else here, not a product beside it: the row hands back the same identifiers the history modes take, so looking at what is on court leads straight into a history query.

Keep the denominator, not just the percentage

A short match and a long match contribute different numbers of service points. Averaging their first-serve percentages gives each match equal weight, which answers a different question from “what share of all serves went in?” Once the denominators are discarded, a caller cannot repair that distinction.

So per-match statistics are raw sums, never percentages. Player aggregates retain those sums alongside the derived ratios: add first_in across disjoint samples, add serve_points, then divide. Don’t average the displayed ratios, and don’t combine overlapping samples. This small output decision determines whether a season analysis can be rebuilt correctly from tournament-sized pulls.

The statistics sample is also explicit. matches_count counts the result sample; matches_with_stats counts the matches inside it that carried usable statistics. They are not interchangeable, and today’s coverage table is exactly why: a WTA aggregate can have a full result sample and an empty statistics sample. A match side with no usable statistics carries stats: null and a stats_reasonsource_missing, zero_serve_points, zero_service_games, inconsistent_serve_counts or unfetchable — not a bag of zeros. Zero aces describes performance; missing statistics describe what we know. Those two facts should never enter a model as the same observation.

Identity is a recorded decision

Names are useful display fields and poor join keys. Spelling variants, incomplete names and collisions are enough to make a plausible-looking join wrong. Stable, source-namespaced player and match identifiers carry the joins instead, and they stay stable across refreshes.

Each match-side identity includes resolution: source, crosswalk, wikidata or unresolved. Those are contract values recording how the identity was established, not permission to infer one from a name. An unresolved player still has an identifier; the missing identity fields stay null. If two identities are later merged, redirect_to names the survivor while the old identifier keeps answering, so a caller follows an explicit correction instead of silently relabeling history.

Live rows obey the same rule. Each side’s identifier is built from the identifier the tour itself publishes, so a match on court joins to that player’s and that tournament’s history by ID, never by name. A player the index does not hold yet arrives with a null player_id and resolution: "unresolved" — the name is still delivered, and no extra row is raised to complain about it.

An empty answer needs a reason

“Not indexed yet” and “nothing matched” require different next steps, and an agent cannot tell them apart from an empty array. out_of_coverage is a free notice for a request outside the indexed span — worth repeating after a backfill. no_match is a free notice for a query inside coverage that finds no rows — not worth repeating unchanged. Neither is an instruction to hammer the same query immediately.

The run summary reports the coverage used to draw that line. It is not a guarantee that every event or statistics field exists throughout the reported span. If the coverage read itself fails, coverage is null and empty queries fall back to the plain no_match notice; that is not proof of complete coverage, and delivery and billing are unaffected either way. Inside coverage, an unknown player and a player with no qualifying matches are indistinguishable from here — which is why a zero-match aggregate is never charged.

Source and input problems never turn the run into a failure: the Actor delivers a labeled notice and exits successfully. Success describes execution, not completeness. Route on error_class, inspect partial data, and read OUTPUT before deciding a query is done. An unknown input key produces a free correction record, not a bill for a failed attempt.

Corrections belong in the contract

A local analytical copy needs more than new results. It needs corrections to old ones, and a way to remove duplicates without losing what they were connected to. The changes feed supplies ordered revisions with an op; tombstones identify removed matches, and merged_into names the surviving match when a removal represents a merge rather than a deletion.

Apply those revisions in order rather than appending each one as another match, and repoint at the surviving identifier when a merge is reported — otherwise that history quietly disappears from your copy. The feed is unfiltered and resumes from an integer checkpoint, so maintaining a tournament-specific subset locally is the caller’s job. That is a narrower promise than arbitrary change queries, and it is enough to keep an analytical copy in step without re-pulling the whole history.

Report coverage per run, don’t advertise it

An index that is actively backfilling will outrun any coverage claim written into documentation, in both directions: the page goes stale when a season lands, and it lies when a source stalls. So the authoritative coverage statement is the one the run itself emits — per tour, with first and last dates, per-year match counts, the statuses those counts include, and the ranking snapshot spans. The table above is a snapshot for humans deciding whether to try it; OUTPUT is what a pipeline should branch on.

How to use it

The default takes no input at all. An empty input — or the same thing said explicitly — returns today’s ATP and WTA board:

{"mode": "live"}

A trimmed live row looks like this. The numbers illustrate the contract, not a verified extract; unrelated null fields are omitted:

{
  "record_type": "live_match",
  "mode": "live",
  "tour": "atp",
  "level": "challenger",
  "tournament": "Phan Thiet",
  "tournament_id": "atp:3139",
  "edition_id": "atp:3139:2026",
  "round": "F",
  "draw": "MS",
  "stage": "main",
  "player_a": {"name": "Rodrigo Pacheco Mendez", "player_id": "atp:p0j1", "country": "MEX", "seed": null, "resolution": "source"},
  "player_b": {"name": "Ilia Simakin", "player_id": "atp:s0o6", "country": "RUS", "seed": 4, "resolution": "source"},
  "sets": [{"a": 6, "b": 3, "tiebreak": null}],
  "score": "6-3 3-1",
  "game_score": {"a": "40", "b": "15"},
  "server": "a",
  "status": "live",
  "status_detail": null,
  "court": "Center Court",
  "fetched_at": "2026-09-20T03:18:04Z",
  "stale": false
}

A match in progress has no winner, so the two sides are player_a and player_b rather than winner and loser. tournament_id is the identifier tournamentId takes in the other modes. game_score, server, court and scheduled_start are null whenever a board does not publish them — a guessed timestamp is never emitted in their place — and stale: true marks a row served from the last good read of its board instead of dropped. A tour with nothing on court is a quiet day, not a fault: sources in OUTPUT carries each tour’s status and match count, and only a tour that failed to answer adds a notice.

Ask for the indexed US Open men’s singles semifinals and final in a bounded window:

{
  "mode": "matches",
  "tour": "atp",
  "tournamentId": "atp:560",
  "dateFrom": "2025-08-25",
  "dateTo": "2025-09-07",
  "rounds": ["SF", "F"],
  "limit": 50
}

The same query through the Apify Python SDK, reading both the rows and the run summary:

import os
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("shelvick/tennis-data-index").call(run_input={
    "mode": "matches", "tour": "atp", "tournamentId": "atp:560",
    "dateFrom": "2025-08-25", "dateTo": "2025-09-07",
    "rounds": ["SF", "F"], "limit": 50,
})
for row in client.dataset(run["defaultDatasetId"]).iterate_items():
    if row["record_type"] == "notice":
        print(row["error_class"], row["notice"])
    else:
        print(row["date"], row["round"], row["winner"]["name"], row["score"])

summary = client.key_value_store(run["defaultKeyValueStoreId"]).get_record("OUTPUT")
print(summary["value"]["coverage"])

Or through REST, synchronously, for a bounded pull:

curl -X POST 'https://api.apify.com/v2/acts/shelvick~tennis-data-index/run-sync-get-dataset-items' \
  -H "Authorization: Bearer $APIFY_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"mode":"rankings","tour":"atp","rankMax":100,"limit":100}'

An abbreviated player-statistics row has this shape. The numbers illustrate the contract, not a verified extract; unrelated null fields and other statistical components are omitted:

{
  "record_type": "player_stats",
  "mode": "player_stats",
  "matches_count": 4,
  "matches_with_stats": 3,
  "wins": 3,
  "losses": 1,
  "stats": {"serve_points": 200, "first_in": 120, "first_won": 90},
  "derived": {"first_serve_pct": 0.6, "first_won_pct": 0.75, "win_pct": 0.75},
  "stats_reason": null
}

For truncated runs, read resume_field and resume_cursor from OUTPUT, keep the original mode and filters, and preserve an opaque cursor exactly as issued. If both continuation fields are null after a first-page stop, there is nothing to continue from — raise the run’s maximum charge and repeat the input rather than inventing a token. Partial-page replay can occur, so merge by the stable identifiers and handle repeated head-to-head summaries separately.

From an MCP-enabled agent, expose the Actor as a tool and instruct it to branch on record_type first, then on error_class. The per-field input descriptions carry the mode requirements, so a model can construct a valid call without a worked example — and should treat a successful run as executed, not as complete coverage.

How it compares

Approach Typical fit Integration boundary
Single-source scrapers One tour or one tournament workflow You own format changes and identity reconciliation as soon as a second source is added.
Static historical datasets A fixed, reproducible research sample New results and historical corrections need a separate update process.
Subscription sports-data feeds Contracted breadth, or live monitoring Available history, permitted uses and statistics depth follow the agreement.
Tennis Data API Today’s board plus repeat completed-match analysis across tours One row contract for both, a board read on demand, several daily refreshes and a revision feed; coverage is expanding and statistics gaps are explicit.

A fixed historical sample may be all a study needs. Monitoring at board grain is served here: what is on court, the set score, the game in progress and the server, updated within about a minute of the tours publishing it. Per-point feeds and betting odds remain out, so sequence analysis and markets still need their own sources. This Actor is for everything between: today’s matches and repeat queries over completed results across tours, without taking ownership of several changing formats — and with the gaps legible instead of inferred.

Pricing model

Pay per delivered result, with push-then-charge: every row is written to the dataset before its charge is created, so a delivery that stops halfway bills only what arrived. Match rows move to a cheaper volume rate after a per-run threshold, which is what makes a season-sized pull viable rather than a series of small ones. Head-to-head summaries and player aggregates are charged as aggregates, and ranking rows have their own rate.

What is never charged: live board rows, lookup rows, notices, input corrections, the coverage read, failed runs, and an aggregate covering zero matches. That last exemption is deliberate — inside coverage the index cannot distinguish a mistyped player from a player with no qualifying matches, so a typo must not become a paid empty answer. The run’s maximum-charge cap stops delivery cleanly and reports continuation details in OUTPUT instead of truncating silently.

The Apify Store Pricing tab is authoritative for current rates, thresholds and any subscriber discounts.

Open questions / future work

  • Archive depth. The ATP backfill is the moving front: 2020 through 2022 are complete, 2023 through 2025 currently hold their US Open editions while the rest arrives season by season. The useful question is which missing seasons callers actually need, not how large a coverage claim fits in a title.
  • Retired and suspended on the live board. Both are in the live status vocabulary, but neither was observed on any tour board while the mode was being built, so the markers behind them are unverified. An unfamiliar marker is preserved verbatim in status_detail rather than forced into the vocabulary — which keeps the row honest but leaves the mapping to confirm against a real retirement.
  • ITF live identifiers. ITF live rows currently carry no player_id: the identifiers on that board have not been confirmed to belong to the same ID space the index stores, and matching by name is exactly the shortcut the rest of the contract refuses. Names arrive as published, the identifier stays null with resolution: "unresolved", and the join becomes available once the ID space is confirmed rather than guessed.
  • Per-row live pricing. Live rows carry no data charge today because the board is an on-ramp to the history modes. That trade assumes callers check a board and then query; a caller polling it in a tight loop is a different shape, and if that use appears, per-row live pricing is the question rather than a rate limit.
  • Statistics completeness. More result history does not automatically bring more statistics — ATP publishes per-match serve and return data across the archive; the WTA and ITF layers largely do not. Deepening the usable statistical sample without hiding its denominator, or inventing fields a source never published, is separate work from adding results.
  • Surfaces. The surface filter and the by-surface head-to-head split exist in the contract but have no labels behind them yet. Populating surfaces is worth doing precisely because a surface split is one of the questions people actually ask of head-to-head data.
  • Tour separation. Challenger events share the men’s-tour grouping because the archive publishes no level to split on. Separating them should improve the query, not silently change what an existing label means.
  • Doubles. Out of scope for now. Doubles would need its own identity and statistics handling rather than a flag on the singles contract, and I would rather ship singles honestly than both thinly.