Tennis Scraper API: ATP WTA ITF Results, Stats, Rankings, H2H
Pricing
from $2.55 / 1,000 match record (standard)s
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
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
15 hours ago
Last modified
Categories
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:
matchesfilters 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:
h2hreturns 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_statsreturns one aggregate of raw serve/return sums and derived percentages, optionally restricted by opponent-ranking band. Only completed and retired matches count. - Rankings:
rankingsreturns a tour's indexed ranking table for a date, optionally bounded by ranking position. - Incremental updates:
changesreturns 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
| Approach | Typical fit | Integration boundary |
|---|---|---|
| Single-source scrapers | One tour or tournament | Caller reconciles identifiers and output formats across sources. |
| Static historical datasets | Fixed research samples | Fresh results and corrections require another update process. |
| Subscription sports-data APIs | Contracted coverage, possibly live data | Available history, statistics and redistribution rights depend on the product. |
| This Actor | Completed-match analysis across tours | One 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.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
mode | string | No | matches | matches, h2h, player_stats, rankings or changes. |
tour | string | No | - | atp, wta, challenger, itf; unfiltered for match history when omitted. Rankings defaults to ATP. |
dateFrom | string | No | - | Inclusive match-date lower bound. |
dateTo | string | No | - | Inclusive upper bound; not before dateFrom. |
tournamentId | string | No | - | Source-prefixed tournament ID, such as atp:540; combine with dates for an edition. |
surface | string | No | - | Stored label, matched exactly and case-sensitively; values are lowercase, such as grass, hard or clay. |
rounds | array of strings | No | - | R128, R64, R32, R16, QF, SF, F, RR, Q1, Q2, Q3; empty means unfiltered. |
stage | string | No | - | main or qual; omission includes either where indexed. |
statuses | array of strings | No | ["completed","retired"] | Status filter; statistics mode requires this default pair. |
playerA | string | For h2h | - | First player ID, such as atp:s0ag. |
playerB | string | For h2h | - | Different second player ID, such as atp:a0e2. |
player | string | For player_stats | - | One player ID, such as wta:326408. |
opponentRankMin | integer | No | - | Positive minimum numeric opponent rank; statistics only. |
opponentRankMax | integer | No | - | Positive maximum numeric opponent rank; not below the minimum. |
rankingDate | string | No | - | Indexed ranking snapshot date, not a match-date filter. |
rankMax | integer | No | - | Top N: 100 returns positions 1 to 100. Not a row budget. |
since | integer | No | - | Nonnegative revision checkpoint for changes; omission starts from zero. |
cursor | string | No | - | Opaque continuation token; copy without editing. Rejected in changes mode, which resumes from since. |
limit | integer | No | 5000 | Data-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
playerAandplayerB. Uses match-history filters and pagination; ignoresplayer, ranking selectors andsince. - player_stats: requires
player. Uses match-history filters and optional opponent-rank bounds;statusesmust remain completed plus retired. IgnoresplayerA,playerB, ranking selectors andsince. - rankings: uses
tour,rankingDate,rankMaxand pagination. Ignores match-history filters, player selectors andsince. - changes: uses
sinceandlimit; ignores tour, match-history, player and ranking selectors, and REJECTScursor— the revision feed resumes fromsince. 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 osfrom apify_client import ApifyClientclient = 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-indexas 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_clientexample 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, pollGET /v2/actor-runs/{runId}, then read the default dataset andOUTPUTfrom 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_class | Meaning and routing |
|---|---|
source_error | Temporary source failure; the only class to retry immediately, allowing a few minutes between attempts. |
no_match | Valid filters matched no indexed rows; reconsider scope or IDs. |
out_of_coverage | The 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_input | Correct the input using the notice. |
not_configured | Run configuration unavailable; no lookup or data charge. |
invalid_request | Request rejected; inspect and correct it. |
not_separable | Challenger-only filtering unavailable; results include ATP main tour. |
budget_exhausted | Maximum charge reached; resume with a suitable budget. |
row_limit | Data-row limit reached; resume or raise the limit. |
internal_error | A 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: nullwithstats_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.