Guides/Build a live tennis scoreboard

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.

EventWhendata
readyOn connectEvent list and your plan's delay_seconds
snapshotOnce, firstThe whole live board in items
match_startA match appears on the boardThe match row
match_updateScore, status or stats changedThe whole updated row
match_endA match left the boardmatch_id, last_seen, final (URL of the final row)
pingEvery 15 sKeep-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.

javascript
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

javascript
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) and tiebreak_score. Tour-level rows carry stats: 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} (the final URL in match_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.

bash
curl "https://api.citoapi.com/api/v1/tennis/matches/live" \
  -H "x-api-key: YOUR_API_KEY"
Was this page helpful?