Docs/Webhooks/Recipes

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.

JavaScript
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}/state

League 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.

JavaScript
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.

Dota 2 has one event, 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_ended only 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.completed and branch on data.outcome: COMPLETED, RETIREMENT, WALKOVER, DEFAULT or ABANDONED.
  • UFC: settle on ufc.fight.completed and branch on data.outcome: win, draw or no_contest. Stat markets wait for ufc.fight.stats_ready.
  • Skip test deliveries: payload.test is true on them.
JavaScript
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.

JavaScript
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:

JSON
{ "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_ready carries the full match with stats and point_stats, minutes or hours after the result. It is not sent when a match's statistics stay partial.
  • UFC: ufc.fight.stats_ready carries boutStats and roundStats. Round numbers on ufc.round.completed are provisional; these are official.
  • LoL: when data.statsPending is true on lol.match.completed or lol.game.completed, read the match or game route again later.
  • CS2: on cs2.match.completed read /cs2/matches/{matchId}/player-stats, and retry on a schedule if a map has no lines yet.
  • Call of Duty: treat cod.match.completed as a signal to re-read /cod/matches.
JavaScript
// 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());
}
Was this page helpful?