Guides/Build and keep a tennis dataset up to date

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

LoadOne match at a timeWith include=stats
One week, every level (~1,400 matches)~2,800 calls14 calls
ATP tour-level season (~1,960 matches)~3,900 calls20 calls
Yesterday's results~400 calls1 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_year for seasons: an event that starts in late December stays in its season.
  • Add level (G, M, A, C, ...) to narrow, or match_type=all to include doubles.
  • Before 2004, match_date is 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.missing names 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?