Quick answer: A 15–40 alert on the Tennis Trader Board is a live score state, not a result. To see how often the server held after 15–40 or 0–40, fetch GET /history/matches/{id}?sequence=clean, find those point strings on the server/receiver slots, then walk forward until the nested games arrays change. One match is a demo. Clay vs hard needs a loop of completed ids, grouped by match.surface. Not tips — counts only.

Disclosure: Links to Live Tennis API are affiliate links. We may earn a commission if you subscribe. Use our link or checkout code botblog for 10% off. Live Tennis API also gave BotBlog Ultra access for testing. The how-to steps are independent. Full disclosure.

If you only want live 15–40 chips while you trade, stop here and open the free Tennis Trader Board — no key, no Python. This guide is the next step: check historical tennis data after the match, with the same 15–40 / 0–40 vocabulary the board already shows.

Start on the live board. Filter 15–40 / 0–40 on the free Tennis Trader Board, then come back here to audit hold vs break on completed tapes.

Open the Tennis Trader Board →

I’m Stephane. I run BotBlog. The 15–40 tennis trading strategy guide explains why traders watch that state. It does not prove how often the server actually holds. Guesswork and “paste this into Claude” summaries are a poor substitute for walking the points.

You will need a paid API key to fetch a real tape. Skip the no-card free key (100 requests/day, no history). Ultra is the plan if you also want the live WebSocket the board is built on. History-only readers can use Historical Starter instead — see the plan notes at the end. Confirm the key before you chase Python errors.

Subscribe to Ultra (10% off) →

Affiliate link — pick a paid plan, then type botblog at checkout. Disclosure

What you are measuring

On the board, 15–40 means the server has 15 and the receiver has 40 — two break points. 0–40 is three. The break-point playbook covers 30–40, ad-out and tiebreaks separately; this script only counts 15–40 and 0–40, matching the alerts most people pin.

A point-by-point row is a photograph of the score. It does not contain game_winner. You cannot ask the 15–40 row who won the game. You wait until games changes, then see which player’s game list went up.

Count one threat per game: if a game goes 0–40 then 15–40, that is still one service game under break-point pressure — the moment you would have seen the first alert. Hold rate is holds / (holds + breaks). Games that never finish (retirement mid-game) are unresolved, not a hold.

The real tape shape

Base URL: https://api.livetennisapi.com/api/public/v1

Endpoint: GET /history/matches/{matchId}?sequence=clean

Auth in Python (same pattern as the dashboard guide): header Authorization: Bearer YOUR_KEY. The API also accepts X-API-Key or ?token= in the browser. Prefer a header so the key does not land in logs.

The response is match + tape + meta (plus profiles / tiebreaks you can ignore here). Player names and surface live on match, not at the top level. Each tape row looks like the vendor’s own Nishikori vs Shang example (match id 22896, ATP Washington, 27 Jul 2026) — truncated:

{
  "meta": {
    "coverage": "from_start",
    "point_source": "observed"
  },
  "tape": [
    {
      "sets": [0, 0],
      "games": [[0], [0]],
      "points": ["0", "15"],
      "server": 2,
      "is_tiebreak": false,
      "timestamp": "2026-07-27T21:08:48.219296Z",
      "win_probability_p1": 0.397,
      "danger": 0.108
    }
  ]
}

Three details that break copy-paste scripts written against a guessed JSON:

  • games is nested: [[p1 games in each set], [p2 games in each set]]. [[6, 3], [4, 4]] is 6–4, 3–4 — not a flat [2, 3].
  • points is player-major: index 0 is player 1, index 1 is player 2. If "server": 1 and "points": ["15", "40"], player 1 is serving at 15–40.
  • ?sequence=clean is required for counting. Leave it off and duplicate commits inflate the threat count. Do not combine it with ?points=complete (the API rejects that pair).

Observed vs reconstructed (read this before you backtest)

KindWhat you can countWhat stays null
Observed 2023+ rows (meta.point_source = observed / mixed)Score states, holds, breaksModel win % only where the vendor scored that point — never invent it
Reconstructed 2023+ rowsScore sequence onlytimestamp, win_probability_p1, danger
2013–2022 archive tapeScore sequence (winner-first rows, different id space)All model fields, every timestamp. Different URL: /history/archive/matches/{archiveId}/tape

Print meta.coverage and meta.point_source on every match you audit. Skip coverage: none. Do not feed reconstructed rows into a “momentum from win-probability” prompt — those numbers were never computed.

This script stays on the 2023+ endpoint. Archive tapes use server: 1 = the winner was serving, not live player 1. Mixing the two walkers silently inverts holds and breaks.

Python setup

If you have never installed Python, do dashboard guide Steps 1–2 (Python, a folder, .venv, pip install requests python-dotenv), then come back. Same .env key works.

LIVETENNISAPI_KEY=paste_your_paid_key_here

Save the script below as audit_1540.py in that folder.

The walker

import os
import requests
from dotenv import load_dotenv

load_dotenv()

BASE = "https://api.livetennisapi.com/api/public/v1"
KEY = os.environ["LIVETENNISAPI_KEY"]
HEADERS = {
    "Authorization": f"Bearer {KEY}",
    "User-Agent": "BotBlog1540Audit/1.0 (+https://botblog.co.uk)",
}


def is_1540_or_040(row):
    if row.get("is_tiebreak"):
        return False
    server = row.get("server")
    points = row.get("points") or []
    if server not in (1, 2) or len(points) < 2:
        return False
    server_pts = points[server - 1]
    receiver_pts = points[2 - server]
    if server_pts is None or receiver_pts is None:
        return False
    return receiver_pts == "40" and server_pts in ("0", "15")


def server_held(initial_games, later_games, server):
    before = list(initial_games[server - 1])
    after = list(later_games[server - 1])
    return after > before


def audit_tape(tape):
    threats = holds = breaks = unresolved = 0
    i = 0
    n = len(tape)
    while i < n:
        row = tape[i]
        if not is_1540_or_040(row):
            i += 1
            continue
        threats += 1
        server = row["server"]
        initial_games = row.get("games") or [[], []]
        j = i + 1
        resolved = False
        while j < n:
            later_games = tape[j].get("games") or [[], []]
            if later_games != initial_games:
                if server_held(initial_games, later_games, server):
                    holds += 1
                else:
                    breaks += 1
                resolved = True
                break
            j += 1
        if resolved:
            i = j  # next row is 0-0 of the following game
        else:
            unresolved += 1
            i += 1
    decided = holds + breaks
    rate = (holds / decided * 100) if decided else None
    return threats, holds, breaks, unresolved, rate


def audit_match(match_id):
    url = f"{BASE}/history/matches/{match_id}"
    r = requests.get(url, headers=HEADERS, params={"sequence": "clean"}, timeout=30)
    print(r.status_code, r.text[:300] if r.status_code != 200 else "")
    r.raise_for_status()
    data = r.json()
    match = data.get("match") or {}
    players = match.get("players") or {}
    p1 = (players.get("p1") or {}).get("name", "Player 1")
    p2 = (players.get("p2") or {}).get("name", "Player 2")
    meta = data.get("meta") or {}
    threats, holds, breaks, unresolved, rate = audit_tape(data.get("tape") or [])
    print(f"{p1} vs {p2} (id {match_id})")
    print(f"surface={match.get('surface')} coverage={meta.get('coverage')} point_source={meta.get('point_source')}")
    print(f"15-40/0-40 games={threats} holds={holds} breaks={breaks} unresolved={unresolved}")
    print("hold rate n/a" if rate is None else f"server hold rate={rate:.1f}%")


if __name__ == "__main__":
    audit_match(22896)  # vendor example: Nishikori vs Shang, ATP Washington 2026

Run it from the project folder with the venv on:

python audit_1540.py

Use python3 if python is not recognised. Do not paste that line into a browser address bar.

If you get 401, the key is missing or wrong. 403 with upgrade_required means the key’s plan does not include history — free keys cannot read this endpoint. 404 means that id has no tape.

The printed hold rate is that match only. It is not ATP hard-court truth and not a reason to lay anyone on Betfair.

No key yet? Walk a tiny fixture first

Paste this under the functions (comment out audit_match(22896)) to prove the walker without the network:

demo = [
    {"games": [[2], [3]], "points": ["15", "40"], "server": 1, "is_tiebreak": False},
    {"games": [[2], [3]], "points": ["30", "40"], "server": 1, "is_tiebreak": False},
    {"games": [[2], [3]], "points": ["40", "40"], "server": 1, "is_tiebreak": False},
    {"games": [[2], [3]], "points": ["AD", "40"], "server": 1, "is_tiebreak": False},
    {"games": [[3], [3]], "points": ["0", "0"], "server": 2, "is_tiebreak": False},
]
print(audit_tape(demo))
# -> (1, 1, 0, 0, 100.0)  one 15-40, server held

Player 1 served at 15–40, saved to deuce, then held. The 15–40 row never said that — the jump from [[2], [3]] to [[3], [3]] did.

Many matches, clay vs hard

One id cannot split surfaces. List completed matches, then call the walker per id.

GET /history/matches is already completed matches. There is no status=completed query on that route (that flag belongs on /matches). Example:

GET /history/matches?tour=atp&from=2026-07-01&to=2026-07-31&limit=50

Page with offset until meta.has_more is false. Group by each row’s surface. Read the listing’s tape coverage before you fetch a full tape; skip empty coverage. Stay inside your plan’s rate limit — a polite loop with a short sleep is enough for a first sample.

WTA vs ATP, and Challenger vs tour, will not share one hold rate. Do not average them into a single headline number and then trade it.

Optional: one-match summary in ChatGPT / Claude

Useful only after you have fetched one JSON body. Not a backtest, not a bot.

I am pasting one completed-match JSON from Live Tennis API
GET /history/matches/{id}?sequence=clean.

Using only fields that exist in the JSON:
1. Count games that reached server 15-40 or 0-40 (player-major points; skip is_tiebreak).
2. Say whether meta.coverage / meta.point_source is observed or reconstructed.
3. If win_probability_p1 is null on a row, say so — do not invent a swing.

Do not give betting advice, signals, tick entries, or first-serve percentages
unless those numbers are literally in the JSON.

Which plan to buy

If you already have Ultra, you already have this tape (and the 2013–2022 archive). Do not add Historical Starter on the same account just to run this script.

1. Live exchange traders (most BotBlog tennis readers) — you want the hosted board for scanning, plus your own WebSocket, model win %, and history on one key. That is Live Tennis API Ultra, not a history-only plan. Code botblog = 10% off.

Subscribe to Ultra (10% off) →

Affiliate — disclosure. Ultra is a live feed with history included. It does not “unlock” the free BotBlog board; the board stays free.

2. Offline modelling only — no live scores, no WebSocket, just completed tapes. Historical Data API Starter ($29/mo) is the cheaper door. Bulk year files are on Pro.

Get Historical Starter ($29/mo) →

Affiliate — code botblog for 10% off. After signup, pick the Historical Data Starter plan. Disclosure

3. One research window — a $49 one-month history pass (or a one-year pack) if you do not want a recurring subscription. Same affiliate link; choose the one-off package on the Historical Data page.

Get the $49 one-month pass →

Affiliate — code botblog. Disclosure

Related guides

Educational only. Completed-match counts are not betting or financial advice and do not predict the next 15–40 you see live. Past hold rates are not future results. This post contains affiliate links to Live Tennis API. Full disclosure.