Build a live tennis scoreboard
Every live ATP, WTA, Challenger and ITF match over one stream, without polling
Why a stream, not polling
Polling /tennis/matches/live every 5 seconds is 720 calls an hour. The stream /tennis/live/stream pushes only what changed and counts one call per 15 seconds while it is open (240 an hour), and you never miss a point between polls.
| Event | When | data |
|---|---|---|
ready | On connect | Event list and your plan's delay_seconds |
snapshot | Once, first | The whole live board in items |
match_start | A match appears on the board | The match row |
match_update | Score, status or stats changed | The whole updated row |
match_end | A match left the board | match_id, last_seen, final (URL of the final row) |
ping | Every 15 s | Keep-alive |
Filter with ?tour=ATP or ?match_id=. Free and Starter plans get each event 60 seconds late; Pro and higher are real time.
Server: keep a live board in memory (Node 18+)
Keep your API key on the server. This reads the stream with fetch and keeps a map of live matches that your page or app reads from.
const URL = "https://api.citoapi.com/api/v1/tennis/live/stream";
const board = new Map(); // match id -> latest row
async function follow() {
const res = await fetch(URL, { headers: { "x-api-key": process.env.CITO_API_KEY, accept: "text/event-stream" } });
if (!res.ok) throw new Error(`stream refused: ${res.status} ${await res.text()}`);
const decoder = new TextDecoder();
let buf = "";
for await (const chunk of res.body) {
buf += decoder.decode(chunk, { stream: true });
let cut;
while ((cut = buf.indexOf("\n\n")) >= 0) {
const block = buf.slice(0, cut); buf = buf.slice(cut + 2);
const event = /^event: (.*)$/m.exec(block)?.[1];
const data = JSON.parse(/^data: (.*)$/m.exec(block)?.[1] ?? "null");
handle(event, data);
}
}
}
function handle(event, data) {
if (event === "snapshot") { board.clear(); for (const m of data.items) board.set(m.id, m); }
if (event === "match_start" || event === "match_update") board.set(data.id, data);
if (event === "match_end") board.delete(data.match_id); // fetch data.final for the result if you show it
}
(async function run() {
for (;;) {
try { await follow(); } catch (e) { console.error(e.message); }
await new Promise((r) => setTimeout(r, 3000)); // reconnect; the next snapshot resets the board
}
})();Render a row
function line(m) {
const side = (p) => `${p.is_serving ? "• " : " "}${p.name.padEnd(24)} ${p.sets_won} ${p.current_game_score ?? ""}`;
return [`${m.tour} ${m.tournament_name}, ${m.round_name}, set ${m.current_set}`, side(m.player1), side(m.player2)].join("\n");
}
// ATP Taipei CH, Final, set 2
// • Yosuke Watanuki 1 40
// Jan Kumstat 0 30- Each row carries
sets(games per set,is_completed),game_score,server(1 or 2) andtiebreak_score. Tour-level rows carrystats: a list of 15 serve and return stats, each with both players' values. ITF rows are score-only. - For the point-by-point timeline of one match, call
/tennis/matches/live/{id}?include=point_history. - When a match ends, read the final row from
/tennis/matches/{id}(thefinalURL inmatch_end).
In the browser
Don't put your API key in front-end code. Have your server expose its board (for example GET /my-api/live returning [...board.values()]) or relay the events to your page. For a push socket instead of SSE, use the WebSocket at wss://api.citoapi.com/api/v1/tennis/live/ws (see the Tennis API page).
Only need results?
If you only need to know when matches start, finish a set or finish, a webhook is simpler than a stream: Tennis webhooks.
See what is live now
The live board returns every match in progress with sets, games, the point score and who is serving.
curl "https://api.citoapi.com/api/v1/tennis/matches/live" \
-H "x-api-key: YOUR_API_KEY"