Docs/LoL API/Webhooks

League of Legends webhooks

Get a signed POST when a series starts, a game ends, the gold lead swings, an objective falls or the final score is in. Filter by league, match, game or side.

Which events do I need?

Live scoreboard

lol.live.game_startedlol.live.statelol.score.updatedlol.match.completed

A live snapshot of gold, kills and objectives during each game, the series score between games, and the final result.

Settle bets

lol.match.completedlol.game.completed

Final series score and winner, plus each game. A corrected score sends a second lol.match.completed, so make settlement re-runnable.

Results only

lol.match.completed

One event per final series score.

Alerts

lol.match.startedlol.score.updatedlol.match.completed

Series start, each game result, and the final.

Filters

Set on the webhook, for example {"filters":{"league":"..."}}. Each takes one value or a list, compared case-insensitively. A filter on a field an event does not carry stops every delivery of that event, so each event below lists the filters it supports.

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.

Recipes

Live scoreboard

Subscribe to lol.live.game_started, lol.live.state, lol.score.updated and lol.match.completed. Use data.sampledAt to skip older snapshots.
JavaScript
if (event === "lol.live.state") {
  const last = games.get(data.gameId);
  if (!last || data.sampledAt > last.sampledAt) games.set(data.gameId, data);
}

Alert on big moments

Subscribe to lol.live.objective and lol.live.gold_swing, filtered to one league. data.swing is the change in data.goldDiff, and a negative goldDiff means red leads.
JSON
{ "events": ["lol.live.objective", "lol.live.gold_swing"], "filters": { "league": "LEC" } }

Store every game

On lol.game.completed save the draft, team stats, players and first objectives. Read /lol/games/{gameId} later if data.statsPending is true.

More: recipes for every sport.

Good to know

  • Before relying on live events for a match, read GET /api/v1/lol/matches/{matchId}/coverage: it says whether live state events are expected.
  • data.teams.red and data.teams.blue on live events are team ids. Pass canonicalGameId or gameId to /lol/games/{gameId}.
  • lol.match.completed fires once per final score, so a corrected score sends a second event. Make settlement safe to re-run.
  • data.statsPending true means per-game stats are still arriving. Read the match route again later.

All 18 events

11 of 18 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=lol.

lol.match.startedActive

A League of Legends series has started.

When
When a series goes live. Once per match.
Frequency
about 5 per week
Delivery
Median 1.3 s, p95 2.0 s, from the moment Cito recorded the event to the moment a customer endpoint answered (77 recent deliveries).
Plan
Pro and Scale
Filters
league, matchId
Use it to
Flip a series to live in your app, or post a “series starting” alert.
Fields in data (12)
FieldTypeMeaning
coverageLevelnull or stringHow much live detail this series has.
formatstringSeries format, for example Bo5.
leaguestringLeague name, or the sport id on CS2 events.
leagueNamestringDisplay name of the league.
matchIdstringThe match id. Works on the match routes for this sport.
payload_versionnumberVersion of this payload shape. Newer events carry more fields.
scheduledStartstringScheduled start time, ISO 8601 UTC.
startTimestringStart time, ISO 8601 UTC.
streamLinksnull or objectStream links when known, otherwise null.
teamsarrayThe teams in this match.
tournamentstringTournament id.
tournamentNamestringDisplay name of the tournament.
Full example (real payload)
JSON
{
  "event": "lol.match.started",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-04T22:10:06.862Z",
  "event_type": "lol.match.started",
  "match_id": "lol-match-115660540726095115",
  "previous_state": null,
  "data": {
    "teams": [
      "gen",
      "lyon-gaming"
    ],
    "format": "Bo5",
    "league": "Worlds",
    "matchId": "lol-match-115660540726095115",
    "startTime": "2026-11-04T22:00:00Z",
    "leagueName": "Worlds",
    "tournament": "lol-worlds_2026",
    "streamLinks": null,
    "coverageLevel": null,
    "scheduledStart": "2026-11-04T22:00:00Z",
    "tournamentName": "Worlds 2026",
    "payload_version": 2
  }
}
lol.match.completedActive

A League of Legends series has finished, with the final score and winner.

When
When a series ends. Once per final score, so a corrected score sends a second event.
Frequency
about 5 per week
Delivery
Median 1.0 s, p95 1.8 s, from the moment Cito recorded the event to the moment a customer endpoint answered (147 recent deliveries).
Plan
Pro and Scale
Filters
league, matchId
Use it to
Settle on the final score and winner. If data.statsPending is true, read /lol/matches/{matchId} again later for stats.

data.statsPending true means per-game stats are still arriving. data.games and data.players appear on payload_version 2 events.

Fields in data (11)
FieldTypeMeaning
formatstringSeries format, for example Bo5.
gamesarrayPer-game results of this series, on newer payloads.
leaguestringLeague name, or the sport id on CS2 events.
matchIdstringThe match id. Works on the match routes for this sport.
payload_versionnumberVersion of this payload shape. Newer events carry more fields.
playersarrayPer-player stats, on newer payloads.
scorestringSeries score, team1-team2 in the order of data.teams.
statsPendingbooleanTrue when per-game stats are still arriving. Read the match route again later.
teamsarrayThe teams in this match.
tournamentstringTournament id.
winnerstringWinning team.
Full example (real payload)
JSON
{
  "event": "lol.match.completed",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-10-12T10:26:35.708Z",
  "event_type": "lol.match.completed",
  "match_id": "lol-match-117133830110912099",
  "previous_state": null,
  "data": {
    "games": [
      {
        "gameId": "lol-game-117133830110912100",
        "number": 1,
        "winner": null,
        "duration": 2098,
        "teamStats": {
          "red": {
            "gold": 61949,
            "kills": 8,
            "barons": 0,
            "towers": 5,
            "dragons": 3,
            "heralds": null,
            "inhibitors": 0
          },
          "blue": {
            "gold": 73829,
            "kills": 16,
            "barons": 2,
            "towers": 10,
            "dragons": 3,
            "heralds": null,
            "inhibitors": 2
          }
        }
      },
      {
        "gameId": "lol-game-117133830110912101",
        "number": 2,
        "winner": null,
        "duration": 2391,
        "teamStats": {
          "red": {
            "gold": 75687,
            "kills": 22,
            "barons": 2,
            "towers": 10,
            "dragons": 3,
            "heralds": null,
            "inhibitors": 2
          },
          "blue": {
            "gold": 73129,
            "kills": 16,
            "barons": 1,
            "towers": 8,
            "dragons": 3,
            "heralds": null,
            "inhibitors": 1
          }
        }
      },
      {
        "gameId": "lol-game-117133830110912102",
        "number": 3,
        "winner": null,
        "duration": 1393,
        "teamStats": {
          "red": {
            "gold": 35624,
            "kills": 2,
            "barons": 0,
            "towers": 0,
            "dragons": 0,
            "heralds": null,
            "inhibitors": 0
          },
          "blue": {
            "gold": 51569,
            "kills": 17,
            "barons": 1,
            "towers": 9,
            "dragons": 3,
            "heralds": null,
            "inhibitors": 1
          }
        }
      },
      {
        "gameId": "lol-game-117133830110912103",
        "number": 4,
        "winner": null,
        "duration": 1693,
        "teamStats": {
          "red": {
            "gold": 43752,
            "kills": 5,
            "barons": 0,
            "towers": 2,
            "dragons": 1,
            "heralds": null,
            "inhibitors": 0
          },
          "blue": {
            "gold": 54983,
            "kills": 15,
            "barons": 1,
            "towers": 10,
            "dragons": 2,
            "heralds": null,
            "inhibitors": 2
          }
        }
      },
      {
        "gameId": "lol-game-117133830110912104",
        "number": 5,
        "winner": "kt",
        "duration": null,
        "teamStats": {
          "red": {
            "gold": 60691,
            "kills": 20,
            "barons": 1,
            "towers": 2,
            "dragons": 4,
            "heralds": null,
            "inhibitors": 0
          },
          "blue": {
            "gold": 67041,
            "kills": 23,
            "barons": 0,
            "towers": 10,
            "dragons": 0,
            "heralds": null,
            "inhibitors": 2
          }
        }
      }
    ],
    "score": "3-2",
    "teams": [
      "kt",
      "team-vitality"
    ],
    "format": "Bo5",
    "league": "DCGI",
    "winner": "kt",
    "matchId": "lol-match-117133830110912099",
    "players": [
      {
        "id": "b5598781-6f93-4cd8-b516-3af0b34420b6",
        "name": "VITNaakNako",
        "team": "team-vitality",
        "avgCs": 227,
        "kills": 12,
        "deaths": 22,
        "assists": 21,
        "championPool": [
          "Rumble",
          "Ambessa",
          "Camille",
          "Cho'Gath",
          "Varus"
        ]
      },
      {
        "id": "50b1177b-c863-40fa-b3a8-0d6a13c9933e",
        "name": "KTPerfecT",
        "team": "kt",
        "avgCs": 247.4,
        "kills": 13,
        "deaths": 11,
        "assists": 29,
        "championPool": [
          "K'Sante",
          "Renekton",
          "Olaf",
          "Yorick",
          "Jax"
        ]
      },
      {
        "id": "032f9c34-51e8-4231-b50a-1ffd046d1db4",
        "name": "VITLyncas",
        "team": "team-vitality",
        "avgCs": 210.6,
        "kills": 16,
        "deaths": 13,
        "assists": 27,
        "championPool": [
          "Qiyana",
          "Lee Sin",
          "Wukong",
          "Trundle",
          "Aatrox"
        ]
      },
      {
        "id": "dea412e3-5797-4422-a999-86246dbd626d",
        "name": "VITFIESTA",
        "team": "team-vitality",
        "avgCs": 272.6,
        "kills": 18,
        "deaths": 17,
        "assists": 22,
        "championPool": [
          "Orianna",
          "Syndra",
          "Ahri",
          "Hwei",
          "Akali"
        ]
      },
      {
        "id": "be637a06-5fef-4c0c-a9db-f3cff4c4dcf1",
        "name": "VITFleshy",
        "team": "team-vitality",
        "avgCs": 36.2,
        "kills": 0,
        "deaths": 14,
        "assists": 52,
        "championPool": [
          "Rakan",
          "Alistar",
          "Nautilus",
          "Karma",
          "Blitzcrank"
        ]
      },
      {
        "id": "42674dd7-9981-4a42-973a-9152421f04a3",
        "name": "KTJiwoo",
        "team": "kt",
        "avgCs": 291.4,
        "kills": 32,
        "deaths": 14,
        "assists": 19,
        "championPool": [
          "Kalista",
          "Ezreal",
          "Miss Fortune",
          "Yunara",
          "Corki"
        ]
      },
      {
        "id": "c5ed5727-29f5-486f-82bd-697f1914f4e5",
        "name": "VITCarzzy",
        "team": "team-vitality",
        "avgCs": 293.4,
        "kills": 19,
        "deaths": 13,
        "assists": 27,
        "championPool": [
          "Xayah",
          "Ziggs",
          "Kai'Sa",
          "Caitlyn",
          "Aphelios"
        ]
      },
      {
        "id": "ecd7827d-b6bc-4281-b587-70ae5d0e4545",
        "name": "KTCuzz",
        "team": "kt",
        "avgCs": 198.4,
        "kills": 10,
        "deaths": 14,
        "assists": 50,
        "championPool": [
          "Pantheon",
          "Jarvan IV",
          "Vi",
          "Skarner",
          "Xin Zhao"
        ]
      },
      {
        "id": "27796c82-2b9c-4291-a967-fc94245ae4d6",
        "name": "KTBdd",
        "team": "kt",
        "avgCs": 281.8,
        "kills": 22,
        "deaths": 11,
        "assists": 34,
        "championPool": [
          "Ryze",
          "Viktor",
          "Taliyah",
          "Locke",
          "Vex"
        ]
      },
      {
        "id": "830c7c55-fc4d-488d-92f6-599a09833ae6",
        "name": "KTEffort",
        "team": "kt",
        "avgCs": 30.8,
        "kills": 2,
        "deaths": 15,
        "assists": 54,
        "championPool": [
          "Renata Glasc",
          "Seraphine",
          "Leona",
          "Lulu",
          "Maokai"
        ]
      }
    ],
    "tournament": "lol-tournament-117133773242499009",
    "statsPending": true,
    "payload_version": 2
  }
}
lol.score.updatedActive

A game inside a series ended and the series score changed.

When
After each game of a series, once per result.
Frequency
about 132 per day across all matches (about 59 per match)
Delivery
Median 1.2 s, p95 1.8 s, from the moment Cito recorded the event to the moment a customer endpoint answered (6,018 recent deliveries).
Plan
Pro and Scale
Filters
league, matchId
Use it to
Update the series score after each game, and alert on the game winner.

data.score is team1-team2 in the order of data.teams. data.gameWinner, data.gameId and data.gameNumber appear on most events, not all.

Fields in data (9)
FieldTypeMeaning
gameIdstringOne game inside a series.
gameNumbernumberWhich game of the series, from 1.
gameWinnernull or stringTeam that won this game.
leaguestringLeague name, or the sport id on CS2 events.
matchIdstringThe match id. Works on the match routes for this sport.
payload_versionnumberVersion of this payload shape. Newer events carry more fields.
previousScorestringSeries score before this game.
scorestringSeries score, team1-team2 in the order of data.teams.
teamsarrayThe teams in this match.
Full example (real payload)
JSON
{
  "event": "lol.score.updated",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-03T14:09:13.170Z",
  "event_type": "lol.score.updated",
  "match_id": "lol-match-116249880467010563",
  "previous_state": null,
  "data": {
    "score": "1-2",
    "teams": [
      "t1-challengers",
      "kt-challengers"
    ],
    "gameId": "lol-game-116249880467010566",
    "league": "LCK Challengers",
    "matchId": "lol-match-116249880467010563",
    "gameNumber": 3,
    "gameWinner": "kt-challengers",
    "previousScore": "2-1",
    "payload_version": 2
  }
}
lol.standings.updatedNone recorded yet

League standings changed.

When
When standings for a league change.
Frequency
none recorded so far
Plan
Pro and Scale
Filters
None
Use it to
Re-read the league table instead of polling it.
You can subscribe, but none has been recorded. Do not rely on it.

No example recorded yet.

lol.transfer.newActive

A player transfer was recorded.

When
When a new transfer is recorded.
Frequency
about 5 per week
Plan
Pro and Scale
Filters
None
Use it to
Refresh a roster page or post a roster-move alert.

No example recorded yet.

lol.tournament.startedNone recorded yet

A 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 or switch your schedule view.
You can subscribe, but none has been recorded. Do not rely on it.

No example recorded yet.

lol.tournament.completedNone recorded yet

A tournament has finished.

When
When a tournament ends.
Frequency
none recorded so far
Plan
Pro and Scale
Filters
None
Use it to
Close out a tournament page and fetch final standings.
You can subscribe, but none has been recorded. Do not rely on it.

No example recorded yet.

lol.live.game_startedActive

A game inside a live series has started.

When
When a game of a tracked series goes in progress.
Frequency
about 5 per day across all matches (about 4 per match)
Delivery
Median 1.2 s, p95 2.0 s, from the moment Cito recorded the event to the moment a customer endpoint answered (412 recent deliveries).
Plan
Pro and Scale
Filters
matchId
Use it to
Open the live view for that game and start reading live state.

Some events carry matchId only. data.teams.red and data.teams.blue are team ids. matchId, gameId and canonicalGameId identify the series and the game; pass canonicalGameId or gameId to /lol/games/{gameId}.

Fields in data (12)
FieldTypeMeaning
canonicalGameIdstringThe game id to pass to /lol/games/{gameId}.
gameIdstringOne game inside a series.
gameNumbernumberWhich game of the series, from 1.
gameStatestringState of the game, for example in_game.
gameTimenullGame clock in minutes on change events, seconds on state snapshots.
leaguestringLeague name, or the sport id on CS2 events.
leagueIdstringLeague id.
leagueSlugstringLeague slug.
matchIdstringThe match id. Works on the match routes for this sport.
sampledAtstringWhen this snapshot of the game was taken.
sequenceNumbernumberOrder of snapshots within one game. Higher is newer.
teamsobjectThe teams in this match.
Full example (real payload)
JSON
{
  "event": "lol.live.game_started",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-10-31T17:57:25.477Z",
  "event_type": "lol.live.game_started",
  "match_id": "lol-match-115660540726095085",
  "previous_state": null,
  "data": {
    "teams": {
      "red": "103461966965149786",
      "blue": "111692118851466302"
    },
    "gameId": "115660540726095087",
    "league": "Worlds",
    "matchId": "lol-match-115660540726095085",
    "gameTime": null,
    "leagueId": "98767975604431411",
    "gameState": "inProgress",
    "sampledAt": "2026-10-31T17:57:25.403Z",
    "gameNumber": 2,
    "leagueSlug": "worlds",
    "canonicalGameId": "lol-game-115660540726095087"
  }
}
lol.live.stateActive

A snapshot of a live game: gold, kills, towers, barons, dragons and inhibitors for both sides.

When
Repeatedly during a live game, on tracked leagues.
Frequency
about 127 per day across all matches (about 177 per match)
Delivery
Median 980 ms, p95 1.5 s, from the moment Cito recorded the event to the moment a customer endpoint answered (3,490 recent deliveries).
Plan
Pro and Scale
Filters
league, matchId, gameId
Use it to
Drive a live scoreboard: gold, kills, towers, dragons and barons for both sides.

data.teams.red and data.teams.blue are team ids. matchId, gameId and canonicalGameId identify the series and the game; pass canonicalGameId or gameId to /lol/games/{gameId}. Check GET /lol/matches/{matchId}/coverage for expected_webhooks before relying on it.

Fields in data (13)
FieldTypeMeaning
blueobjectTotals for the blue side.
canonicalGameIdstringThe game id to pass to /lol/games/{gameId}.
gameIdstringOne game inside a series.
gameNumbernumberWhich game of the series, from 1.
gameTimenumberGame clock in minutes on change events, seconds on state snapshots.
goldDiffnumberBlue gold minus red gold. Negative means red leads.
leaguestringLeague name, or the sport id on CS2 events.
leagueIdstringLeague id.
leagueSlugstringLeague slug.
matchIdstringThe match id. Works on the match routes for this sport.
redobjectTotals for the red side.
sampledAtstringWhen this snapshot of the game was taken.
teamsobjectThe teams in this match.
Full example (real payload)
JSON
{
  "event": "lol.live.state",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T00:46:47.528Z",
  "event_type": "lol.live.state",
  "match_id": "lol-match-115660540726095127",
  "previous_state": null,
  "data": {
    "red": {
      "gold": 78179,
      "kills": 16,
      "barons": 1,
      "towers": 9,
      "dragons": [
        "mountain",
        "mountain"
      ],
      "inhibitors": 1
    },
    "blue": {
      "gold": 71690,
      "kills": 18,
      "barons": 0,
      "towers": 5,
      "dragons": [
        "ocean",
        "hextech",
        "mountain",
        "mountain"
      ],
      "inhibitors": 0
    },
    "teams": {
      "red": "100205573495116443",
      "blue": "99566404848691211"
    },
    "gameId": "115660540726095130",
    "league": "Worlds",
    "matchId": "lol-match-115660540726095127",
    "gameTime": 4,
    "goldDiff": -6489,
    "leagueId": "98767975604431411",
    "sampledAt": "2026-11-08T00:41:06.806Z",
    "gameNumber": 3,
    "leagueSlug": "worlds",
    "canonicalGameId": "lol-game-115660540726095130"
  }
}
lol.live.objectiveActive

A side took an objective (data.objective, data.side), with running totals.

When
When a baron, dragon or inhibitor falls in a live game.
Frequency
about 19 per day across all matches (about 27 per match)
Delivery
Median 877 ms, p95 1.3 s, from the moment Cito recorded the event to the moment a customer endpoint answered (960 recent deliveries).
Plan
Pro and Scale
Filters
league, matchId, gameId, side
Use it to
Show a “Baron taken” style moment in a feed, or trigger an alert.

data.teams.red and data.teams.blue are team ids. matchId, gameId and canonicalGameId identify the series and the game; pass canonicalGameId or gameId to /lol/games/{gameId}.

Fields in data (14)
FieldTypeMeaning
canonicalGameIdstringThe game id to pass to /lol/games/{gameId}.
gameIdstringOne game inside a series.
gameNumbernumberWhich game of the series, from 1.
gameTimenumberGame clock in minutes on change events, seconds on state snapshots.
leaguestringLeague name, or the sport id on CS2 events.
leagueIdstringLeague id.
leagueSlugstringLeague slug.
matchIdstringThe match id. Works on the match routes for this sport.
objectivestringThe objective taken, for example baron.
objectiveTypestringThe kind of objective.
sampledAtstringWhen this snapshot of the game was taken.
sidestringred or blue.
teamsobjectThe teams in this match.
totalsobjectRunning totals per side.
Full example (real payload)
JSON
{
  "event": "lol.live.objective",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T00:40:13.786Z",
  "event_type": "lol.live.objective",
  "match_id": "lol-match-115660540726095127",
  "previous_state": null,
  "data": {
    "side": "blue",
    "teams": {
      "red": "100205573495116443",
      "blue": "99566404848691211"
    },
    "gameId": "115660540726095130",
    "league": "Worlds",
    "totals": {
      "redBarons": 1,
      "blueBarons": 0,
      "redDragons": 2,
      "blueDragons": 4,
      "redInhibitors": 0,
      "blueInhibitors": 0
    },
    "matchId": "lol-match-115660540726095127",
    "gameTime": 9,
    "leagueId": "98767975604431411",
    "objective": "dragon",
    "sampledAt": "2026-11-08T00:36:59.780Z",
    "gameNumber": 3,
    "leagueSlug": "worlds",
    "objectiveType": "mountain",
    "canonicalGameId": "lol-game-115660540726095130"
  }
}
lol.live.gold_swingActive

The gold lead moved sharply.

When
When the gold difference moves by a large amount between two samples of a live game.
Frequency
about 22 per day across all matches (about 31 per match)
Delivery
Median 874 ms, p95 1.4 s, from the moment Cito recorded the event to the moment a customer endpoint answered (1,553 recent deliveries).
Plan
Pro and Scale
Filters
league, matchId, gameId
Use it to
Flag momentum shifts for live commentary or in-play models.

data.swing is the change in data.goldDiff. data.teams.red and data.teams.blue are team ids. matchId, gameId and canonicalGameId identify the series and the game; pass canonicalGameId or gameId to /lol/games/{gameId}.

Fields in data (16)
FieldTypeMeaning
blueGoldnumberBlue side gold.
canonicalGameIdstringThe game id to pass to /lol/games/{gameId}.
gameIdstringOne game inside a series.
gameNumbernumberWhich game of the series, from 1.
gameTimenumberGame clock in minutes on change events, seconds on state snapshots.
goldDiffnumberBlue gold minus red gold. Negative means red leads.
leadingSidestringThe side ahead on gold.
leaguestringLeague name, or the sport id on CS2 events.
leagueIdstringLeague id.
leagueSlugstringLeague slug.
matchIdstringThe match id. Works on the match routes for this sport.
previousGoldDiffnumbergoldDiff before this change.
redGoldnumberRed side gold.
sampledAtstringWhen this snapshot of the game was taken.
swingnumberThe change in goldDiff.
teamsobjectThe teams in this match.
Full example (real payload)
JSON
{
  "event": "lol.live.gold_swing",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T00:44:16.137Z",
  "event_type": "lol.live.gold_swing",
  "match_id": "lol-match-115660540726095127",
  "previous_state": null,
  "data": {
    "swing": -1166,
    "teams": {
      "red": "100205573495116443",
      "blue": "99566404848691211"
    },
    "gameId": "115660540726095130",
    "league": "Worlds",
    "matchId": "lol-match-115660540726095127",
    "redGold": 77295,
    "blueGold": 71601,
    "gameTime": 9,
    "goldDiff": -5694,
    "leagueId": "98767975604431411",
    "sampledAt": "2026-11-08T00:40:59.782Z",
    "gameNumber": 3,
    "leagueSlug": "worlds",
    "leadingSide": "red",
    "canonicalGameId": "lol-game-115660540726095130",
    "previousGoldDiff": -4528
  }
}
lol.live.kill_updateActive

Team kill totals changed.

When
When kills change between two samples of a live game.
Frequency
about 41 per day across all matches (about 57 per match)
Delivery
Median 871 ms, p95 1.4 s, from the moment Cito recorded the event to the moment a customer endpoint answered (2,273 recent deliveries).
Plan
Pro and Scale
Filters
league, matchId, gameId
Use it to
Tick kill counters without waiting for the next state snapshot.

data.delta holds the kills added per side. data.teams.red and data.teams.blue are team ids. matchId, gameId and canonicalGameId identify the series and the game; pass canonicalGameId or gameId to /lol/games/{gameId}.

Fields in data (13)
FieldTypeMeaning
blueKillsnumberBlue side kills.
canonicalGameIdstringThe game id to pass to /lol/games/{gameId}.
deltaobjectWhat was added per side since the last sample.
gameIdstringOne game inside a series.
gameNumbernumberWhich game of the series, from 1.
gameTimenumberGame clock in minutes on change events, seconds on state snapshots.
leaguestringLeague name, or the sport id on CS2 events.
leagueIdstringLeague id.
leagueSlugstringLeague slug.
matchIdstringThe match id. Works on the match routes for this sport.
redKillsnumberRed side kills.
sampledAtstringWhen this snapshot of the game was taken.
teamsobjectThe teams in this match.
Full example (real payload)
JSON
{
  "event": "lol.live.kill_update",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T00:44:26.967Z",
  "event_type": "lol.live.kill_update",
  "match_id": "lol-match-115660540726095127",
  "previous_state": null,
  "data": {
    "delta": {
      "red": 1,
      "blue": 0
    },
    "teams": {
      "red": "100205573495116443",
      "blue": "99566404848691211"
    },
    "gameId": "115660540726095130",
    "league": "Worlds",
    "matchId": "lol-match-115660540726095127",
    "gameTime": 6,
    "leagueId": "98767975604431411",
    "redKills": 16,
    "blueKills": 18,
    "sampledAt": "2026-11-08T00:41:06.806Z",
    "gameNumber": 3,
    "leagueSlug": "worlds",
    "canonicalGameId": "lol-game-115660540726095130"
  }
}
lol.live.tower_destroyedActive

A tower fell.

When
When tower totals change between two samples of a live game.
Frequency
about 31 per day across all matches (about 43 per match)
Delivery
Median 854 ms, p95 1.3 s, from the moment Cito recorded the event to the moment a customer endpoint answered (1,916 recent deliveries).
Plan
Pro and Scale
Filters
league, matchId, gameId
Use it to
Tick tower counts and show map pressure.

data.delta holds the towers added per side. data.teams.red and data.teams.blue are team ids. matchId, gameId and canonicalGameId identify the series and the game; pass canonicalGameId or gameId to /lol/games/{gameId}.

Fields in data (13)
FieldTypeMeaning
blueTowersnumberBlue side towers.
canonicalGameIdstringThe game id to pass to /lol/games/{gameId}.
deltaobjectWhat was added per side since the last sample.
gameIdstringOne game inside a series.
gameNumbernumberWhich game of the series, from 1.
gameTimenumberGame clock in minutes on change events, seconds on state snapshots.
leaguestringLeague name, or the sport id on CS2 events.
leagueIdstringLeague id.
leagueSlugstringLeague slug.
matchIdstringThe match id. Works on the match routes for this sport.
redTowersnumberRed side towers.
sampledAtstringWhen this snapshot of the game was taken.
teamsobjectThe teams in this match.
Full example (real payload)
JSON
{
  "event": "lol.live.tower_destroyed",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-07T00:21:50.600Z",
  "event_type": "lol.live.tower_destroyed",
  "match_id": "lol-match-115660540726095103",
  "previous_state": null,
  "data": {
    "delta": {
      "red": 0,
      "blue": 4
    },
    "teams": {
      "red": "98767991853197861",
      "blue": "111692118851466302"
    },
    "gameId": "115660540726095106",
    "league": "Worlds",
    "matchId": "lol-match-115660540726095103",
    "gameTime": 0,
    "leagueId": "98767975604431411",
    "redTowers": 11,
    "sampledAt": "2026-11-07T00:21:50.176Z",
    "blueTowers": 11,
    "gameNumber": 3,
    "leagueSlug": "worlds",
    "canonicalGameId": "lol-game-115660540726095106"
  }
}
lol.live.player_downQuiet

One or more players died, with champion, role and death count.

When
When a player death is seen in a live game.
Frequency
none in the last 7 days; last sent 2026-08-21
Plan
Pro and Scale
Filters
league, matchId, gameId
Use it to
Show who is dead on a live board.
You can subscribe, but it has not been sent recently. Do not build on it alone without checking lastSentAt.

data.teams.red and data.teams.blue are team ids. matchId, gameId and canonicalGameId identify the series and the game; pass canonicalGameId or gameId to /lol/games/{gameId}.

Fields in data (11)
FieldTypeMeaning
canonicalGameIdstringThe game id to pass to /lol/games/{gameId}.
gameIdstringOne game inside a series.
gameNumbernumberWhich game of the series, from 1.
gameTimenull or numberGame clock in minutes on change events, seconds on state snapshots.
leaguestringLeague name, or the sport id on CS2 events.
leagueIdstringLeague id.
leagueSlugstringLeague slug.
matchIdstringThe match id. Works on the match routes for this sport.
playersDownarrayPlayers currently dead, with champion, role and death count.
sampledAtstringWhen this snapshot of the game was taken.
teamsobjectThe teams in this match.
Full example
JSON
{
  "event": "lol.live.player_down",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-08-21T00:42:41.281Z",
  "event_type": "lol.live.player_down",
  "match_id": "lol-match-116792888905448318",
  "previous_state": null,
  "data": {
    "teams": {
      "red": "102141671183409133",
      "blue": "114746424759893687"
    },
    "gameId": "116792888905448320",
    "league": "NACL",
    "matchId": "lol-match-116792888905448318",
    "gameTime": 771,
    "leagueId": "109511549831443335",
    "sampledAt": "2026-08-21T00:36:59.878Z",
    "gameNumber": 2,
    "leagueSlug": "nacl",
    "playersDown": [
      {
        "role": "top",
        "side": "blue",
        "deaths": 1,
        "championId": "Olaf",
        "summonerName": "DOG Dcoy",
        "participantId": 1
      },
      {
        "role": "jungle",
        "side": "blue",
        "deaths": 2,
        "championId": "Maokai",
        "summonerName": "DOG Lojaz",
        "participantId": 2
      },
      {
        "role": "top",
        "side": "red",
        "deaths": 1,
        "championId": "Ambessa",
        "summonerName": "MU Crimson",
        "participantId": 6
      },
      {
        "role": "bottom",
        "side": "red",
        "deaths": 1,
        "championId": "Jhin",
        "summonerName": "MU Scary Jerry",
        "participantId": 9
      },
      {
        "role": "support",
        "side": "red",
        "deaths": 1,
        "championId": "Nautilus",
        "summonerName": "MU Obstinatus",
        "participantId": 10
      }
    ],
    "canonicalGameId": "lol-game-116792888905448320"
  }
}
lol.live.map_deltaQuiet

Per-player alive state and map position changed.

When
During a live game.
Frequency
none in the last 7 days; last sent 2026-08-21
Plan
Pro and Scale
Filters
league, matchId, gameId
Use it to
Update a live map or alive-state overlay.
You can subscribe, but it has not been sent recently. Do not build on it alone without checking lastSentAt.

data.teams.red and data.teams.blue are team ids. matchId, gameId and canonicalGameId identify the series and the game; pass canonicalGameId or gameId to /lol/games/{gameId}.

Fields in data (11)
FieldTypeMeaning
canonicalGameIdstringThe game id to pass to /lol/games/{gameId}.
gameIdstringOne game inside a series.
gameNumbernumberWhich game of the series, from 1.
gameTimenull or numberGame clock in minutes on change events, seconds on state snapshots.
leaguestringLeague name, or the sport id on CS2 events.
leagueIdstringLeague id.
leagueSlugstringLeague slug.
matchIdstringThe match id. Works on the match routes for this sport.
playersarrayPer-player stats, on newer payloads.
sampledAtstringWhen this snapshot of the game was taken.
teamsobjectThe teams in this match.
Full example
JSON
{
  "event": "lol.live.map_delta",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-08-21T00:47:11.907Z",
  "event_type": "lol.live.map_delta",
  "match_id": "lol-match-116792888905448318",
  "previous_state": null,
  "data": {
    "teams": {
      "red": "102141671183409133",
      "blue": "114746424759893687"
    },
    "gameId": "116792888905448320",
    "league": "NACL",
    "matchId": "lol-match-116792888905448318",
    "players": [
      {
        "side": "blue",
        "alive": true,
        "position": null,
        "participantId": 1
      },
      {
        "side": "blue",
        "alive": false,
        "position": null,
        "participantId": 2
      },
      {
        "side": "blue",
        "alive": false,
        "position": null,
        "participantId": 3
      },
      {
        "side": "blue",
        "alive": true,
        "position": null,
        "participantId": 4
      },
      {
        "side": "blue",
        "alive": true,
        "position": null,
        "participantId": 5
      },
      {
        "side": "red",
        "alive": false,
        "position": null,
        "participantId": 6
      },
      {
        "side": "red",
        "alive": false,
        "position": null,
        "participantId": 7
      },
      {
        "side": "red",
        "alive": true,
        "position": null,
        "participantId": 8
      },
      {
        "side": "red",
        "alive": true,
        "position": null,
        "participantId": 9
      },
      {
        "side": "red",
        "alive": true,
        "position": null,
        "participantId": 10
      }
    ],
    "gameTime": 1050,
    "leagueId": "109511549831443335",
    "sampledAt": "2026-08-21T00:41:39.224Z",
    "gameNumber": 2,
    "leagueSlug": "nacl",
    "canonicalGameId": "lol-game-116792888905448320"
  }
}
lol.live.pick_lockedQuiet

A champion pick was locked in.

When
During the draft of a live game.
Frequency
none in the last 7 days; last sent 2026-08-21
Plan
Pro and Scale
Filters
league, matchId, gameId, side
Use it to
Show the draft as it locks, one champion at a time.
You can subscribe, but it has not been sent recently. Do not build on it alone without checking lastSentAt.

data.teams.red and data.teams.blue are team ids. matchId, gameId and canonicalGameId identify the series and the game; pass canonicalGameId or gameId to /lol/games/{gameId}.

Fields in data (19)
FieldTypeMeaning
bansarrayBans so far.
canonicalGameIdstringThe game id to pass to /lol/games/{gameId}.
championIdstringChampion id.
gameIdstringOne game inside a series.
gameNumbernumberWhich game of the series, from 1.
gameStatestringState of the game, for example in_game.
gameTimenullGame clock in minutes on change events, seconds on state snapshots.
leaguestringLeague name, or the sport id on CS2 events.
leagueIdstringLeague id.
leagueSlugstringLeague slug.
matchIdstringThe match id. Works on the match routes for this sport.
participantIdnumberPlayer slot in the game.
patchVersionstringGame patch.
picksarrayPicks so far.
rolestringPlayer role.
sampledAtstringWhen this snapshot of the game was taken.
sidestringred or blue.
summonerNamestringPlayer name in game.
teamsobjectThe teams in this match.
Full example
JSON
{
  "event": "lol.live.pick_locked",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-08-20T22:55:23.881Z",
  "event_type": "lol.live.pick_locked",
  "match_id": "lol-match-116792888905448314",
  "previous_state": null,
  "data": {
    "bans": [],
    "role": "mid",
    "side": "blue",
    "picks": [
      {
        "role": "top",
        "side": "blue",
        "championId": "Ambessa",
        "summonerName": "CPD Horder",
        "participantId": 1
      },
      {
        "role": "jungle",
        "side": "blue",
        "championId": "Qiyana",
        "summonerName": "CPD Music",
        "participantId": 2
      },
      {
        "role": "mid",
        "side": "blue",
        "championId": "Cassiopeia",
        "summonerName": "CPD Ablazeolive",
        "participantId": 3
      },
      {
        "role": "bottom",
        "side": "blue",
        "championId": "Kaisa",
        "summonerName": "CPD Mobility",
        "participantId": 4
      },
      {
        "role": "support",
        "side": "blue",
        "championId": "Rell",
        "summonerName": "CPD Breezy",
        "participantId": 5
      },
      {
        "role": "top",
        "side": "red",
        "championId": "Riven",
        "summonerName": "CNV Sniper",
        "participantId": 6
      },
      {
        "role": "jungle",
        "side": "red",
        "championId": "JarvanIV",
        "summonerName": "CNV Tomio",
        "participantId": 7
      },
      {
        "role": "mid",
        "side": "red",
        "championId": "Syndra",
        "summonerName": "CNV Insanity",
        "participantId": 8
      },
      {
        "role": "bottom",
        "side": "red",
        "championId": "Ezreal",
        "summonerName": "CNV Cody Sun",
        "participantId": 9
      },
      {
        "role": "support",
        "side": "red",
        "championId": "Shen",
        "summonerName": "CNV Zyko",
        "participantId": 10
      }
    ],
    "teams": {
      "red": "109783170393931575",
      "blue": "116877540282721669"
    },
    "gameId": "116792888905448316",
    "league": "NACL",
    "matchId": "lol-match-116792888905448314",
    "gameTime": null,
    "leagueId": "109511549831443335",
    "gameState": "in_game",
    "sampledAt": "2026-08-20T22:49:49.980Z",
    "championId": "Cassiopeia",
    "gameNumber": 2,
    "leagueSlug": "nacl",
    "patchVersion": "16.16.805.442",
    "summonerName": "CPD Ablazeolive",
    "participantId": 3,
    "canonicalGameId": "lol-game-116792888905448316"
  }
}
lol.game.completedActive

One game of a series finished, with draft, team stats, players and first objectives.

When
When a game inside a series ends.
Frequency
about 3 per week
Plan
Pro and Scale
Filters
matchId, gameId
Use it to
Store the game: draft, team stats, players and first objectives. If data.statsPending is true, read /lol/games/{gameId} a little later.

data.statsPending true means the stats are still arriving. Read /lol/games/{gameId} a little later for the complete record.

Fields in data (13)
FieldTypeMeaning
draftobjectPicks and bans of this game.
durationnull or numberGame length.
firstObjectivesobjectWhich team took each first objective.
gameIdstringOne game inside a series.
gameNumbernumberWhich game of the series, from 1.
matchIdstringThe match id. Works on the match routes for this sport.
patchnull or stringGame patch.
payload_versionnumberVersion of this payload shape. Newer events carry more fields.
playersobjectPer-player stats, on newer payloads.
statsPendingbooleanTrue when per-game stats are still arriving. Read the match route again later.
teamsobjectThe teams in this match.
teamStatsobjectTeam totals for the game.
winnernull or stringWinning team.
Full example (real payload)
JSON
{
  "event": "lol.game.completed",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-08-21T00:56:19.279Z",
  "event_type": "lol.game.completed",
  "match_id": "lol-match-116792888905448318",
  "previous_state": null,
  "data": {
    "draft": {
      "picks": {
        "red": [
          "Jhin",
          "Ambessa",
          "Nautilus",
          "Lee Sin",
          "Cassiopeia"
        ],
        "blue": [
          "Zeri",
          "Corki",
          "Alistar",
          "Olaf",
          "Maokai"
        ]
      },
      "redBans": [],
      "blueBans": []
    },
    "patch": "16.16.805.442",
    "teams": {
      "red": {
        "code": "MU",
        "name": "Maryville University",
        "slug": "maryville-university"
      },
      "blue": {
        "code": "DOG",
        "name": "Dorado Gaming",
        "slug": "dog"
      }
    },
    "gameId": "lol-game-116792888905448320",
    "winner": "maryville-university",
    "matchId": "lol-match-116792888905448318",
    "players": {
      "red": [
        {
          "cs": 229,
          "id": "1d18b5d5-7c5a-4593-a1c9-9192f8948560",
          "gold": 12473,
          "name": "Scary Jerry",
          "kills": 3,
          "deaths": 2,
          "assists": 17,
          "champion": "Jhin",
          "position": "ADC"
        },
        {
          "cs": 211,
          "id": "4ef78162-ec11-4ab6-b8bd-8ab3d338f043",
          "gold": 10283,
          "name": "Crimson",
          "kills": 5,
          "deaths": 2,
          "assists": 9,
          "champion": "Ambessa",
          "position": "Top"
        },
        {
          "cs": 23,
          "id": "08b82514-9517-43ee-b72b-0cd93ff5f056",
          "gold": 7322,
          "name": "Obstinatus",
          "kills": 0,
          "deaths": 1,
          "assists": 21,
          "champion": "Nautilus",
          "position": "Support"
        },
        {
          "cs": 171,
          "id": "8f9b0edd-f49d-4333-a9e8-41399c94468c",
          "gold": 11079,
          "name": "Niles",
          "kills": 5,
          "deaths": 2,
          "assists": 16,
          "champion": "Lee Sin",
          "position": "Jungle"
        },
        {
          "cs": 248,
          "id": "18a0192f-b383-45b8-bbb7-109b232ed0ac",
          "gold": 13393,
          "name": "AoJune",
          "kills": 14,
          "deaths": 0,
          "assists": 6,
          "champion": "Cassiopeia",
          "position": "Mid"
        }
      ],
      "blue": [
        {
          "cs": 234,
          "id": "6d2f4263-798e-447d-85c6-89f1786f1e22",
          "gold": 10872,
          "name": "aadam",
          "kills": 4,
          "deaths": 6,
          "assists": 3,
          "champion": "Zeri",
          "position": "Mid"
        },
        {
          "cs": 209,
          "id": "d84b2c65-9c33-422b-9ab7-daaaa8956795",
          "gold": 9227,
          "name": "Hub",
          "kills": 0,
          "deaths": 3,
          "assists": 5,
          "champion": "Corki",
          "position": "ADC"
        },
        {
          "cs": 27,
          "id": "0c6ed2c1-0804-4d89-a334-cf4e13fce5e6",
          "gold": 5911,
          "name": "InFlame",
          "kills": 0,
          "deaths": 8,
          "assists": 4,
          "champion": "Alistar",
          "position": "Support"
        },
        {
          "cs": 208,
          "id": "9cdfb559-174a-4d30-93db-2d9438452018",
          "gold": 8260,
          "name": "Dcoy",
          "kills": 1,
          "deaths": 3,
          "assists": 1,
          "champion": "Olaf",
          "position": "Top"
        },
        {
          "cs": 162,
          "id": "3de113b1-2e66-453c-a8c6-445667ba1504",
          "gold": 9140,
          "name": "Lojaz",
          "kills": 2,
          "deaths": 7,
          "assists": 4,
          "champion": "Maokai",
          "position": "Jungle"
        }
      ]
    },
    "duration": 1569,
    "teamStats": {
      "red": {
        "gold": 58388,
        "kills": 31,
        "barons": 1,
        "towers": 8,
        "dragons": 4,
        "heralds": null,
        "inhibitors": 1
      },
      "blue": {
        "gold": 45032,
        "kills": 8,
        "barons": 0,
        "towers": 1,
        "dragons": 0,
        "heralds": null,
        "inhibitors": 0
      }
    },
    "gameNumber": 2,
    "firstObjectives": {
      "firstBaron": "red",
      "firstBlood": "red",
      "firstTower": "red",
      "firstDragon": "red",
      "firstHerald": null
    },
    "payload_version": 2
  }
}
lol.draft.updatedQuiet

A pick or ban was made in a live draft.

When
During the draft of a live game.
Frequency
none in the last 7 days; last sent 2026-08-21
Plan
Pro and Scale
Filters
league, matchId, gameId
Use it to
Replace your stored draft state with the latest picks and bans.
You can subscribe, but it has not been sent recently. Do not build on it alone without checking lastSentAt.

data.teams.red and data.teams.blue are team ids. matchId, gameId and canonicalGameId identify the series and the game; pass canonicalGameId or gameId to /lol/games/{gameId}.

Fields in data (17)
FieldTypeMeaning
actionstringpick or ban.
bansSoFarnumberBans made up to this action.
canonicalGameIdstringThe game id to pass to /lol/games/{gameId}.
championstringChampion picked or banned.
gameIdstringOne game inside a series.
gameNumbernumberWhich game of the series, from 1.
gameTimenullGame clock in minutes on change events, seconds on state snapshots.
leaguestringLeague name, or the sport id on CS2 events.
leagueIdstringLeague id.
leagueSlugstringLeague slug.
matchIdstringThe match id. Works on the match routes for this sport.
ordernumberPosition in the draft order.
payload_versionnumberVersion of this payload shape. Newer events carry more fields.
picksSoFarnumberPicks made up to this action.
sampledAtstringWhen this snapshot of the game was taken.
teamstringTeam making this action.
teamsobjectThe teams in this match.
Full example
JSON
{
  "event": "lol.draft.updated",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-08-20T22:55:23.885Z",
  "event_type": "lol.draft.updated",
  "match_id": "lol-match-116792888905448314",
  "previous_state": null,
  "data": {
    "team": "blue",
    "order": 3,
    "teams": {
      "red": "109783170393931575",
      "blue": "116877540282721669"
    },
    "action": "pick",
    "gameId": "116792888905448316",
    "league": "NACL",
    "matchId": "lol-match-116792888905448314",
    "champion": "Cassiopeia",
    "gameTime": null,
    "leagueId": "109511549831443335",
    "bansSoFar": 0,
    "sampledAt": "2026-08-20T22:49:49.980Z",
    "gameNumber": 2,
    "leagueSlug": "nacl",
    "picksSoFar": 10,
    "canonicalGameId": "lol-game-116792888905448316",
    "payload_version": 2
  }
}

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 "lol.match.started":
      // handle lol.match.started
      break;
    case "lol.match.completed":
      // handle lol.match.completed
      break;
    case "lol.score.updated":
      // handle lol.score.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 "lol.match.started":
      // handle lol.match.started
      break;
    case "lol.match.completed":
      // handle lol.match.completed
      break;
    case "lol.score.updated":
      // handle lol.score.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 == "lol.match.started":
        pass  # handle lol.match.started
    elif event == "lol.match.completed":
        pass  # handle lol.match.completed
    elif event == "lol.score.updated":
        pass  # handle lol.score.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 == "lol.match.started":
        pass  # handle lol.match.started
    elif event == "lol.match.completed":
        pass  # handle lol.match.completed
    elif event == "lol.score.updated":
        pass  # handle lol.score.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 "lol.match.started":
		// handle lol.match.started
	case "lol.match.completed":
		// handle lol.match.completed
	case "lol.score.updated":
		// handle lol.score.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?