Build a tennis prediction or betting model
Results with serve statistics, surface form, head-to-heads and rankings as model features
Data feed only, not a sportsbook. Tennis odds are not offered yet, so bring your own prices.
1. Training data: whole seasons with statistics
Don't fetch one match at a time. A list with include=stats returns 100 matches and their statistics per call: an ATP tour-level season (about 1,960 matches) is 20 calls.
import os, time, requests
BASE = "https://api.citoapi.com/api/v1/tennis"
HEADERS = {"x-api-key": os.environ["CITO_API_KEY"]}
def pages(path, **params):
page = 1
while True:
r = requests.get(f"{BASE}{path}", headers=HEADERS,
params={**params, "page_size": 100, "page": page})
if r.status_code == 429: # per-minute limit: wait and retry the same page
time.sleep(6); continue
body = r.json()
yield from body["items"]
if not body["has_next"]:
return
page += 1
def row(m):
w, l = (m.get("stats") or {}).get("winner") or {}, (m.get("stats") or {}).get("loser") or {}
return {
"match_id": m["canonical_id"], "date": m["match_date"], "surface": m["surface"],
"level": m["level"], "round": m["round"], "best_of": m["best_of"],
"winner_id": m["winner_id"], "loser_id": m["loser_id"],
"winner_rank": m["winner"]["rank"], "loser_rank": m["loser"]["rank"],
"w_ace": w.get("aces"), "l_ace": l.get("aces"),
"w_1st_in": w.get("first_serve_pct"), "l_1st_in": l.get("first_serve_pct"),
"w_1st_won": w.get("first_serve_won_pct"), "l_1st_won": l.get("first_serve_won_pct"),
"w_2nd_won": w.get("second_serve_won_pct"), "l_2nd_won": l.get("second_serve_won_pct"),
"outcome": m["outcome"], # COMPLETED, RETIREMENT, WALKOVER, DEFAULT, ABANDONED
}
train = [row(m) for m in pages("/matches", edition_year=2025, tour="ATP", level="A", include="stats")]- Key on
canonical_id: it is the id a match keeps for good. - Drop or flag
outcomeWALKOVER and RETIREMENT rows: they were not played to the end. - Where a stat was not published, the field is null and
stats_coverage.missingnames it. Treat null as unknown, never as zero.
2. Daily update: 1 call per 100 results
Each morning, read yesterday's finished matches with statistics. Most days are 1 to 3 calls.
from datetime import date, timedelta
yesterday = (date.today() - timedelta(days=1)).isoformat()
new_rows = [row(m) for m in pages("/matches/completed", date=yesterday, include="stats")]
# Serve statistics can land up to about 6 hours after a match ends.
# Re-read rows whose stats_available is false on the next run:
pending = [r["match_id"] for r in new_rows if r["w_ace"] is None]
for i in range(0, len(pending), 50):
batch = requests.get(f"{BASE}/matches/stats", headers=HEADERS,
params={"ids": ",".join(pending[i:i + 50])}).json()
# batch["items"] holds the same object as /matches/{id}/stats, batch["not_found"] any unknown ids3. Pre-match features for a fixture
For an upcoming match, three calls give surface records, the rivalry and recent form. Run them in parallel.
const BASE = "https://api.citoapi.com/api/v1/tennis";
const headers = { "x-api-key": process.env.CITO_API_KEY };
const get = (path) => fetch(BASE + path, { headers }).then((r) => r.json());
const [p1, p2] = ["atp_206173", "atp_207989"];
const [stats1, stats2, h2h, form1, form2] = await Promise.all([
get(`/players/${p1}/stats?surface=Hard&year_from=2025`),
get(`/players/${p2}/stats?surface=Hard&year_from=2025`),
get(`/h2h?players=${p1},${p2}&surface=Hard`),
get(`/players/${p1}/form?limit=10`),
get(`/players/${p2}/form?limit=10`),
]);
const features = {
hard_win_pct_p1: stats1.career_summary.win_percentage,
hard_win_pct_p2: stats2.career_summary.win_percentage,
first_serve_won_p1: stats1.serving_stats.first_serve_points_won_pct,
first_serve_won_p2: stats2.serving_stats.first_serve_points_won_pct,
return_games_won_p1: stats1.return_stats.return_games_won_pct,
return_games_won_p2: stats2.return_stats.return_games_won_pct,
h2h_p1_wins: h2h.summary.player1_wins,
h2h_p2_wins: h2h.summary.player2_wins,
deciding_sets_p1: h2h.deciding_sets.player1_deciding_set_wins,
form_p1: form1.win_pct,
form_p2: form2.win_pct,
rank_p1: h2h.player1.current_rank,
rank_p2: h2h.player2.current_rank,
};Find upcoming fixtures with /tennis/matches/upcoming or /tennis/schedule?date=. For the ranking on a past date (to avoid leaking future information into training), use /tennis/rankings?tour=ATP&date=YYYY-MM-DD.
4. Settle results
Instead of polling for finished matches, subscribe to tennis.match.completed and read winner_id, score and outcome from the event. See Tennis webhooks.
Call budget
- One ATP tour-level season with statistics: about 20 calls.
- Daily update: 1 to 3 calls, plus 1 call per 10 ids for late statistics.
- Pre-match features: 5 calls per fixture (cache player stats for the day).
More on cutting calls: Batch & include.
Pull yesterday with statistics
One call returns up to 100 finished matches with full serve and return statistics for every row.
curl "https://api.citoapi.com/api/v1/tennis/matches/completed?date=2026-11-07&page_size=100&include=stats" \
-H "x-api-key: YOUR_API_KEY"