Guides/Build a tennis prediction or betting model

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.

python
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 outcome WALKOVER and RETIREMENT rows: they were not played to the end.
  • Where a stat was not published, the field is null and stats_coverage.missing names 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.

python
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 ids

3. Pre-match features for a fixture

For an upcoming match, three calls give surface records, the rivalry and recent form. Run them in parallel.

javascript
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.

bash
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"
Was this page helpful?