Fetch many matches in a few calls
Calling /tennis/matches/{id}/stats once per match is the slowest and most expensive way to read a lot of tennis. A week of results is about 1,400 matches: that is 1,400 calls one by one, or 14 calls with include=stats. Pick the row below that fits what you have.
Which one to use
| You need | Use | Counts as |
|---|---|---|
| Statistics for matches you already have ids for | GET /tennis/matches/stats?ids=a,b,c | 1 call per 10 ids (50 ids = 5 calls) |
| A list of matches (a day, a week, a player, a tournament) with their statistics | Add include=stats to the list | 1 call per page (up to 100 matches) |
| Full match rows for ids you already have | GET /tennis/matches?ids=a,b,c (rows in data, not items) | 1 call per 10 ids |
| Player profiles for a roster | GET /tennis/players/batch?ids=a,b,c (unknown ids in missing) | 1 call (up to 50 ids) |
An ids= batch on /matches/stats or /matches counts one call per started block of 10 ids, against both the monthly total and the per-minute limit. A list page counts as one call whatever its size, with or without include=stats. See Rate limits.
Statistics for up to 50 matches: /matches/stats
Send up to 50 match ids, comma-separated or as repeated ids=. Each item in items is exactly what /tennis/matches/{id}/stats returns for that match, in the order you sent. Ids with no match are listed in not_found instead of failing the call. Repeated ids are answered once.
curl "https://api.citoapi.com/api/v1/tennis/matches/stats?ids={matchId},atp_2024_540_701,atp_2021_540_999" \
-H "x-api-key: YOUR_API_KEY"{
"success": true,
"items": [
{
"match_id": "{matchId}",
"status": "COMPLETED",
"score": "7-6 7-5",
"stats_available": true,
"stats": {
"winner": { "aces": 13, "double_faults": 3, "first_serve_pct": 48.6, "first_serve_won_pct": 79.4, "service_points_won": 51, "total_points_won": 76 },
"loser": { "aces": 14, "double_faults": 3, "first_serve_pct": 56.2, "first_serve_won_pct": 70.7, "service_points_won": 48, "total_points_won": 67 },
"player1": { "...": "same numbers, by draw side" },
"player2": { "...": "same numbers, by draw side" }
},
"point_stats": { "winner": { "break_points_converted": 2, "break_point_opportunities": 5 }, "loser": { "...": "..." } },
"sets": [ { "set_number": 1, "player1_games": 7, "player2_games": 6 }, { "set_number": 2, "player1_games": 7, "player2_games": 5 } ]
},
{ "match_id": "atp_2024_540_701", "...": "..." }
],
"total": 2,
"requested": 3,
"not_found": ["atp_2021_540_999"]
}- Limit: 50 distinct ids. 51 or more returns 422 asking you to split the list into calls of 50. No ids returns 422.
- Ids: every id form the single route accepts works here, including merged ids, which resolve to the match they were merged into.
- Gaps: a match without published statistics comes back with
stats_available: falseand a machine-readablestats_unavailable_codeandstats_unavailable_reason, the same as the single route. - Cost: 50 ids count as 5 calls instead of 50.
Statistics on a match list: include=stats
Add include=stats to any of these lists and every row gains stats, stats_available, stats_unavailable_code, stats_unavailable_reason, stats_points_counted, stats_coverage and point_stats: the same values as /tennis/matches/{id}/stats. Without include, rows are unchanged.
/tennis/matches/tennis/matches/recent/tennis/matches/completed/tennis/matches/upcoming/tennis/players/{id}/matches/tennis/tournaments/{id}/schedule# Every match of one week, with statistics, 100 per page
curl "https://api.citoapi.com/api/v1/tennis/matches?date_start=2026-11-01&date_end=2026-11-07&page_size=100&include=stats&page=1" \
-H "x-api-key: YOUR_API_KEY"
# Yesterday's results with statistics
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"
# One player's last 50 matches with statistics
curl "https://api.citoapi.com/api/v1/tennis/players/atp_206173/matches?page_size=50&include=stats" \
-H "x-api-key: YOUR_API_KEY"- Upcoming matches:
stats_availableis false with the codenot_published_yet. - Valid values:
includeacceptsstats. Any other value returns 422 naming the valid ones. Tennis odds are not offered yet, soinclude=oddsreturns 422 too. - Paging: lists page with
pageandpage_size(up to 100); walkpageuntilhas_nextis false.
Pull a whole week or season
Walk the pages of one filtered list with include=stats. A week of every tour, Challenger and ITF match (about 1,400) takes 14 calls.
const BASE = "https://api.citoapi.com/api/v1/tennis";
const headers = { "x-api-key": process.env.CITO_API_KEY };
async function matchesWithStats(params) {
const rows = [];
for (let page = 1; ; page++) {
const qs = new URLSearchParams({ ...params, include: "stats", page_size: "100", page: String(page) });
const res = await fetch(`${BASE}/matches?${qs}`, { headers });
if (res.status === 429) { await new Promise((r) => setTimeout(r, 6000)); page--; continue; }
const body = await res.json();
rows.push(...body.items);
if (!body.has_next) return rows;
}
}
const week = await matchesWithStats({ date_start: "2026-11-01", date_end: "2026-11-07" });
console.log(week.length, "matches;", week.filter((m) => m.stats_available).length, "with statistics");import os, time, requests
BASE = "https://api.citoapi.com/api/v1/tennis"
HEADERS = {"x-api-key": os.environ["CITO_API_KEY"]}
def matches_with_stats(**params):
rows, page = [], 1
while True:
r = requests.get(f"{BASE}/matches", headers=HEADERS,
params={**params, "include": "stats", "page_size": 100, "page": page})
if r.status_code == 429:
time.sleep(6)
continue
body = r.json()
rows += body["items"]
if not body["has_next"]:
return rows
page += 1
# edition_year keeps an event in its season even when it starts in late December
season = matches_with_stats(edition_year=2025, tour="ATP", level="A")
print(len(season), "ATP tour-level matches in 2025") # about 1,960 matches in 20 callsStatistics for ids you stored
When you already hold match ids (from your database or a webhook), send them 50 at a time.
async function statsFor(ids) {
const out = new Map();
for (let i = 0; i < ids.length; i += 50) {
const chunk = ids.slice(i, i + 50);
const res = await fetch(`${BASE}/matches/stats?ids=${chunk.join(",")}`, { headers });
const body = await res.json();
for (const item of body.items) out.set(item.match_id, item);
for (const missing of body.not_found) console.warn("no match", missing);
}
return out;
}Mistyped ids tell you what you meant
A 404 for an unknown player, match or tournament id carries error.suggestions: up to 5 ids that exist, each with a name, short context and a reason. Every suggested id resolves.
GET /tennis/players/wta_104925
{
"success": false,
"error": {
"code": "NOT_FOUND",
"status": 404,
"message": "Player with ID 'wta_104925' not found.",
"suggestions": [
{ "id": "atp_104925", "name": "Novak Djokovic", "tour": "ATP", "ioc": "SRB", "birth_year": 1987, "reason": "other_tour" }
]
}
}reason is one of normalized, other_tour, padding, other_season, nearby_match, live_id or name_match. Match suggestions carry tournament_id, tournament_name, year, round and match_date; tournament suggestions carry year, tour, level and start_date. A typed name works too: /tennis/players/atp_djokovic suggests atp_104925.