Docs/CoD API/Webhooks

Call of Duty webhooks

Get a signed POST when Call of Duty matches finish, standings refresh or roster moves are recorded. These events carry no filter fields, so you receive all of them.

Which events do I need?

Live scoreboard

cod.match.live_updatecod.match.completed

Map progression and score during a match, then the final.

Settle bets

cod.match.completed

Tells you a match finished. Read /cod/matches for the result.

Results only

cod.match.completed

Tells you matches finished. Read /cod/matches for details.

Alerts

cod.transfer.newcod.match.completed

Roster moves and finished matches.

Filters

These events carry no filter fields. Subscribing means you receive all of them. Do not set a filter on a webhook that includes them: a filter on a field an event does not carry stops that event.

Recipes

Refresh on a signal

Treat each event as a signal to re-read the REST route. That also keeps working if you missed one.
JavaScript
const REREAD = {
  "cod.match.completed": "/api/v1/cod/matches",
  "cod.standings.updated": "/api/v1/cod/standings",
  "cod.transfer.new": "/api/v1/cod/transfers",
};

if (REREAD[event]) await refresh(REREAD[event]);

More: recipes for every sport.

Good to know

  • Do not set a filter on a webhook that includes these events. A filter on a field an event does not carry stops that event.
  • cod.match.live_update is for live screens, and cod.match.completed fires when a series is finished. Subscribe to both for a live screen with a final.
  • cod.standings.updated fires on a refresh, which does not by itself mean a position moved.

All 7 events

4 of 7 were sent in the 7 days to 2026-11-08. None recorded yet means you can subscribe, but do not rely on the event. For the current status of each, read GET /api/v1/webhooks/events?game=cod.

cod.transfer.newActive

New Call of Duty roster moves were recorded. A count only: data.count and data.timestamp, with no player names.

When
When a refresh finds new roster moves.
Frequency
about 4 per day across all matches
Delivery
Median 1.0 s, p95 4.9 s, from the moment Cito recorded the event to the moment a customer endpoint answered (119 recent deliveries).
Plan
Pro and Scale
Filters
None
Use it to
Re-read /cod/transfers and refresh roster pages.

Treat it as a signal to re-read /cod/transfers. It carries no entity ids.

Fields in data (2)
FieldTypeMeaning
countnumberHow many records changed.
timestampstringWhen this event happened, ISO 8601 UTC.
Full example (real payload)
JSON
{
  "event": "cod.transfer.new",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-05T17:25:25.657Z",
  "event_type": "cod.transfer.new",
  "match_id": null,
  "previous_state": null,
  "data": {
    "count": 10,
    "timestamp": "2026-11-05T17:25:25.618Z"
  }
}
cod.match.live_updateNone recorded yet

Live map progression and score changes for a Call of Duty match.

When
During a live match.
Frequency
none recorded so far
Plan
Pro and Scale
Filters
None
Use it to
Update a live series view with map progression and score.
You can subscribe, but none has been recorded. Do not rely on it.

No example recorded yet.

cod.match.completedActive

Call of Duty matches finished. A count only: data.matchesCompleted and data.timestamp, with no match ids.

When
When a refresh finds newly completed matches.
Frequency
about 1 per day across all matches
Delivery
Median 1.7 s, p95 6.0 s, from the moment Cito recorded the event to the moment a customer endpoint answered (445 recent deliveries).
Plan
Pro and Scale
Filters
None
Use it to
Re-read /cod/matches and record the result.

Treat it as a signal to re-read /cod/matches. It carries no entity ids.

Fields in data (2)
FieldTypeMeaning
matchesCompletednumberHow many matches finished.
timestampstringWhen this event happened, ISO 8601 UTC.
Full example (real payload)
JSON
{
  "event": "cod.match.completed",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-10-14T16:45:07.511Z",
  "event_type": "cod.match.completed",
  "match_id": null,
  "previous_state": null,
  "data": {
    "timestamp": "2026-10-14T16:45:07.463Z",
    "matchesCompleted": 14
  }
}
cod.standings.updatedActive

Call of Duty standings were refreshed. A count only: data.count and data.timestamp.

When
On each standings refresh, whether or not a position moved.
Frequency
about 96 per day across all matches
Delivery
Median 1.2 s, p95 5.9 s, from the moment Cito recorded the event to the moment a customer endpoint answered (2,401 recent deliveries).
Plan
Pro and Scale
Filters
None
Use it to
Re-read standings. It does not by itself mean a position moved.

Treat it as a signal to re-read standings. It carries no standings rows and does not mean a position changed.

Fields in data (2)
FieldTypeMeaning
countnumberHow many records changed.
timestampstringWhen this event happened, ISO 8601 UTC.
Full example (real payload)
JSON
{
  "event": "cod.standings.updated",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T20:15:04.123Z",
  "event_type": "cod.standings.updated",
  "match_id": null,
  "previous_state": null,
  "data": {
    "count": 12,
    "timestamp": "2026-11-08T20:15:04.083Z"
  }
}
cod.player.stats_updatedNone recorded yet

Call of Duty player stats changed.

When
When player stats are updated.
Frequency
none recorded so far
Plan
Pro and Scale
Filters
None
Use it to
Refresh a player page or leaderboard.
You can subscribe, but none has been recorded. Do not rely on it.

No example recorded yet.

cod.tournament.startedNone recorded yet

A Call of Duty tournament has started.

When
When a tournament begins.
Frequency
none recorded so far
Plan
Pro and Scale
Filters
None
Use it to
Open a tournament hub.
You can subscribe, but none has been recorded. Do not rely on it.

No example recorded yet.

cod.tournament.completedActive

A Call of Duty tournament has finished.

When
When a tournament ends.
Frequency
about 1 per week
Plan
Pro and Scale
Filters
None
Use it to
Close out a tournament page.

No example recorded yet.

Handler

Verify the signature on the raw bytes, answer fast, dedupe on the id. The same for every sport. PHP, Ruby and more.

Node / Express
JavaScript
// 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 "cod.transfer.new":
      // handle cod.transfer.new
      break;
    case "cod.match.completed":
      // handle cod.match.completed
      break;
    case "cod.standings.updated":
      // handle cod.standings.updated
      break;
  }
});

app.listen(3000);
Next.js
TypeScript
// 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 "cod.transfer.new":
      // handle cod.transfer.new
      break;
    case "cod.match.completed":
      // handle cod.match.completed
      break;
    case "cod.standings.updated":
      // handle cod.standings.updated
      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 == "cod.transfer.new":
        pass  # handle cod.transfer.new
    elif event == "cod.match.completed":
        pass  # handle cod.match.completed
    elif event == "cod.standings.updated":
        pass  # handle cod.standings.updated

@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 == "cod.transfer.new":
        pass  # handle cod.transfer.new
    elif event == "cod.match.completed":
        pass  # handle cod.match.completed
    elif event == "cod.standings.updated":
        pass  # handle cod.standings.updated

@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 "cod.transfer.new":
		// handle cod.transfer.new
	case "cod.match.completed":
		// handle cod.match.completed
	case "cod.standings.updated":
		// handle cod.standings.updated
	}
}

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))
}
Was this page helpful?