Tennis Scraper API: ATP WTA ITF Results, Stats, Rankings, H2H avatar

Tennis Scraper API: ATP WTA ITF Results, Stats, Rankings, H2H

Pricing

from $2.55 / 1,000 match record (standard)s

Go to Apify Store
Tennis Scraper API: ATP WTA ITF Results, Stats, Rankings, H2H

Tennis Scraper API: ATP WTA ITF Results, Stats, Rankings, H2H

Tennis data API for completed ATP, WTA and ITF singles matches. Query tennis results by tournament, match stats, head-to-head records, player stats and tennis rankings. Hourly updates, stable player IDs and a change feed. Coverage varies by tour and is expanding; no live scores.

Pricing

from $2.55 / 1,000 match record (standard)s

Rating

0.0

(0)

Developer

Scott Helvick

Scott Helvick

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

15 hours ago

Last modified

Share

A tennis data API for completed ATP, WTA and ITF singles matches: tennis results, match statistics, head-to-head records, player stats and tennis rankings from one index, with hourly updates. Comparing tennis results often means joining separate match histories, rankings and statistics with incompatible identifiers. This Actor returns completed singles matches, head-to-head records, player statistics, ranking tables and incremental changes from one cross-tour index, refreshed hourly.

What this does

  • Match results: matches filters rows by dates, tour, tournament, surface, round, stage and status. Rows carry player names alongside stable IDs, and per-side match statistics where the source published them. Suitable for tournament analysis or a season results panel.
  • Head-to-head: h2h returns every indexed meeting between two player IDs within the filters, plus one summary row containing the record, wins for each player and a by-surface split.
  • Player statistics: player_stats returns one aggregate of raw serve/return sums and derived percentages, optionally restricted by opponent-ranking band. Only completed and retired matches count.
  • Rankings: rankings returns a tour's indexed ranking table for a date, optionally bounded by ranking position.
  • Incremental updates: changes returns revisions after an integer checkpoint, including tombstones for merged-away rows, so a caller can keep a local analytical copy in step.

Coverage expands continuously as historical seasons are backfilled; depth differs by tour. Every run reports the exact coverage it ran against in its summary. A request outside current coverage returns a free out_of_coverage notice naming the covered span rather than a silent empty result. Coverage is not a promise that every event or statistics field exists throughout that span.

Coverage is queryable through the run summary, so an agent can distinguish "no data yet" from "no such match": requests outside coverage receive out_of_coverage, while queries inside coverage that match nothing receive no_match. Neither notice is charged.

All modes write one dataset using a superset row shape selected by record_type. Rows stream page by page; data charges happen only after delivery. The OUTPUT key-value record describes counts, freshness and continuation.

Why re-aggregatable statistics matter

Averaging percentages weights short and long matches equally. Raw sums let callers combine disjoint samples before calculating percentages.

The Actor derives percentages from raw sums rather than averaging percentages. matches_count and matches_with_stats distinguish the result sample from the statistics sample. Missing statistics are not zero performance: availability varies by tour and era, and a match side without usable statistics carries stats: null and a stats_reason explaining why.

How it compares to separate tennis data feeds

ApproachTypical fitIntegration boundary
Single-source scrapersOne tour or tournamentCaller reconciles identifiers and output formats across sources.
Static historical datasetsFixed research samplesFresh results and corrections require another update process.
Subscription sports-data APIsContracted coverage, possibly live dataAvailable history, statistics and redistribution rights depend on the product.
This ActorCompleted-match analysis across toursOne row contract, hourly refresh, revision feed; coverage and statistics remain source-dependent.

This is a completed-results query tool, not a substitute for a live sports feed.

Input

Dates use YYYY-MM-DD. A dash means no schema default; examples shown in the Store form are prefills, not additional defaults.

FieldTypeRequiredDefaultDescription
modestringNomatchesmatches, h2h, player_stats, rankings or changes.
tourstringNo-atp, wta, challenger, itf; unfiltered for match history when omitted. Rankings defaults to ATP.
dateFromstringNo-Inclusive match-date lower bound.
dateTostringNo-Inclusive upper bound; not before dateFrom.
tournamentIdstringNo-Source-prefixed tournament ID, such as atp:540; combine with dates for an edition.
surfacestringNo-Stored label, matched exactly and case-sensitively; values are lowercase, such as grass, hard or clay.
roundsarray of stringsNo-R128, R64, R32, R16, QF, SF, F, RR, Q1, Q2, Q3; empty means unfiltered.
stagestringNo-main or qual; omission includes either where indexed.
statusesarray of stringsNo["completed","retired"]Status filter; statistics mode requires this default pair.
playerAstringFor h2h-First player ID, such as atp:s0ag.
playerBstringFor h2h-Different second player ID, such as atp:a0e2.
playerstringFor player_stats-One player ID, such as wta:326408.
opponentRankMinintegerNo-Positive minimum numeric opponent rank; statistics only.
opponentRankMaxintegerNo-Positive maximum numeric opponent rank; not below the minimum.
rankingDatestringNo-Indexed ranking snapshot date, not a match-date filter.
rankMaxintegerNo-Top N: 100 returns positions 1 to 100. Not a row budget.
sinceintegerNo-Nonnegative revision checkpoint for changes; omission starts from zero.
cursorstringNo-Opaque continuation token; copy without editing. Rejected in changes mode, which resumes from since.
limitintegerNo5000Data-row cap, from 1 to 100000; the run's maximum charge may stop delivery sooner. A six-figure pull can take most of the run's time budget.

The status vocabulary also accepts walkover, default, bye, scheduled, in_progress and cancelled. These labels do not imply live coverage; this index covers completed matches.

Challenger events are indexed under ATP and cannot currently be filtered apart from ATP main tour. Requesting challenger returns both, with a free not_separable notice.

Per-mode requirements and ignored fields

  • matches: no required selectors. Uses match-history filters and pagination; ignores player selectors, ranking selectors and since.
  • h2h: requires distinct playerA and playerB. Uses match-history filters and pagination; ignores player, ranking selectors and since.
  • player_stats: requires player. Uses match-history filters and optional opponent-rank bounds; statuses must remain completed plus retired. Ignores playerA, playerB, ranking selectors and since.
  • rankings: uses tour, rankingDate, rankMax and pagination. Ignores match-history filters, player selectors and since.
  • changes: uses since and limit; ignores tour, match-history, player and ranking selectors, and REJECTS cursor — the revision feed resumes from since. It is unfiltered.

Opponent-ranking bounds outside player_stats are rejected, not ignored. Ignored fields still undergo input validation. The schema is strict: an unknown key produces a free correction record, never a failed run.

Output

One dataset contains five record_type variants. These illustrative JSON excerpts omit unrelated null fields; numbers illustrate the contract, not a verified historical extract. mode always identifies the requested query.

Match

{
"record_type": "match", "mode": "matches",
"match_id": "atp:540:2025:MS:ms001", "edition_id": "atp:540:2025",
"tour": "atp", "year": 2025, "draw": "MS", "stage": "main",
"round": "F", "date": "2025-07-13", "status": "completed",
"winner_id": "atp:s0ag", "loser_id": "atp:a0e2",
"winner": {
"player_id": "atp:s0ag", "name": "Jannik Sinner", "country": "ITA",
"qid": null, "resolution": "source", "redirect_to": null,
"stats": {
"aces": 8, "double_faults": 2,
"serve_points": 120, "first_in": 75, "first_won": 60,
"second_won": 25, "second_points": 45,
"bp_saved": 4, "bp_faced": 6, "service_games": 20,
"return_points": 130, "first_return_won": 25, "first_return_points": 80,
"second_return_won": 25, "second_return_points": 50,
"bp_converted": 4, "bp_opportunities": 9, "return_games": 20,
"total_points_won": 135, "total_points": 250
},
"stats_reason": null
},
"loser": {
"player_id": "atp:a0e2", "name": "Carlos Alcaraz", "country": "ESP",
"qid": null, "resolution": "source", "redirect_to": null,
"stats": null, "stats_reason": "source_missing"
},
"score": "4-6 6-4 6-4 6-4", "sets": null,
"source": "atp", "source_url": null,
"db_built_at": "2026-09-10T12:00:00Z"
}

winner and loser accompany match rows in matches, h2h and match data in changes. Each side has an always-present, source-namespaced player_id; name and country can be null when identity is unresolved. qid is a Wikidata Q-number or null. resolution records how identity was established: source, crosswalk, wikidata or unresolved. It distinguishes resolved identities from stored unresolved answers, never guesses from names. redirect_to, normally null, identifies the surviving player ID after a merge; the merged-away ID keeps answering so clients can follow it. stats holds that player's per-match raw sums, never percentages, so callers can re-aggregate before dividing. Null means unusable statistics, never zero play; stats_reason explains absence with source_missing, zero_serve_points, zero_service_games, inconsistent_serve_counts or unfetchable, and is null when statistics are present.

match_id is the stable match key, <tour>:<tournament>:<year>:<draw>:<source key>; edition_id is its first three segments and groups one running of a tournament. Merged-away match rows are excluded from listings. draw is MS/WS for singles and MQ/WQ for the qualifying draws. draw, stage, round and numeric round_seq describe draw placement. date, optional completed_at, and duration_s describe timing. score preserves source notation; sets is structured when available, with source-dependent layout.

winner_seed/loser_seed are tournament seeds, winner_rank/loser_rank are associated rankings, and winner_entry/loser_entry hold entry designations. conflict flags a conflicting record when known. Nullable values must not be treated as zero.

source provides attribution, source_url the verification reference when available, fetched_at the retrieval timestamp, and db_built_at the index snapshot the row's page was served from — a run that spans an hourly refresh can carry more than one value. These fields may be null; aggregates and notices do not represent individual source matches.

In changes, match-shaped rows also carry integer revision and operation op. An upsert supplies match data; a tombstone identifies a removed match and may leave other fields null. When a tombstone arrives because two sources were merged into one match, merged_into names the surviving match id — repoint a local row at it rather than simply deleting the row, or the history disappears from your copy. Merged matches are filtered out of matches and h2h, so merged_into is null there. Apply revisions in order rather than appending every change as a new match.

Head-to-head summary

{
"record_type": "h2h_summary", "mode": "h2h", "matches_count": 2,
"summary": {
"player_a": "atp:s0ag", "player_b": "atp:a0e2",
"matches": 2, "wins_a": 1, "wins_b": 1,
"by_surface": {"grass": {"matches": 2, "wins_a": 1, "wins_b": 1}}
}
}

summary covers the full filtered indexed set, not just the delivered page. One summary is delivered per run, subject to budget, alongside the meeting rows. matches_count repeats its match count.

Player statistics

{
"record_type": "player_stats", "mode": "player_stats",
"player_id": "atp:s0ag", "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
}

Top-level stats and stats_reason describe the cohort aggregate on player_stats rows only; per-match statistics live inside winner and loser. The abbreviated stats object contains raw sums, including aces, double faults, serve points, first serves in/won, second serves won/points, break points saved/faced, service games, return points, first/second return points won and played, break points converted/opportunities, return games and total points won/played. derived contains ratios calculated from sums; for example first_serve_pct is first_in / serve_points.

wins and losses describe the filtered result sample; matches_with_stats measures statistics completeness. A matches_count of zero arrives free, with a no_match notice for queries inside coverage or out_of_coverage for requests naming data not yet indexed. Within coverage, an unknown player ID and a player with no qualifying matches look identical from here. Missing aggregate components remain null. Statistics availability varies by tour and era; WTA statistics are not promised.

Ranking

{
"record_type": "ranking", "mode": "rankings", "tour": "atp",
"rank_date": "2025-07-14", "rank": 1, "player_id": "atp:s0ag",
"points": 12030, "tournaments_played": 18,
"source": "atp", "source_url": null
}

rank_date selects the snapshot; rank is position, not points. points and tournaments_played are source-reported and nullable.

Notice

{
"record_type": "notice", "mode": "matches", "error_class": "row_limit",
"notice": "This run reached the `limit` you set before delivering every matching row. Everything above is complete. To continue, start another run with the resume cursor from this run's summary record, or raise `limit`."
}

notice explains a correction, missing result or early stop. Route on error_class, not prose. Notices are free.

Run summary and continuation

The OUTPUT key-value record contains mode, delivered, charged, charged_by_event, notices, pages, truncated, resume_cursor, resume_field, stopped_by, db_built_at and coverage. Delivered and charged counts describe data rows; notices are counted separately. Event counts are not monetary totals.

coverage reports each tour's first_date, last_date, total match count and years map of year to match count, plus ranking-snapshot spans and the snapshot's db_built_at. It also carries statuses — which match statuses those counts include — and, where the index qualifies how a tour is filed, a note. Coverage is read once per run, free of charge. If that read fails, coverage is null and empty queries use the plain no_match notice; delivery and billing are unaffected.

When truncated, copy resume_cursor into the input named by resume_field. For cursor, preserve the opaque string. For since, convert a numeric-string checkpoint to an integer. Retain the original mode and filters. A missing continuation value is not permission to invent a token: resume_cursor and resume_field are both null when the run stopped inside its first page, which means there is nothing to continue from — raise the run's maximum charge and run the same input again. Page-boundary replay can occur after a partial page: merge by stable IDs, and handle repeated h2h summaries separately.

Example

Request 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 input through REST:

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":"matches","tour":"atp","tournamentId":"atp:560","dateFrom":"2025-08-25","dateTo":"2025-09-07","rounds":["SF","F"],"limit":50}'

Python, including the summary needed for continuation:

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():
print(row)
summary = client.key_value_store(run["defaultKeyValueStoreId"]).get_record("OUTPUT")
print(summary["value"])

Calling from an AI agent

  • Apify MCP server: expose shelvick/tennis-data-index as a tool. Select a mode, construct schema-valid inputs, and inspect notices before treating a successful run as complete coverage.
  • Apify SDK (Python): the apify_client example returns dataset rows and the run summary. An orchestrator can route notices and persist continuation checkpoints.
  • REST API: use the synchronous endpoint above for bounded queries. For larger pulls, use POST /v2/acts/shelvick~tennis-data-index/runs, poll GET /v2/actor-runs/{runId}, then read the default dataset and OUTPUT from the default key-value store.

Pricing

Pay-per-event: match rows use match-record, with the cheaper match-record-bulk volume rate after a threshold within the same run. H2h summaries and player aggregates use aggregate-row; ranking records use ranking-row. An aggregate covering zero matches is delivered free — the index cannot tell an unknown player ID from a player who never played, so a mistyped ID is never billed.

Data charging occurs only after records are pushed. Notices, input corrections and failures are always free; previously delivered data remains billable if a later page fails. The maximum-charge cap stops delivery cleanly and reports continuation details in OUTPUT. The platform also defines an apify-actor-start event.

See the Pricing tab on this Store page for the current per-event rates and any active subscriber discounts.

Behavior

Source or configuration problems do not end the run as FAILED: the Actor delivers a labeled notice and exits successfully. Success describes execution, not guaranteed coverage.

error_classMeaning and routing
source_errorTemporary source failure; the only class to retry immediately, allowing a few minutes between attempts.
no_matchValid filters matched no indexed rows; reconsider scope or IDs.
out_of_coverageThe requested date range, season or tour names data the index does not hold yet; the free notice names the covered span. Unlike other terminal classes, this is worth repeating later as coverage expands.
invalid_inputCorrect the input using the notice.
not_configuredRun configuration unavailable; no lookup or data charge.
invalid_requestRequest rejected; inspect and correct it.
not_separableChallenger-only filtering unavailable; results include ATP main tour.
budget_exhaustedMaximum charge reached; resume with a suitable budget.
row_limitData-row limit reached; resume or raise the limit.
internal_errorA fault stopped delivery; inspect the partial output.

Every class except source_error is terminal for that input, not an automatic immediate retry instruction. out_of_coverage is worth retrying later as data is added, not immediately. Notices may accompany usable data.

Latency scales with page count: a one-page query is the shortest path; multi-page histories require sequential retrieval and delivery per page. Allow startup time plus page processing, and use asynchronous calls for broad pulls. No measured wall-clock guarantee is offered. Hourly refresh does not guarantee a source publishes every completed result immediately.

Telemetry: to improve coverage and reliability, this Actor reports anonymous usage metrics and diagnostic events to the developer — mode, delivered and charged counts, page counts, notice counts, delivered-field completeness counts and stop reasons (counts and enum keys only), and, only when something goes wrong, the relevant input fields. No account identifiers are collected, and telemetry never affects a run.

FAQ

Can percentages be combined across runs? Combine raw sums from disjoint samples, then recalculate. Averaging percentages or combining overlapping samples changes the denominator incorrectly.

Does a limit shorten the h2h record? The summary covers the full filtered set, while the delivered meeting rows can be truncated. Check OUTPUT before assuming all meetings arrived.

Can changes be restricted to one tournament? Not currently. Consume the revision feed and maintain any desired analytical subset locally, including removals.

Source and acceptable use

The records are normalized facts drawn from official tour and tournament sources. Match and ranking records carry source attribution and a per-record source reference where available; nullable references and statistics are explicit rather than fabricated.

The scope is completed matches, refreshed hourly. Output is intended for analysis, modelling and integration, not republication as a bulk mirror of any source. Retain source references and coverage qualifications when incorporating the facts into downstream analysis.

What this doesn't do

  • No live or in-play scores. Hourly completed-match refresh is not match monitoring.
  • No odds. Betting markets and prices are outside the output.
  • No point-by-point. Scores and available match statistics do not reconstruct rallies or individual points.
  • No doubles. Version one covers singles only.
  • No universal statistics guarantee. Availability varies by tour and era; absent match statistics use stats: null with stats_reason.

Use a licensed live-score feed for in-play monitoring, an odds feed for markets, or a point-level sports dataset for sequence analysis. Use a doubles-specific results feed for doubles coverage.