← Developer Blog

Historical tennis data for machine learning

1,485,752 results back to 1968, 179,578 matches with a point-by-point tape, 11,638 shot-charted. Which endpoint, which tier, and the leakage traps.

· By the Live Tennis API team

Training a tennis model needs three different shapes of history, and they live behind three different endpoints on three different tiers. This guide says plainly which is which, what each actually contains as of September 14, 2026, and where the leakage traps are.

Everything below uses the base URL https://api.livetennisapi.com/api/public/v1 with an X-API-Key header. A free key (no card) at livetennisapi.com/subscribe/free does not include history — it covers the current state of play — so you will need at least Basic ($9.99/mo) to follow along.

The three corpora, and what is actually in them

Corpus Endpoint Tier Size, counted 2026-09-14
Results archive, 1968–2022 GET /history/archive/matches Basic 1,485,752 results
Point-by-point tape, 2023→now GET /history/matches/{id} Basic 179,578 matches of 187,832 completed (96%), 27,381,531 point-state rows
Shot-level charting GET /charting/matches/{id} Ultra 11,638 charted matches, 1,850,490 shot-level points, back to 1960

Those are counted figures, not estimates, and each carries the date it was counted. The coverage share is the one to watch: it is 96%, not 100%, and it moves as completed matches accumulate faster than tapes do. Treat the denominator as something that grows.

Which one do you actually want?

A common and reasonable combination is the archive for the outcome prior and the tape for the in-play update.

Pulling a training set

List completed matches, then pull each tape:

import os, requests

BASE = "https://api.livetennisapi.com/api/public/v1"
headers = {"X-API-Key": os.environ["LIVE_TENNIS_API_KEY"]}

# Completed matches, newest first — each row tells you whether a tape exists
r = requests.get(f"{BASE}/history/matches", params={"tour": "atp", "limit": 100},
                 headers=headers, timeout=30)
r.raise_for_status()

for m in r.json().get("matches", []):
    if not m.get("has_tape"):
        continue                      # skip rather than assume; coverage is 96%, not 100%
    tape = requests.get(f"{BASE}/history/matches/{m['id']}",
                        headers=headers, timeout=30).json()
    # tape carries the score after each point, and the model's probability where computed

Check the coverage rollup before you assume a slice is complete:

curl -s "https://api.livetennisapi.com/api/public/v1/history/coverage" \
  -H "X-API-Key: $LIVE_TENNIS_API_KEY"

That endpoint returns measured completeness per tour and draw bucket, each with its own as_of. It exists so you do not have to infer completeness from the rows you happened to fetch.

Three leakage traps specific to tennis

1. The denominator moves. Coverage is 96% of completed matches and that share changes as results land. If you compute a feature like "share of this player's matches with a tape" at training time and again at inference, you are measuring two different populations. Freeze the corpus for a training run and record the date you froze it.

2. Rankings are as-of, or they are leakage. A player's ranking today is not their ranking at the time of a 2019 match. GET /rankings serves per-player as-of records on Ultra precisely so a backtest can ask what was known then. Joining current rankings onto historical matches is the single most common way a tennis model appears to work and does not.

3. Retirements and walkovers are not ordinary wins. A match that ends in retirement has a winner and a scoreline that does not describe a completed contest. Decide explicitly whether they belong in your training set; do not let the default join decide for you.

Where the archive stops and the tape starts

The results archive runs to the end of 2022. The point-by-point tape starts in January 2023. They do not overlap, and they are different shapes of data — results versus points. Matches from 2013 to 2022 additionally carry a reconstructed tape at GET /history/archive/matches/{id}/tape on Ultra, which is derived rather than recorded and should be labelled as such in any feature you build from it.

What this costs

For a one-off training pull, the daily cap matters more than the rate limit. Pre-built monthly bulk files are available at GET /history/packages on Pro if you would rather download than iterate.

Full endpoint reference at docs.livetennisapi.com, including a point-by-point topic page and the results archive. Plans and limits are on the pricing page.

Frequently asked questions

How much historical tennis data is available?

Three corpora, counted on September 14, 2026. The results archive holds 1,485,752 results from 1968 to the end of 2022. The point-by-point tape covers January 2023 onward with 27,381,531 point-state rows across 179,578 completed matches, which is 96% of the 187,832 completed matches on record. Shot-level charting covers 11,638 matches and 1,850,490 shot-level points back to 1960. Every one of those figures is counted rather than estimated and carries the date it was counted, because a coverage share moves as new matches complete.

Which tier do I need for historical tennis data?

Basic at $9.99/month covers the two corpora most models need: the 1968–2022 results archive and the point-by-point tape from 2023 onward, at 60 requests per minute and 1,000 per day. Ultra at $99.99/month adds shot-level charting, per-player as-of rankings, model win-probability fields and the reconstructed 2013–2022 archive tape, at 600 per minute and 500,000 per day. The free tier does not include history at all — it covers the current state of play, so you can validate the API shape before paying but not pull a training set.

Is the tennis data complete?

No, and the API says so rather than letting you assume. Tape coverage is 96% of completed matches as of September 14, 2026 — 179,578 of 187,832. The `GET /history/coverage` endpoint returns measured completeness per tour and draw bucket with its own as-of date, and each row in the completed-match listing tells you whether a tape exists for that match. Skip matches without one rather than treating absence as a zero; and re-read coverage rather than carrying a number forward, because the denominator grows as matches complete.

How do I avoid data leakage in a tennis model?

Three traps are specific to tennis. Rankings are the biggest: a player's ranking today is not their ranking at the time of a 2019 match, so join as-of rankings, which Ultra serves per player, rather than current ones. Second, freeze your corpus for a training run and record the date, because tape coverage is a share of a denominator that grows. Third, decide explicitly how retirements and walkovers are treated — they carry a winner and a scoreline that does not describe a completed contest, and the default join will quietly include them.

Can I download tennis data in bulk instead of paging an API?

Yes. `GET /history/packages` lists pre-built monthly bulk files and `GET /history/packages/{period}` returns a manifest or the file itself, both on the Pro tier. For a one-off training pull this is usually the better route: the daily request cap matters more than the per-minute rate when you are fetching tens of thousands of matches, and a bulk file costs one request rather than thousands. Paging the per-match endpoints makes more sense for incremental updates once your initial corpus is built.

Built with the Live Tennis API — real-time scores, players, odds and model win-probability for ATP, WTA, Challenger and ITF.

API reference SDKs on GitHub Plans from $9.99/mo