Tennis results by webhook
A results bot and an always-current database, with no polling
Why webhooks
Checking /tennis/matches/recent every minute to catch finished matches is 1,440 calls a day, and you still learn about a result up to a minute late. A tennis.match.completed webhook costs no calls and arrives when the result is final. Webhooks are on Pro and Scale.
1. Subscribe
In Dashboard → Webhooks, create a webhook with your URL and the events tennis.match.completed and tennis.match.stats_ready. To follow only some players, add a playerId filter; for one event, tournamentId; for one tour, tour. Copy the secret.
2. Post results to Discord
import express from "express";
import crypto from "node:crypto";
const app = express();
const handled = new Set();
function valid(req) {
const expected = crypto.createHmac("sha256", process.env.CITO_WEBHOOK_SECRET).update(req.body).digest("hex");
const got = req.get("X-Cito-Signature") || "";
return got.length === expected.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}
app.post("/cito", express.raw({ type: "application/json" }), async (req, res) => {
if (!valid(req)) return res.sendStatus(401);
res.sendStatus(200);
const id = req.get("X-Cito-Event-Id");
if (handled.has(id)) return;
handled.add(id);
const { event, data: m } = JSON.parse(req.body.toString("utf8"));
if (event !== "tennis.match.completed") return;
const how = m.outcome === "COMPLETED" ? "" : ` (${m.outcome.toLowerCase()})`;
await fetch(process.env.DISCORD_WEBHOOK_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
content: `🎾 **${m.winner.name}** d. ${m.loser.name} ${m.score}${how}\n${m.tournament_name}, ${m.round_name}`,
}),
});
});
app.listen(3000);Result: 🎾 Yosuke Watanuki d. Jan Kumstat 7-6 7-5 / Taipei CH, Final. Swap the Discord URL for a Slack incoming webhook to post to Slack instead.
3. Keep a database current
Store the result on tennis.match.completed, then overwrite it with the full row when tennis.match.stats_ready arrives (minutes to hours later) with serve statistics. Both carry the same data.id, so an upsert on it is all you need.
import hmac, hashlib, json, os, sqlite3
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["CITO_WEBHOOK_SECRET"].encode()
db = sqlite3.connect("tennis.db", check_same_thread=False)
db.execute("CREATE TABLE IF NOT EXISTS matches (id TEXT PRIMARY KEY, row JSON, has_stats INT)")
db.execute("CREATE TABLE IF NOT EXISTS seen (event_id TEXT PRIMARY KEY)")
@app.post("/cito")
def cito():
raw = request.get_data()
if not hmac.compare_digest(hmac.new(SECRET, raw, hashlib.sha256).hexdigest(),
request.headers.get("X-Cito-Signature", "")):
abort(401)
if db.execute("INSERT OR IGNORE INTO seen VALUES (?)", (request.headers["X-Cito-Event-Id"],)).rowcount == 0:
return "", 200 # already handled (a retry)
p = json.loads(raw)
m = p["data"]
if p["event"] in ("tennis.match.completed", "tennis.match.stats_ready"):
db.execute("INSERT INTO matches VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET row=excluded.row, has_stats=excluded.has_stats",
(m["id"], json.dumps(m), int(p["event"] == "tennis.match.stats_ready")))
db.commit()
return "", 2004. Live set updates
Add tennis.match.started and tennis.set.completed to post "Sinner takes the first set 7-6" style updates: set_winner.name, set_score (player1 first) and set_number are on every set event. For point-by-point scores, use the live stream instead.
If your server was down
Failed deliveries are retried (3 attempts). After a longer outage, list what was sent with GET /api/v1/webhooks/{id}/deliveries and re-send with POST /api/v1/webhooks/{id}/replay, or catch up in one call per day with /tennis/matches/completed?date=YYYY-MM-DD&include=stats.
See the event catalog
Event names, what each carries, an example payload, filters and signing rules.
curl "https://api.citoapi.com/api/v1/tennis/webhooks/events" \
-H "x-api-key: YOUR_API_KEY"