Webhook recipes
Four things people build, with the events to subscribe to and the code. Each snippet starts after your handler has verified the signature and deduped on the id.
Live scoreboard
Keep one row per match, overwrite it on every event, and use a snapshot event or REST to recover after a gap. Order is not guaranteed, so never let an older event overwrite newer state.
CS2
Subscribe to cs2.match.live_started, cs2.score.updated, cs2.round.ended, cs2.map.completed and cs2.match.completed.
const board = new Map(); // matchId -> { score, updatedAt }
export function onCs2Event({ event, data, timestamp }) {
const id = data.matchId;
const row = board.get(id) ?? { updatedAt: "" };
if (timestamp < row.updatedAt) return; // an older event arrived late
if (event === "cs2.score.updated") {
// A score can be null while a map is being set up: keep the last one.
if (data.team1Score != null) row.score = [data.team1Score, data.team2Score];
}
if (event === "cs2.match.completed") {
row.final = { score: [data.team1Score, data.team2Score], winner: data.winnerTeamId };
}
row.updatedAt = timestamp;
board.set(id, row);
// push row to your browsers over your own socket here
}
// After a gap, read the current state:
// GET /api/v1/cs2/live/{matchId}/stateLeague of Legends
Subscribe to lol.live.game_started, lol.live.state, lol.score.updated and lol.match.completed. Use data.sampledAt to ignore older snapshots of one game.
if (event === "lol.live.state") {
const game = games.get(data.gameId);
if (game && data.sampledAt <= game.sampledAt) return;
games.set(data.gameId, data);
}
if (event === "lol.score.updated") {
series.set(data.matchId, { score: data.score, teams: data.teams, lastWinner: data.gameWinner });
}Tennis and UFC
Events mark the shape of the match: tennis.match.started, tennis.set.completed and tennis.match.completed; ufc.fight.started, ufc.round.completed and ufc.fight.completed. For the point-by-point score, use /tennis/live/stream, or for fights /ufc/live.
dota2.draft.updated. For the in-game score, poll /dota2/matches/live.Bet settlement
Settle only on final events, and make settlement safe to run twice.
- CS2: settle on
cs2.match.completed.cs2.match.live_endedonly says live tracking stopped; it is not the official result. - LoL: settle on
lol.match.completed. A corrected final score sends a second event with a new id, so re-grading must overwrite, not add. - Tennis: settle on
tennis.match.completedand branch ondata.outcome: COMPLETED, RETIREMENT, WALKOVER, DEFAULT or ABANDONED. - UFC: settle on
ufc.fight.completedand branch ondata.outcome: win, draw or no_contest. Stat markets wait forufc.fight.stats_ready. - Skip test deliveries:
payload.testis true on them.
export async function settle(payload, db) {
if (payload.test) return; // never settle on a test
const { id, event, data } = payload;
await db.tx(async (t) => {
// Unique on event id: a retry of this exact event does nothing.
const fresh = await t.result(
"INSERT INTO settled_events (id) VALUES ($1) ON CONFLICT DO NOTHING", [id]);
if (fresh.rowCount === 0 && !data.replayed) return;
if (event === "tennis.match.completed") {
if (data.outcome === "WALKOVER") return voidMarkets(t, data.id);
return gradeMatchWinner(t, data.id, data.winner.id);
}
if (event === "cs2.match.completed") {
return gradeMatchWinner(t, data.matchId, data.winnerTeamId);
}
if (event === "lol.match.completed") {
// Overwrites any earlier grade if the score was corrected.
return regradeSeries(t, data.matchId, data.score, data.winner);
}
});
}If your endpoint was down, nothing is lost for these events: catch-up re-sends match and result events from the last 7 days.
Discord alerts
Discord expects its own message format, so point Cito at your handler and let it post to your Discord webhook. Choose low-volume events such as cs2.match.started and cs2.match.completed, or filter to one player or fighter. Keep the Discord URL in an env var: it works like a password.
const FORMAT = {
"cs2.match.started": (d) => `${d.teams.join(" vs ")} is starting (${d.event})`,
"cs2.match.completed": (d) => `${d.teams.join(" vs ")}: ${d.team1Score}-${d.team2Score}`,
"tennis.match.completed": (d) => `${d.winner.name} d. ${d.loser.name} ${d.score}`,
"ufc.fight.completed": (d) => `${d.winner?.fighterName ?? "No winner"} by ${d.method}`,
};
export async function alert({ event, data, test }) {
const text = FORMAT[event]?.(data);
if (!text || test) return;
await fetch(process.env.DISCORD_WEBHOOK_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ content: text }),
});
}Filters to follow someone:
{ "events": ["tennis.match.started", "tennis.match.completed"], "filters": { "playerId": "atp_206173" } }
{ "events": ["ufc.fight.started", "ufc.fight.completed"], "filters": { "fighterSlug": "kayla-harrison" } }Fetch full stats when ready
Results arrive first. Stats can take longer, so wait for the signal instead of polling.
- Tennis:
tennis.match.stats_readycarries the full match withstatsandpoint_stats, minutes or hours after the result. It is not sent when a match's statistics stay partial. - UFC:
ufc.fight.stats_readycarriesboutStatsandroundStats. Round numbers onufc.round.completedare provisional; these are official. - LoL: when
data.statsPendingis true onlol.match.completedorlol.game.completed, read the match or game route again later. - CS2: on
cs2.match.completedread/cs2/matches/{matchId}/player-stats, and retry on a schedule if a map has no lines yet. - Call of Duty: treat
cod.match.completedas a signal to re-read/cod/matches.
// Tennis and UFC: the stats are in the event.
if (event === "tennis.match.stats_ready") await saveStats(data.id, data.stats);
if (event === "ufc.fight.stats_ready") await saveFight(data.boutId, data.boutStats, data.roundStats);
// CS2: the event is the signal. Read the stats, and try again later if they are not in yet.
if (event === "cs2.match.completed") {
await jobs.add("cs2-stats", { matchId: data.matchId }, { attempts: 6, backoff: 10 * 60 * 1000 });
}
// The job:
async function fetchCs2Stats({ matchId }) {
const res = await fetch(`https://api.citoapi.com/api/v1/cs2/matches/${matchId}/player-stats`, {
headers: { "x-api-key": process.env.CITO_API_KEY },
});
if (!res.ok) throw new Error(`stats ${res.status}`); // the queue retries
await saveStats(matchId, await res.json());
}