# Cito webhooks

Cito sends a signed JSON POST to your HTTPS URL when a sports event happens. Live delivery needs Pro and Scale (and Enterprise). Free and Starter accounts can create a test inbox and send sample events to it.

Base URL: https://api.citoapi.com/api/v1
Auth: `x-api-key: <key>` or `Authorization: Bearer cito_...`. A key only reaches its own account's webhooks.
Event catalog (public, always current): https://api.citoapi.com/api/v1/webhooks/events
OpenAPI: https://api.citoapi.com/api/v1/webhooks/openapi.json
Human docs: https://citoapi.com/docs/webhooks/
Set up with AI: https://citoapi.com/docs/webhooks/set-up-with-ai/
Dashboard: https://citoapi.com/dashboard/webhooks/

Failed requests carry `error`, `code`, `fix` and `docs`. Plan locks also carry `upgradeUrl`, `currentPlan` and `requiredPlan`.

## Quickstart

1. Pick events. `GET /webhooks/events` lists every event with status, volume, fields and a real example. Subscribe to the fewest that do the job.
2. No server yet? `POST /webhook-inbox` returns a hosted temporary URL (`inbox.url`) and a `signingSecret`, on any plan.
3. Create the webhook. On Pro or Scale:

```bash
resp=$(curl -s -X POST https://api.citoapi.com/api/v1/webhooks \
  -H "x-api-key: $CITO_API_KEY" -H "content-type: application/json" \
  -d '{"url":"https://yourapp.com/webhooks/cito","events":["cs2.match.completed"]}')

# The secret is shown once: straight to a gitignored file, never to the screen.
echo "$resp" | jq -r 'select(.webhook.secret) | "CITO_WEBHOOK_SECRET=\(.webhook.secret)"' >> .env
echo "$resp" | jq '{id: .webhook.id, warnings, next, error, code, fix, upgradeUrl}'
```

The response has `webhook.secret`, shown once. Never print it. It may also have `warnings` and a `next` hint. If the plan has no live delivery, this returns 403 with `code: WEBHOOK_PLAN_REQUIRED`, `fix` and `upgradeUrl`: no webhook exists, so `/webhooks/{id}/test` and catch-up do not apply. Use a test inbox and the local test below.

4. Verify signatures on the raw bytes (below).
5. Send a test: `POST /webhooks/{id}/test` with `{"event":"cs2.match.completed"}`. The response has `status`, `statusCode`, `latencyMs` and `diagnosis`.
6. Watch `GET /webhooks/{id}/deliveries`.

## What arrives

Headers: X-Cito-Signature, X-Cito-Event, X-Cito-Event-Id, X-Cito-Timestamp, X-Cito-Delivery-Attempt, X-Cito-Test (test deliveries only).

```json
{
  "event": "cs2.match.completed",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T15:24:03.312Z",
  "event_type": "cs2.match.completed",
  "match_id": "cs2-match-2400316",
  "previous_state": null,
  "data": {
    "matchId": "cs2-match-2400316",
    "team1Score": 2,
    "team2Score": 0
  }
}
```

- `event`: The event name.
- `id`: Unique id of this event. Use it as your idempotency key (same as the X-Cito-Event-Id header).
- `timestamp`: When Cito sent this delivery, ISO 8601 UTC.
- `event_type`: The event name.
- `match_id`: The match id, or null when the event is not about one match.
- `previous_state`: The state before the change, on events that have one. Otherwise null.
- `data`: The event body. See fields and example on each event.

A test delivery has test: true on the envelope, data.test true and the header X-Cito-Test: true. A replay has data.replayed true and data.original_event_id.

## Verify the signature

`X-Cito-Signature` is the hex HMAC-SHA256 of the exact raw request body, keyed with your webhook secret. No prefix. Hash the raw bytes before parsing JSON: re-serializing changes the bytes.

### Node.js

```js
import crypto from "node:crypto";

// rawBody: the exact bytes of the request, before JSON parsing
export function verifyCito(rawBody, signatureHeader, secret) {
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const got = signatureHeader || "";
  return got.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}
```

### Python

```python
import hashlib, hmac

def verify_cito(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), (signature_header or "").encode())
```

### Go

```go
import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
)

func VerifyCito(rawBody []byte, signatureHeader, secret string) bool {
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(signatureHeader))
}
```

### PHP

```php
<?php
$raw = file_get_contents("php://input"); // the exact bytes, before json_decode
$expected = hash_hmac("sha256", $raw, getenv("CITO_WEBHOOK_SECRET"));
$got = $_SERVER["HTTP_X_CITO_SIGNATURE"] ?? "";
if (!hash_equals($expected, $got)) {
    http_response_code(401);
    exit;
}
$payload = json_decode($raw, true);
http_response_code(200);
```

### Ruby

```ruby
require "openssl"
require "rack/utils"

# raw_body = request.raw_post (Rails) or request.body.read (Rack), before JSON parsing
def verify_cito(raw_body, signature_header, secret)
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
  Rack::Utils.secure_compare(expected, signature_header.to_s)
end
```

Check your code offline with this test vector: secret `cito_test_secret_123`, body below, expected signature `97db77f5cd96e09a70dcf6cc75fc5d92d780c41ce82c853b51e4070954c54171`.

```json
{"event":"lol.live.state","id":"sample-event-id-123","timestamp":"2026-06-07T16:00:00.000Z","data":{"gameId":"lol-game-123","matchId":"lol-match-123","league":"LEC","gameTime":1337,"blueTeam":{"tag":"G2","kills":7,"gold":24800,"towers":3,"dragons":2,"barons":0},"redTeam":{"tag":"FNC","kills":5,"gold":23700,"towers":2,"dragons":1,"barons":0}}}
```

## Full handlers

### Node / Express

```js
// server.js  (npm install express)
import crypto from "node:crypto";
import express from "express";

const app = express();
const seen = new Set(); // use your database in production

// express.raw keeps the exact bytes. express.json() here would break the signature.
app.post("/webhooks/cito", express.raw({ type: "*/*" }), (req, res) => {
  const expected = crypto
    .createHmac("sha256", process.env.CITO_WEBHOOK_SECRET)
    .update(req.body)
    .digest("hex");
  const got = req.get("X-Cito-Signature") || "";
  const valid =
    got.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
  if (!valid) return res.sendStatus(401);

  res.sendStatus(200); // answer within 10 seconds, then work

  const { id, event, data } = JSON.parse(req.body.toString("utf8"));
  // The id is the same on every retry and replay. A replay you asked for gets through, so make the work safe to repeat.
  // test is true on the envelope of test deliveries: keep those out of your real data.
  if (seen.has(id) && !data.replayed) return;
  seen.add(id);

  switch (event) {
    case "cs2.match.completed":
      // handle cs2.match.completed
      break;
  }
});

app.listen(3000);
```

### Next.js

```ts
// app/api/webhooks/cito/route.ts
import crypto from "node:crypto";

const seen = new Set<string>(); // use your database in production

export async function POST(req: Request) {
  const raw = await req.text(); // the exact body, before any JSON parsing
  const expected = crypto
    .createHmac("sha256", process.env.CITO_WEBHOOK_SECRET!)
    .update(raw, "utf8")
    .digest("hex");
  const got = req.headers.get("x-cito-signature") ?? "";
  const valid =
    got.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
  if (!valid) return new Response("invalid signature", { status: 401 });

  const payload = JSON.parse(raw);
  // The id is the same on every retry and replay. A replay you asked for gets through, so make handle() safe to repeat.
  // payload.test is true on test deliveries: keep those out of your real data.
  if (!seen.has(payload.id) || payload.data?.replayed) {
    seen.add(payload.id);
    // Do slow work in a queue. Answer within 10 seconds.
    handle(payload).catch(console.error);
  }
  return new Response("ok");
}

async function handle({ event, data }: { event: string; data: any }) {
  switch (event) {
    case "cs2.match.completed":
      // handle cs2.match.completed
      break;
  }
}
```

### Python / FastAPI

```python
# main.py  (pip install fastapi uvicorn)
import hashlib, hmac, json, os
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request

app = FastAPI()
SECRET = os.environ["CITO_WEBHOOK_SECRET"].encode()
seen = set()  # use your database in production

def handle(event, data):
    if event == "cs2.match.completed":
        pass  # handle cs2.match.completed

@app.post("/webhooks/cito")
async def cito_webhook(request: Request, tasks: BackgroundTasks):
    raw = await request.body()  # the exact bytes, before any JSON parsing
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected.encode(), request.headers.get("x-cito-signature", "").encode()):
        raise HTTPException(status_code=401, detail="invalid signature")

    payload = json.loads(raw)
    # The id is the same on every retry and replay. A replay you asked for gets through, so make handle() safe to repeat.
    # payload["test"] is True on test deliveries: keep those out of your real data.
    if payload["id"] not in seen or payload["data"].get("replayed"):
        seen.add(payload["id"])
        tasks.add_task(handle, payload["event"], payload["data"])  # runs after we answer
    return {"ok": True}
```

### Python / Flask

```python
# app.py  (pip install flask)
import hashlib, hmac, json, os
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["CITO_WEBHOOK_SECRET"].encode()
seen = set()  # use your database in production

def handle(event, data):
    if event == "cs2.match.completed":
        pass  # handle cs2.match.completed

@app.post("/webhooks/cito")
def cito_webhook():
    raw = request.get_data()  # the exact bytes, before any JSON parsing
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected.encode(), request.headers.get("X-Cito-Signature", "").encode()):
        abort(401)

    payload = json.loads(raw)
    # The id is the same on every retry and replay. A replay you asked for gets through, so make handle() safe to repeat.
    # payload["test"] is True on test deliveries: keep those out of your real data.
    if payload["id"] not in seen or payload["data"].get("replayed"):
        seen.add(payload["id"])
        handle(payload["event"], payload["data"])  # move slow work to a queue: answer within 10 seconds
    return "", 200
```

### Go

```go
// main.go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"log"
	"net/http"
	"os"
	"sync"
)

var (
	secret = []byte(os.Getenv("CITO_WEBHOOK_SECRET"))
	seen   sync.Map // use your database in production
)

type envelope struct {
	Event string          `json:"event"`
	ID    string          `json:"id"`
	Data  json.RawMessage `json:"data"`
}

func handle(event string, data json.RawMessage) {
	switch event {
	case "cs2.match.completed":
		// handle cs2.match.completed
	}
}

func citoWebhook(w http.ResponseWriter, r *http.Request) {
	raw, err := io.ReadAll(r.Body) // the exact bytes, before any JSON parsing
	if err != nil {
		http.Error(w, "bad body", http.StatusBadRequest)
		return
	}
	mac := hmac.New(sha256.New, secret)
	mac.Write(raw)
	expected := hex.EncodeToString(mac.Sum(nil))
	if !hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Cito-Signature"))) {
		http.Error(w, "invalid signature", http.StatusUnauthorized)
		return
	}

	var p envelope
	if err := json.Unmarshal(raw, &p); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	// The id is the same on every retry and replay. A replay you asked for gets through, so make handle safe to repeat.
	// "test" is true on test deliveries: keep those out of your real data.
	var replay struct {
		Replayed bool `json:"replayed"`
	}
	_ = json.Unmarshal(p.Data, &replay)
	if _, dup := seen.LoadOrStore(p.ID, true); !dup || replay.Replayed {
		go handle(p.Event, p.Data) // answer first, work after
	}
	w.WriteHeader(http.StatusOK)
}

func main() {
	http.HandleFunc("/webhooks/cito", citoWebhook)
	log.Fatal(http.ListenAndServe(":3000", nil))
}
```

## Delivery rules

- Success: Any 2xx answer. Answer within 10 seconds.
- Attempts: 3 per event, 1 and 2 seconds apart. Retried: Timeouts, network errors, 5xx, 408 and 429. Not retried: Other 4xx answers.
- Redirects: 307 and 308 keep the POST. 301, 302 and 303 turn it into a GET with no body, so set the webhook to the final URL.
- Ordering: Not guaranteed. Use data timestamps or seq-like fields, and treat each event independently.
- Dedupe: the envelope `id` and `X-Cito-Event-Id` are derived from the event itself, identical on every retry and replay. Store them. A replay also has `data.replayed` true and `data.original_event_id` (automatic catch-up adds `data.catch_up` true), so let a replay through your dedupe when you asked for it, and make that work safe to repeat. Test deliveries have a fresh id each time and `test: true`.
- Pause: After 3 failed events in a row, Cito pauses sending to that webhook: 1, then 5, 15 and 30 minutes (30 is the cap). Events that occur during a pause are NOT queued for you. They are logged with status "skipped_backoff" and are not sent again unless you ask. Any success ends the pause.
- After recovery: When the first delivery after a pause succeeds, Cito re-sends up to 50 missed match and result events from the last 24 hours, oldest first, with data.catch_up true and the same event id. POST /api/v1/webhooks/{id}/catch-up re-sends any still missing. POST /api/v1/webhooks/{id}/replay re-sends a specific event or match.
- Switched off: A webhook is switched off (isActive false, reason in lastError) after 100 failures in a row, 20 in a row when the answer is permanent (401, 403, 404, 410, 530, DNS or certificate errors), or 50 in a row with no success for 7 days. Turn back on: POST /api/v1/webhooks/{id}/reset, or PATCH with {"isActive":true}.
- Addresses: Deliveries come from Cito servers and may change. Verify X-Cito-Signature instead of allow-listing addresses.

## Filters

Set on a webhook as {"filters":{"league":"LEC"}}. Each field takes one value or a list, compared case-insensitively. Fields per event are on each event.

- `league`: Only this league. On CS2 events the league is always cs2, so it does not narrow them.
- `matchId`: Only this match. Take the id from the schedule or live routes.
- `gameId`: Only one game inside a series.
- `side`: Only events for one side of the map.
- `tour`: Leave empty for every tour.
- `tournamentId`: Find ids with /tennis/tournaments?q=.
- `playerId`: Matches either player. Find ids with /tennis/players/search?q=.
- `eventSlug`: Only this card: slug or id from /ufc/events.
- `eventId`: 
- `boutId`: Only this fight: id from /ufc/events/{slug}/bouts.
- `fighterSlug`: Only fights with this fighter. On card events, cards or changes that include them.
- `fighterId`: 
- `series`: UFC, Contender Series or Road to UFC.

A filter on a field an event does not carry stops every delivery of that event. Each event lists the filters it supports.

## Test inbox

- `POST /webhook-inbox` with `{"name":"..."}`: `inbox.id`, `inbox.url`, `inbox.expiresAt`, `signingSecret` (once), `limits`, `plan.liveDelivery`.
- `POST /webhook-inbox/{id}/sample` with `{"event":"..."}`: a real recent sample, signed and marked as a test. Free and Starter get samples at least 2 hours old.
- `GET /webhook-inbox/{id}/requests`: `requests[]` with `signatureValid`, `latestRequestId`, `pollEverySeconds`. Pass `after=<latestRequestId>` to poll.
- `GET /webhook-inbox/{id}/stream`: server-sent events `ready`, `request`, `end`. Closes after 5 minutes.
- `DELETE /webhook-inbox/{id}/requests` clears it. `DELETE /webhook-inbox/{id}` removes it.
- Limits: 3 live inboxes, 10 created per day, 24 hours, 100 requests, 64 KB per body.
- Secrets: samples are signed with the inbox `signingSecret`. Deliveries from a webhook pointed at the inbox are signed with that webhook's secret. Both show `signatureValid: true`.
- An inbox stores requests and never forwards them. It cannot call your handler.
- Anyone holding the URL can post to it. Treat bodies as untrusted text. Requests that fail the signature check are kept apart and marked `signatureValid: false`.

## Test your handler locally

A webhook cannot reach localhost, and an inbox does not forward. Take a real payload from an inbox, sign it with your own secret and post it to your handler. Works on every plan. To test delivery itself, deploy the handler or use a stable tunnel, then call `POST /webhooks/{id}/test`. Test deliveries have `test: true` and real sample data: never apply them to real data such as settlement.

```bash
# 1. Save a real payload (GET /webhook-inbox/{id}/requests returns it as .requests[0].body)
curl -s "https://api.citoapi.com/api/v1/webhook-inbox/$INBOX_ID/requests?limit=1" -H "x-api-key: $CITO_API_KEY" \
  | jq -c '.requests[0].body' > sample.json

# 2. Sign those exact bytes with your own secret, and post them to your handler. Expect 2xx.
SIG=$(openssl dgst -sha256 -hmac "$CITO_WEBHOOK_SECRET" -r < sample.json | cut -d' ' -f1)
curl -i -X POST http://localhost:3000/webhooks/cito -H "X-Cito-Signature: $SIG" \
  -H "content-type: application/json" --data-binary @sample.json

# 3. Change one byte of the signature. Expect 401.
```

## Endpoints

- catalog: GET /api/v1/webhooks/events?game=&event=&examples=false&fields=false (public)
- openapi: GET /api/v1/webhooks/openapi.json (public)
- list: GET /api/v1/webhooks
- get: GET /api/v1/webhooks/{id}
- create: POST /api/v1/webhooks
- update: PATCH /api/v1/webhooks/{id}
- delete: DELETE /api/v1/webhooks/{id}
- test: POST /api/v1/webhooks/{id}/test
- regenerate_secret: POST /api/v1/webhooks/{id}/regenerate-secret
- reset: POST /api/v1/webhooks/{id}/reset
- deliveries: GET /api/v1/webhooks/{id}/deliveries
- delivery: GET /api/v1/webhooks/{id}/deliveries/{deliveryId}
- replay: POST /api/v1/webhooks/{id}/replay
- catch_up: POST /api/v1/webhooks/{id}/catch-up
- inbox: POST, GET /api/v1/webhook-inbox

Deliveries take `limit`, `cursor`, `status` (delivered, failed, skipped_backoff), `event`, `matchId`, `since`, `until`, `include=summary`. Replay takes `{"eventId":"..."}` or `{"matchId":"...","eventType":"..."}`. Catch-up takes `{"since":"ISO","limit":50,"dryRun":true}` and covers match and result events, not live ticks.

## MCP

Hosted server: https://api.citoapi.com/mcp. Send your key as `x-api-key`. Local: `npx cito-mcp install --key cito_your_key_here`. Tools: explain_webhook_setup, list_webhook_events, list_webhooks, create_webhook, update_webhook, delete_webhook (needs confirm: true), regenerate_webhook_secret, test_webhook, webhook_deliveries, replay_webhook, catch_up_webhook, create_webhook_inbox, list_webhook_inboxes, read_webhook_inbox, send_inbox_sample, clear_webhook_inbox, delete_webhook_inbox.

## Troubleshooting

- 401 from your handler: you verified a parsed body. Hash the raw bytes. Or the secret is old: regenerating replaces it.
- Timeouts: answer 2xx first and work after. Timeout is 10 seconds.
- Nothing arriving: check the webhook is active and not paused (`deliveryPaused`, `nextRetryAt`), the event's status in the catalog, and any filter on a field the event does not carry. Send a test.
- Duplicates: normal after a failed answer. Dedupe on `id`. Two webhooks on one URL also send each event twice.
- Cloudflare 403 or 1020 on your side: your WAF blocked the POST. Allow the path and rely on the signature rather than addresses, which can change.

## Events by sport

Status was measured at 2026-11-08T20:30:57.835Z. "None recorded yet" means you can subscribe, but do not rely on it.

### Counter-Strike 2

Detail: https://citoapi.com/docs/api/cs2/webhooks.md

- `cs2.match.started` (Active, about 25 per day across all matches): A CS2 match has started, with event name and team names.
- `cs2.match.live_started` (Active, about 25 per day across all matches): Live round-by-round tracking began for a CS2 match.
- `cs2.match.live_ended` (Active, about 25 per day across all matches): Live tracking ended for a CS2 match, with the last known state of the map and players in data.final_state.
- `cs2.match.completed` (Active, about 29 per day across all matches): A CS2 match has finished, with the final map score and winner.
- `cs2.map.started` (Active, about 343 per day across all matches (about 25 per match)): A map of a CS2 series is known or has started.
- `cs2.map.completed` (Active, about 21 per day across all matches (about 2 per match)): A map of a CS2 series finished, with the round score and winner.
- `cs2.round.started` (Active, about 1,184 per day across all matches (about 47 per match)): A round started, with teams and sides.
- `cs2.round.ended` (Active, about 1,222 per day across all matches (about 51 per match)): A round ended, with winner side and reason.
- `cs2.bomb.planted` (Active, about 660 per day across all matches (about 28 per match)): The bomb was planted: site, player and players alive per side.
- `cs2.bomb.defused` (Active, about 1 per day across all matches): The bomb was defused.
- `cs2.bomb.exploded` (Active, about 1 per day across all matches): The bomb exploded.
- `cs2.score.updated` (Active, about 2,225 per day across all matches (about 90 per match)): The live score changed.
- `cs2.player.state_changed` (Active, about 58,003 per day across all matches (about 2,417 per match)): A player changed: health, money, weapon, armor, kills or similar. data.changes lists what changed.
- `cs2.live.state` (Active, about 5,582 per day across all matches (about 221 per match)): A snapshot of a live match: status, score and current map.
- `cs2.live.player_stats_updated` (Active, about 565 per day across all matches (about 57 per match)): Live player stats were refreshed. A count only: data.playerStatRows says how many rows changed.
- `cs2.ranking.updated` (None recorded yet, none recorded so far): A CS2 team ranking update.

### League of Legends

Detail: https://citoapi.com/docs/api/league-of-legends/webhooks.md

- `lol.match.started` (Active, about 5 per week): A League of Legends series has started.
- `lol.match.completed` (Active, about 5 per week): A League of Legends series has finished, with the final score and winner.
- `lol.score.updated` (Active, about 132 per day across all matches (about 59 per match)): A game inside a series ended and the series score changed.
- `lol.standings.updated` (None recorded yet, none recorded so far): League standings changed.
- `lol.transfer.new` (Active, about 5 per week): A player transfer was recorded.
- `lol.tournament.started` (None recorded yet, none recorded so far): A tournament has started.
- `lol.tournament.completed` (None recorded yet, none recorded so far): A tournament has finished.
- `lol.live.game_started` (Active, about 5 per day across all matches (about 4 per match)): A game inside a live series has started.
- `lol.live.state` (Active, about 127 per day across all matches (about 177 per match)): A snapshot of a live game: gold, kills, towers, barons, dragons and inhibitors for both sides.
- `lol.live.objective` (Active, about 19 per day across all matches (about 27 per match)): A side took an objective (data.objective, data.side), with running totals.
- `lol.live.gold_swing` (Active, about 22 per day across all matches (about 31 per match)): The gold lead moved sharply.
- `lol.live.kill_update` (Active, about 41 per day across all matches (about 57 per match)): Team kill totals changed.
- `lol.live.tower_destroyed` (Active, about 31 per day across all matches (about 43 per match)): A tower fell.
- `lol.live.player_down` (Quiet, none in the last 7 days; last sent 2026-08-21): One or more players died, with champion, role and death count.
- `lol.live.map_delta` (Quiet, none in the last 7 days; last sent 2026-08-21): Per-player alive state and map position changed.
- `lol.live.pick_locked` (Quiet, none in the last 7 days; last sent 2026-08-21): A champion pick was locked in.
- `lol.game.completed` (Active, about 3 per week): One game of a series finished, with draft, team stats, players and first objectives.
- `lol.draft.updated` (Quiet, none in the last 7 days; last sent 2026-08-21): A pick or ban was made in a live draft.

### Dota 2

Detail: https://citoapi.com/docs/api/dota-2/webhooks.md

- `dota2.draft.updated` (Active, about 22 per day across all matches (about 3 per match)): The picks and bans of a Dota 2 game, with hero names and team ids.

### Call of Duty

Detail: https://citoapi.com/docs/api/call-of-duty/webhooks.md

- `cod.transfer.new` (Active, about 4 per day across all matches): New Call of Duty roster moves were recorded. A count only: data.count and data.timestamp, with no player names.
- `cod.match.live_update` (None recorded yet, none recorded so far): Live map progression and score changes for a Call of Duty match.
- `cod.match.completed` (Active, about 1 per day across all matches): Call of Duty matches finished. A count only: data.matchesCompleted and data.timestamp, with no match ids.
- `cod.standings.updated` (Active, about 96 per day across all matches): Call of Duty standings were refreshed. A count only: data.count and data.timestamp.
- `cod.player.stats_updated` (None recorded yet, none recorded so far): Call of Duty player stats changed.
- `cod.tournament.started` (None recorded yet, none recorded so far): A Call of Duty tournament has started.
- `cod.tournament.completed` (Active, about 1 per week): A Call of Duty tournament has finished.

### Tennis

Detail: https://citoapi.com/docs/api/tennis/webhooks.md

- `tennis.match.started` (Active, about 4 per day across all matches): A tennis match went live. data is the match as /tennis/matches/live returns it.
- `tennis.set.completed` (Active, about 8 per day across all matches (about 2 per match)): A set finished: data.set_number, data.set_score, data.set_winner_side, data.set_winner, plus the live match.
- `tennis.match.completed` (Active, about 3 per day across all matches): A tennis result is final. data is the match as /tennis/matches/{id} returns it, without stats and point_stats.
- `tennis.match.stats_ready` (Active, about 4 per week): The full serve statistics of a completed match are available. data is the full /tennis/matches/{id} body.

### UFC

Detail: https://citoapi.com/docs/api/ufc/webhooks.md

- `ufc.event.started` (None recorded yet, none recorded so far): A UFC card has started, with the public card in data.bouts.
- `ufc.fight.started` (None recorded yet, none recorded so far): A fight has started: data.startedAt is the estimated bell.
- `ufc.round.started` (None recorded yet, none recorded so far): A round has started: data.round.
- `ufc.round.completed` (None recorded yet, none recorded so far): A round has ended: data.round, data.endedBy (bell or finish), and each fighter's provisional round numbers in data.stats.
- `ufc.fight.completed` (None recorded yet, none recorded so far): A fight result: data.outcome (win, draw or no_contest), data.winner, data.loser, data.method, data.methodDetails, data.resultRound, data.resultTime.
- `ufc.fight.stats_ready` (None recorded yet, none recorded so far): The official whole-fight and per-round stats of a finished fight are available, in data.boutStats and data.roundStats, with the final result.
- `ufc.card.updated` (None recorded yet, none recorded so far): A UFC card changed: data.changes lists bouts added, moved between sections or cancelled.
- `ufc.event.completed` (None recorded yet, none recorded so far): A UFC card has finished, with every result in data.results.
