Build and keep a tennis dataset up to date
Load seasons with statistics in a few calls, then stay current with one call a day
The rule: lists with include=stats, never one match at a time
| Load | One match at a time | With include=stats |
|---|---|---|
| One week, every level (~1,400 matches) | ~2,800 calls | 14 calls |
| ATP tour-level season (~1,960 matches) | ~3,900 calls | 20 calls |
| Yesterday's results | ~400 calls | 1 to 3 calls |
"One match at a time" means a list call plus /matches/{id} and /matches/{id}/stats per match.
1. Initial load
python
import os, time, sqlite3, json, requests
BASE = "https://api.citoapi.com/api/v1/tennis"
HEADERS = {"x-api-key": os.environ["CITO_API_KEY"]}
db = sqlite3.connect("tennis.db")
db.execute("CREATE TABLE IF NOT EXISTS matches (id TEXT PRIMARY KEY, match_date TEXT, row JSON)")
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:
time.sleep(6); continue
r.raise_for_status()
body = r.json()
yield from body["items"]
if not body["has_next"]:
return
page += 1
def save(rows):
db.executemany("INSERT OR REPLACE INTO matches VALUES (?, ?, ?)",
[(m["canonical_id"], m["match_date"], json.dumps(m)) for m in rows])
db.commit()
for year in range(2015, 2026):
for tour in ("ATP", "WTA"):
save(pages("/matches", edition_year=year, tour=tour, include="stats"))- Key on
canonical_id. It is the id a match keeps for good; merged duplicates keep resolving from their old ids. - Use
edition_yearfor seasons: an event that starts in late December stays in its season. - Add
level(G, M, A, C, ...) to narrow, ormatch_type=allto include doubles. - Before 2004,
match_dateis the tournament's start (date_is_tournament_start: true): group those eras by tournament, not day.
2. Daily update
python
from datetime import date, timedelta
for back in (1, 2): # yesterday and the day before: late finishes and late statistics
day = (date.today() - timedelta(days=back)).isoformat()
save(pages("/matches/completed", date=day, include="stats"))Serve statistics can arrive up to about 6 hours after a match ends, so re-reading the day before costs 1 to 3 calls and fills them. For ids you stored without statistics, send them to /matches/stats?ids= 50 at a time. Or skip the schedule entirely: a tennis.match.completed webhook tells you the moment each match ends (see Tennis webhooks).
3. Rankings
python
dates = requests.get(f"{BASE}/rankings/dates", headers=HEADERS, params={"tour": "ATP", "year": 2025}).json()
for monday in dates["items"]: # "2025-12-29", ... one per published list
rows = list(pages("/rankings", tour="ATP", date=monday, rank_max=5000))
# each row: rank, player_id, player_name, ioc, points, previous_rank, rank_movement
# dates["unpublished_mondays"] lists weeks with no new list (the previous one stayed in force)4. Stay correct
- The archive is maintained: historical records get added and corrected. Every change that affects data you receive is in the changelog (with RSS). Re-pull the periods an entry names.
- Null means unknown, never zero.
stats_coverage.missingnames fields a match did not publish. - Need the whole archive as files instead of calls? Email support@citoapi.com about a data export.
Size your pull first
Ask for one row: total tells you how many matches the filter covers, and total_pages how many calls it takes at 100 per page.
bash
curl "https://api.citoapi.com/api/v1/tennis/matches?edition_year=2025&tour=WTA&page_size=1&fields=id" \
-H "x-api-key: YOUR_API_KEY"Was this page helpful?