Docs/CS2 API/Webhooks

Counter-Strike 2 webhooks

Get a signed POST when a CS2 match starts, a round ends, a bomb is planted, a map finishes or the result is final. Live round events are on Pro and Scale. Accounts on closed plans keep the match and map events.

Which events do I need?

Live scoreboard

cs2.match.live_startedcs2.score.updatedcs2.round.endedcs2.map.completedcs2.match.completed

Tells you when a match goes live, then every score change and round result, and closes with the map and match results.

Settle bets

cs2.match.completedcs2.map.completed

Final map score and winner, plus each map for per-map markets. cs2.match.live_ended is left out: it is not the official result.

Results only

cs2.match.completed

One event per finished match.

Alerts

cs2.match.startedcs2.match.completed

“Match starting” and “final score”. Few enough to post to a channel.

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.

Recipes

Keep a score on screen

Subscribe to cs2.match.live_started, cs2.score.updated and cs2.match.completed. A score can be null while a map is being set up, so keep the last one.
JavaScript
if (event === "cs2.score.updated" && data.team1Score != null) {
  setScore(data.matchId, data.team1Score, data.team2Score);
}
if (event === "cs2.match.completed") {
  setFinal(data.matchId, data.team1Score, data.team2Score, data.winnerTeamId);
}

Bomb timer

Start it on cs2.bomb.planted, and stop it on cs2.bomb.defused, cs2.bomb.exploded or the next cs2.round.ended, because a round can end before the bomb events reach you.

Player stats after the match

On cs2.match.completed, read GET /api/v1/cs2/matches/{matchId}/player-stats. If a map has no lines yet, try again later.

More: recipes for every sport.

Good to know

  • Settle on cs2.match.completed. cs2.match.live_ended only says live tracking stopped.
  • On match events team1Score counts maps. On cs2.match.live_ended, data.score counts maps and data.final_state.score counts rounds on the last map.
  • On CS2 events data.league is always cs2, so a league filter does not narrow them. Use matchId.
  • cs2.player.state_changed fires many times per round for every player. It is by far the highest-volume event, so subscribe only if you draw a live player panel.

All 16 events

15 of 16 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=cs2.

cs2.match.startedActive

A CS2 match has started, with event name and team names.

When
When a match goes live. Once per match.
Frequency
about 25 per day across all matches
Delivery
Median 301 ms, p95 597 ms, from the moment Cito recorded the event to the moment a customer endpoint answered (566 recent deliveries).
Plan
Pro and Scale
Filters
league, matchId
Use it to
Flip a match to live and post a “match starting” alert with the event and teams.
Fields in data (9)
FieldTypeMeaning
dataobjectThe change itself, on score events.
eventnull or stringThe tournament or event name inside data on CS2 events, the event name on the envelope.
event_typestringThe event name.
leaguestringLeague name, or the sport id on CS2 events.
match_idstringThe match id. Works on the match routes for this sport.
matchIdstringThe match id. Works on the match routes for this sport.
previous_statenull or objectThe state before the change, when the event has one.
teamsarrayThe teams in this match.
timestampstringWhen this event happened, ISO 8601 UTC.
Full example (real payload)
JSON
{
  "event": "cs2.match.started",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T14:09:05.649Z",
  "event_type": "cs2.match.started",
  "match_id": "cs2-match-2400316",
  "previous_state": {
    "status": "upcoming"
  },
  "data": {
    "data": {
      "event": "ESL Challenger League Season 52 Europe Cup 11",
      "teams": [
        "Balkan Outlaws",
        "The Last Resort"
      ]
    },
    "event": "ESL Challenger League Season 52 Europe Cup 11",
    "teams": [
      "Balkan Outlaws",
      "The Last Resort"
    ],
    "league": "cs2",
    "matchId": "cs2-match-2400316",
    "match_id": "cs2-match-2400316",
    "timestamp": "2026-11-08T14:09:05.633Z",
    "event_type": "cs2.match.started",
    "previous_state": {
      "status": "upcoming"
    }
  }
}
cs2.match.live_startedActive

Live round-by-round tracking began for a CS2 match.

When
When live tracking of a match begins. About once per match.
Frequency
about 25 per day across all matches
Delivery
Median 357 ms, p95 1.0 s, from the moment Cito recorded the event to the moment a customer endpoint answered (567 recent deliveries).
Plan
Pro and Scale (real time)
Filters
league, matchId
Use it to
Start listening to the round events for this match, or open your live view.
Fields in data (7)
FieldTypeMeaning
dataobjectThe change itself, on score events.
event_typestringThe event name.
leaguestringLeague name, or the sport id on CS2 events.
match_idstringThe match id. Works on the match routes for this sport.
matchIdstringThe match id. Works on the match routes for this sport.
previous_statenullThe state before the change, when the event has one.
timestampstringWhen this event happened, ISO 8601 UTC.
Full example (real payload)
JSON
{
  "event": "cs2.match.live_started",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T16:25:22.572Z",
  "event_type": "cs2.match.live_started",
  "match_id": "cs2-match-2400093",
  "previous_state": null,
  "data": {
    "data": {
      "score": {
        "team1": null,
        "team2": null
      },
      "teams": {
        "team1_id": "cs2-team-13029",
        "team2_id": "cs2-team-13026",
        "team1_name": "RED Canids Academy",
        "team2_name": "BESTIA Academy"
      },
      "status": "live",
      "match_id": "cs2-match-2400093",
      "started_at": "2026-11-08T16:25:22.451Z"
    },
    "league": "cs2",
    "matchId": "cs2-match-2400093",
    "match_id": "cs2-match-2400093",
    "timestamp": "2026-11-08T16:25:22.451Z",
    "event_type": "cs2.match.live_started",
    "previous_state": null
  }
}
cs2.match.live_endedActive

Live tracking ended for a CS2 match, with the last known state of the map and players in data.final_state.

When
When a match stops being live. Use cs2.match.completed for the official result.
Frequency
about 25 per day across all matches
Delivery
Median 353 ms, p95 1.0 s, from the moment Cito recorded the event to the moment a customer endpoint answered (554 recent deliveries).
Plan
Pro and Scale (real time)
Filters
league, matchId
Use it to
Stop your live view. Do not settle on this event: wait for cs2.match.completed.

data.reason says why tracking stopped. The score in data.score counts maps; data.final_state.score counts rounds on the last map.

Fields in data (7)
FieldTypeMeaning
dataobjectThe change itself, on score events.
event_typestringThe event name.
leaguestringLeague name, or the sport id on CS2 events.
match_idstringThe match id. Works on the match routes for this sport.
matchIdstringThe match id. Works on the match routes for this sport.
previous_stateobjectThe state before the change, when the event has one.
timestampstringWhen this event happened, ISO 8601 UTC.
Full example (real payload)
JSON
{
  "event": "cs2.match.live_ended",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T14:10:55.655Z",
  "event_type": "cs2.match.live_ended",
  "match_id": "cs2-match-2400384",
  "previous_state": {
    "score": {
      "team1": 5,
      "team2": 12
    },
    "teams": {
      "team1_name": "benched gods",
      "team2_name": "PCIFIC",
      "terrorist_team_name": "PCIFIC",
      "counter_terrorist_team_name": "benched gods"
    },
    "status": "live",
    "players": [
      {
        "hp": 100,
        "adr": 120.83333333333331,
        "name": "jresy",
        "nick": "jresy",
        "side": "T",
        "alive": true,
        "kills": 19,
        "money": 3100,
        "deaths": 11,
        "helmet": true,
        "kevlar": true,
        "weapon": "ak47",
        "assists": 6,
        "player_id": "cs2-player-21250",
        "has_defuse_kit": false
      },
      {
        "hp": 100,
        "adr": 74.5,
        "name": "scolleN",
        "nick": "scolleN",
        "side": "T",
        "alive": true,
        "kills": 15,
        "money": 6350,
        "deaths": 11,
        "helmet": true,
        "kevlar": true,
        "weapon": "ak47",
        "assists": 5,
        "player_id": "cs2-player-22504",
        "has_defuse_kit": false
      },
      {
        "hp": 100,
        "adr": 73.61111111111111,
        "name": "lugseN",
        "nick": "lugseN",
        "side": "T",
        "alive": true,
        "kills": 15,
        "money": 8300,
        "deaths": 7,
        "helmet": true,
        "kevlar": true,
        "weapon": "awp",
        "assists": 4,
        "player_id": "cs2-player-23274",
        "has_defuse_kit": false
      },
      {
        "hp": 100,
        "adr": 85.38888888888889,
        "name": "mini",
        "nick": "mini",
        "side": "T",
        "alive": true,
        "kills": 14,
        "money": 2550,
        "deaths": 8,
        "helmet": true,
        "kevlar": true,
        "weapon": "ak47",
        "assists": 5,
        "player_id": "cs2-player-9352",
        "has_defuse_kit": false
      },
      {
        "hp": 100,
        "adr": 59.27777777777778,
        "name": "oyesil",
        "nick": "oyesil",
        "side": "T",
        "alive": true,
        "kills": 10,
        "money": 3050,
        "deaths": 8,
        "helmet": true,
        "kevlar": true,
        "weapon": "ak47",
        "assists": 5,
        "player_id": "cs2-player-25890",
        "has_defuse_kit": false
      },
      {
        "hp": 100,
        "adr": 69.77777777777777,
        "name": "facer",
        "nick": "facer",
        "side": "CT",
        "alive": true,
        "kills": 10,
        "money": 100,
        "deaths": 16,
        "helmet": false,
        "kevlar": true,
        "weapon": "m4a1_silencer",
        "assists": 4,
        "player_id": "cs2-player-24885",
        "has_defuse_kit": false
      },
      {
        "hp": 100,
        "adr": 72.94444444444444,
        "name": "f1chper",
        "nick": "f1chper",
        "side": "CT",
        "alive": true,
        "kills": 10,
        "money": 300,
        "deaths": 13,
        "helmet": false,
        "kevlar": true,
        "weapon": "m4a1_silencer",
        "assists": 1,
        "player_id": "cs2-player-26074",
        "has_defuse_kit": false
      },
      {
        "hp": 100,
        "adr": 41.388888888888886,
        "name": "yoyoS2",
        "nick": "yoyoS2",
        "side": "CT",
        "alive": true,
        "kills": 9,
        "money": 0,
        "deaths": 14,
        "helmet": true,
        "kevlar": true,
        "weapon": "awp",
        "assists": 2,
        "player_id": "cs2-player-26646",
        "has_defuse_kit": false
      },
      {
        "hp": 95,
        "adr": 56.55555555555556,
        "name": "wign",
        "nick": "Wign",
        "side": "CT",
        "alive": true,
        "kills": 8,
        "money": 0,
        "deaths": 14,
        "helmet": true,
        "kevlar": true,
        "weapon": "m4a1_silencer",
        "assists": 3,
        "player_id": "cs2-player-23773",
        "has_defuse_kit": false
      },
      {
        "hp": 100,
        "adr": 48.833333333333336,
        "name": "K014NZ",
        "nick": "K014NZ",
        "side": "CT",
        "alive": true,
        "kills": 8,
        "money": 0,
        "deaths": 16,
        "helmet": true,
        "kevlar": true,
        "weapon": "m4a1_silencer",
        "assists": 2,
        "player_id": "cs2-player-24840",
        "has_defuse_kit": true
      }
    ],
    "match_id": "cs2-match-2400384",
    "current_map": "de_anubis",
    "bomb_planted": false,
    "current_round": 18,
    "round_time_seconds": null
  },
  "data": {
    "data": {
      "score": {
        "team1": 1,
        "team2": 1
      },
      "teams": {
        "team1_id": "cs2-team-13599",
        "team2_id": "cs2-team-13054",
        "team1_name": "benched gods",
        "team2_name": "PCIFIC"
      },
      "reason": "not_live",
      "status": "ended",
      "ended_at": "2026-11-08T14:10:55.632Z",
      "match_id": "cs2-match-2400384",
      "final_state": {
        "score": {
          "team1": 5,
          "team2": 12
        },
        "teams": {
          "team1_name": "benched gods",
          "team2_name": "PCIFIC",
          "terrorist_team_name": "PCIFIC",
          "counter_terrorist_team_name": "benched gods"
        },
        "status": "live",
        "players": [
          {
            "hp": 100,
            "adr": 120.83333333333331,
            "name": "jresy",
            "nick": "jresy",
            "side": "T",
            "alive": true,
            "kills": 19,
            "money": 3100,
            "deaths": 11,
            "helmet": true,
            "kevlar": true,
            "weapon": "ak47",
            "assists": 6,
            "player_id": "cs2-player-21250",
            "has_defuse_kit": false
          },
          {
            "hp": 100,
            "adr": 74.5,
            "name": "scolleN",
            "nick": "scolleN",
            "side": "T",
            "alive": true,
            "kills": 15,
            "money": 6350,
            "deaths": 11,
            "helmet": true,
            "kevlar": true,
            "weapon": "ak47",
            "assists": 5,
            "player_id": "cs2-player-22504",
            "has_defuse_kit": false
          },
          {
            "hp": 100,
            "adr": 73.61111111111111,
            "name": "lugseN",
            "nick": "lugseN",
            "side": "T",
            "alive": true,
            "kills": 15,
            "money": 8300,
            "deaths": 7,
            "helmet": true,
            "kevlar": true,
            "weapon": "awp",
            "assists": 4,
            "player_id": "cs2-player-23274",
            "has_defuse_kit": false
          },
          {
            "hp": 100,
            "adr": 85.38888888888889,
            "name": "mini",
            "nick": "mini",
            "side": "T",
            "alive": true,
            "kills": 14,
            "money": 2550,
            "deaths": 8,
            "helmet": true,
            "kevlar": true,
            "weapon": "ak47",
            "assists": 5,
            "player_id": "cs2-player-9352",
            "has_defuse_kit": false
          },
          {
            "hp": 100,
            "adr": 59.27777777777778,
            "name": "oyesil",
            "nick": "oyesil",
            "side": "T",
            "alive": true,
            "kills": 10,
            "money": 3050,
            "deaths": 8,
            "helmet": true,
            "kevlar": true,
            "weapon": "ak47",
            "assists": 5,
            "player_id": "cs2-player-25890",
            "has_defuse_kit": false
          },
          {
            "hp": 100,
            "adr": 69.77777777777777,
            "name": "facer",
            "nick": "facer",
            "side": "CT",
            "alive": true,
            "kills": 10,
            "money": 100,
            "deaths": 16,
            "helmet": false,
            "kevlar": true,
            "weapon": "m4a1_silencer",
            "assists": 4,
            "player_id": "cs2-player-24885",
            "has_defuse_kit": false
          },
          {
            "hp": 100,
            "adr": 72.94444444444444,
            "name": "f1chper",
            "nick": "f1chper",
            "side": "CT",
            "alive": true,
            "kills": 10,
            "money": 300,
            "deaths": 13,
            "helmet": false,
            "kevlar": true,
            "weapon": "m4a1_silencer",
            "assists": 1,
            "player_id": "cs2-player-26074",
            "has_defuse_kit": false
          },
          {
            "hp": 100,
            "adr": 41.388888888888886,
            "name": "yoyoS2",
            "nick": "yoyoS2",
            "side": "CT",
            "alive": true,
            "kills": 9,
            "money": 0,
            "deaths": 14,
            "helmet": true,
            "kevlar": true,
            "weapon": "awp",
            "assists": 2,
            "player_id": "cs2-player-26646",
            "has_defuse_kit": false
          },
          {
            "hp": 95,
            "adr": 56.55555555555556,
            "name": "wign",
            "nick": "Wign",
            "side": "CT",
            "alive": true,
            "kills": 8,
            "money": 0,
            "deaths": 14,
            "helmet": true,
            "kevlar": true,
            "weapon": "m4a1_silencer",
            "assists": 3,
            "player_id": "cs2-player-23773",
            "has_defuse_kit": false
          },
          {
            "hp": 100,
            "adr": 48.833333333333336,
            "name": "K014NZ",
            "nick": "K014NZ",
            "side": "CT",
            "alive": true,
            "kills": 8,
            "money": 0,
            "deaths": 16,
            "helmet": true,
            "kevlar": true,
            "weapon": "m4a1_silencer",
            "assists": 2,
            "player_id": "cs2-player-24840",
            "has_defuse_kit": true
          }
        ],
        "match_id": "cs2-match-2400384",
        "current_map": "de_anubis",
        "bomb_planted": false,
        "current_round": 18,
        "round_time_seconds": null
      }
    },
    "league": "cs2",
    "matchId": "cs2-match-2400384",
    "match_id": "cs2-match-2400384",
    "timestamp": "2026-11-08T14:10:55.632Z",
    "event_type": "cs2.match.live_ended",
    "previous_state": {
      "score": {
        "team1": 5,
        "team2": 12
      },
      "teams": {
        "team1_name": "benched gods",
        "team2_name": "PCIFIC",
        "terrorist_team_name": "PCIFIC",
        "counter_terrorist_team_name": "benched gods"
      },
      "status": "live",
      "players": [
        {
          "hp": 100,
          "adr": 120.83333333333331,
          "name": "jresy",
          "nick": "jresy",
          "side": "T",
          "alive": true,
          "kills": 19,
          "money": 3100,
          "deaths": 11,
          "helmet": true,
          "kevlar": true,
          "weapon": "ak47",
          "assists": 6,
          "player_id": "cs2-player-21250",
          "has_defuse_kit": false
        },
        {
          "hp": 100,
          "adr": 74.5,
          "name": "scolleN",
          "nick": "scolleN",
          "side": "T",
          "alive": true,
          "kills": 15,
          "money": 6350,
          "deaths": 11,
          "helmet": true,
          "kevlar": true,
          "weapon": "ak47",
          "assists": 5,
          "player_id": "cs2-player-22504",
          "has_defuse_kit": false
        },
        {
          "hp": 100,
          "adr": 73.61111111111111,
          "name": "lugseN",
          "nick": "lugseN",
          "side": "T",
          "alive": true,
          "kills": 15,
          "money": 8300,
          "deaths": 7,
          "helmet": true,
          "kevlar": true,
          "weapon": "awp",
          "assists": 4,
          "player_id": "cs2-player-23274",
          "has_defuse_kit": false
        },
        {
          "hp": 100,
          "adr": 85.38888888888889,
          "name": "mini",
          "nick": "mini",
          "side": "T",
          "alive": true,
          "kills": 14,
          "money": 2550,
          "deaths": 8,
          "helmet": true,
          "kevlar": true,
          "weapon": "ak47",
          "assists": 5,
          "player_id": "cs2-player-9352",
          "has_defuse_kit": false
        },
        {
          "hp": 100,
          "adr": 59.27777777777778,
          "name": "oyesil",
          "nick": "oyesil",
          "side": "T",
          "alive": true,
          "kills": 10,
          "money": 3050,
          "deaths": 8,
          "helmet": true,
          "kevlar": true,
          "weapon": "ak47",
          "assists": 5,
          "player_id": "cs2-player-25890",
          "has_defuse_kit": false
        },
        {
          "hp": 100,
          "adr": 69.77777777777777,
          "name": "facer",
          "nick": "facer",
          "side": "CT",
          "alive": true,
          "kills": 10,
          "money": 100,
          "deaths": 16,
          "helmet": false,
          "kevlar": true,
          "weapon": "m4a1_silencer",
          "assists": 4,
          "player_id": "cs2-player-24885",
          "has_defuse_kit": false
        },
        {
          "hp": 100,
          "adr": 72.94444444444444,
          "name": "f1chper",
          "nick": "f1chper",
          "side": "CT",
          "alive": true,
          "kills": 10,
          "money": 300,
          "deaths": 13,
          "helmet": false,
          "kevlar": true,
          "weapon": "m4a1_silencer",
          "assists": 1,
          "player_id": "cs2-player-26074",
          "has_defuse_kit": false
        },
        {
          "hp": 100,
          "adr": 41.388888888888886,
          "name": "yoyoS2",
          "nick": "yoyoS2",
          "side": "CT",
          "alive": true,
          "kills": 9,
          "money": 0,
          "deaths": 14,
          "helmet": true,
          "kevlar": true,
          "weapon": "awp",
          "assists": 2,
          "player_id": "cs2-player-26646",
          "has_defuse_kit": false
        },
        {
          "hp": 95,
          "adr": 56.55555555555556,
          "name": "wign",
          "nick": "Wign",
          "side": "CT",
          "alive": true,
          "kills": 8,
          "money": 0,
          "deaths": 14,
          "helmet": true,
          "kevlar": true,
          "weapon": "m4a1_silencer",
          "assists": 3,
          "player_id": "cs2-player-23773",
          "has_defuse_kit": false
        },
        {
          "hp": 100,
          "adr": 48.833333333333336,
          "name": "K014NZ",
          "nick": "K014NZ",
          "side": "CT",
          "alive": true,
          "kills": 8,
          "money": 0,
          "deaths": 16,
          "helmet": true,
          "kevlar": true,
          "weapon": "m4a1_silencer",
          "assists": 2,
          "player_id": "cs2-player-24840",
          "has_defuse_kit": true
        }
      ],
      "match_id": "cs2-match-2400384",
      "current_map": "de_anubis",
      "bomb_planted": false,
      "current_round": 18,
      "round_time_seconds": null
    }
  }
}
cs2.match.completedActive

A CS2 match has finished, with the final map score and winner.

When
When a match result is final.
Frequency
about 29 per day across all matches
Delivery
Median 266 ms, p95 641 ms, from the moment Cito recorded the event to the moment a customer endpoint answered (2,800 recent deliveries).
Plan
Pro and Scale
Filters
league, matchId
Use it to
Settle on the final map score and winner, then fetch player stats with /cs2/matches/{matchId}/player-stats.

On this event matchId, mapId and winnerTeamId can use a different id prefix from other CS2 events. The trailing number is the same one as in cs2-match-{number}, so compare on that number.

Fields in data (8)
FieldTypeMeaning
endsAtstringWhen the match ended, ISO 8601 UTC.
eventnull or stringThe tournament or event name inside data on CS2 events, the event name on the envelope.
leaguestringLeague name, or the sport id on CS2 events.
matchIdstringThe match id. Works on the match routes for this sport.
team1ScorenumberMaps won by team 1 on match events, rounds on map events. Null while a map is being set up.
team2ScorenumberMaps won by team 2 on match events, rounds on map events. Null while a map is being set up.
teamsarrayThe teams in this match.
winnerTeamIdstringThe winning team id. Works on /cs2/teams/{id}.
Full example (real payload)
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": {
    "event": "ESL Challenger League Season 52 Europe Cup 11",
    "teams": [
      "Balkan Outlaws",
      "The Last Resort"
    ],
    "endsAt": "2026-11-08T15:24:03.241Z",
    "league": "cs2",
    "matchId": "cs2-match-2400316",
    "team1Score": 2,
    "team2Score": 0,
    "winnerTeamId": "cs2-team-14130"
  }
}
cs2.map.startedActive

A map of a CS2 series is known or has started.

When
When a map record of a live series opens.
Frequency
about 343 per day across all matches (about 25 per match)
Delivery
Median 509 ms, p95 872 ms, from the moment Cito recorded the event to the moment a customer endpoint answered (4,772 recent deliveries).
Plan
Pro and Scale
Filters
league, matchId
Use it to
Show which map is in play. Be ready for repeats of the same map.

data.mapName can be "TBA" and scores null until the map is picked and played, and this event can repeat for one map. On this event matchId, mapId and winnerTeamId can use a different id prefix from other CS2 events. The trailing number is the same one as in cs2-match-{number}, so compare on that number.

Fields in data (7)
FieldTypeMeaning
leaguestringLeague name, or the sport id on CS2 events.
mapIdstringMap id. Works on /cs2/maps/{mapId}/economy.
mapNamestringMap name, or TBA until it is known.
mapNumbernumberWhich map of the series, from 1.
matchIdstringThe match id. Works on the match routes for this sport.
team1ScorenullMaps won by team 1 on match events, rounds on map events. Null while a map is being set up.
team2ScorenullMaps won by team 2 on match events, rounds on map events. Null while a map is being set up.
Full example (real payload)
JSON
{
  "event": "cs2.map.started",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T20:25:38.311Z",
  "event_type": "cs2.map.started",
  "match_id": "cs2-match-2400439",
  "previous_state": null,
  "data": {
    "mapId": "cs2-match-2400439-map-1",
    "league": "cs2",
    "mapName": "Default",
    "matchId": "cs2-match-2400439",
    "mapNumber": 1,
    "team1Score": null,
    "team2Score": null
  }
}
cs2.map.completedActive

A map of a CS2 series finished, with the round score and winner.

When
When a map ends.
Frequency
about 21 per day across all matches (about 2 per match)
Delivery
Median 264 ms, p95 624 ms, from the moment Cito recorded the event to the moment a customer endpoint answered (3,023 recent deliveries).
Plan
Pro and Scale
Filters
league, matchId
Use it to
Record the map winner and round score; settle per-map markets.

On this event matchId, mapId and winnerTeamId can use a different id prefix from other CS2 events. The trailing number is the same one as in cs2-match-{number}, so compare on that number.

Fields in data (8)
FieldTypeMeaning
leaguestringLeague name, or the sport id on CS2 events.
mapIdstringMap id. Works on /cs2/maps/{mapId}/economy.
mapNamestringMap name, or TBA until it is known.
mapNumbernumberWhich map of the series, from 1.
matchIdstringThe match id. Works on the match routes for this sport.
team1ScorenumberMaps won by team 1 on match events, rounds on map events. Null while a map is being set up.
team2ScorenumberMaps won by team 2 on match events, rounds on map events. Null while a map is being set up.
winnerTeamIdstringThe winning team id. Works on /cs2/teams/{id}.
Full example (real payload)
JSON
{
  "event": "cs2.map.completed",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-06T18:31:57.023Z",
  "event_type": "cs2.map.completed",
  "match_id": "cs2-match-2400085",
  "previous_state": null,
  "data": {
    "mapId": "cs2-match-2400085-map-2",
    "league": "cs2",
    "mapName": "Ancient",
    "matchId": "cs2-match-2400085",
    "mapNumber": 2,
    "team1Score": 10,
    "team2Score": 13,
    "winnerTeamId": "cs2-team-14126"
  }
}
cs2.round.startedActive

A round started, with teams and sides.

When
At the start of each round of a live match.
Frequency
about 1,184 per day across all matches (about 47 per match)
Delivery
Median 367 ms, p95 1.3 s, from the moment Cito recorded the event to the moment a customer endpoint answered (8,847 recent deliveries).
Plan
Pro and Scale (real time)
Filters
league, matchId
Use it to
Reset round-level state: round timer, bomb state, sides.
Fields in data (7)
FieldTypeMeaning
dataobjectThe change itself, on score events.
event_typestringThe event name.
leaguestringLeague name, or the sport id on CS2 events.
match_idstringThe match id. Works on the match routes for this sport.
matchIdstringThe match id. Works on the match routes for this sport.
previous_statenull or objectThe state before the change, when the event has one.
timestampstringWhen this event happened, ISO 8601 UTC.
Full example (real payload)
JSON
{
  "event": "cs2.round.started",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T20:29:35.329Z",
  "event_type": "cs2.round.started",
  "match_id": "cs2-match-2400320",
  "previous_state": {
    "score": {
      "team1": 4,
      "team2": 8
    },
    "round_number": 12
  },
  "data": {
    "data": {
      "map": "de_anubis",
      "score": {
        "team1": 8,
        "team2": 4
      },
      "teams": {
        "team1_name": "aimclub",
        "team2_name": "Balkan Outlaws",
        "terrorist_team_name": "Balkan Outlaws",
        "counter_terrorist_team_name": "aimclub"
      },
      "round_number": 13
    },
    "league": "cs2",
    "matchId": "cs2-match-2400320",
    "match_id": "cs2-match-2400320",
    "timestamp": "2026-11-08T20:29:35.312Z",
    "event_type": "cs2.round.started",
    "previous_state": {
      "score": {
        "team1": 4,
        "team2": 8
      },
      "round_number": 12
    }
  }
}
cs2.round.endedActive

A round ended, with winner side and reason.

When
At the end of each round of a live match.
Frequency
about 1,222 per day across all matches (about 51 per match)
Delivery
Median 392 ms, p95 1.6 s, from the moment Cito recorded the event to the moment a customer endpoint answered (8,852 recent deliveries).
Plan
Pro and Scale (real time)
Filters
league, matchId
Use it to
Update round history with the winner side and reason.
Fields in data (7)
FieldTypeMeaning
dataobjectThe change itself, on score events.
event_typestringThe event name.
leaguestringLeague name, or the sport id on CS2 events.
match_idstringThe match id. Works on the match routes for this sport.
matchIdstringThe match id. Works on the match routes for this sport.
previous_stateobjectThe state before the change, when the event has one.
timestampstringWhen this event happened, ISO 8601 UTC.
Full example (real payload)
JSON
{
  "event": "cs2.round.ended",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T20:15:22.416Z",
  "event_type": "cs2.round.ended",
  "match_id": "cs2-match-2400228",
  "previous_state": {
    "score": {
      "team1": 13,
      "team2": 13
    },
    "round_number": 27
  },
  "data": {
    "data": {
      "map": "de_ancient",
      "score": {
        "team1": 13,
        "team2": 13
      },
      "reason": "Terrorists_Win",
      "winner": "TERRORIST",
      "event_id": "ef9cb4d9d46a3126439ff1a6d8745d31",
      "timestamp": "2026-11-08T20:15:21.644Z",
      "round_number": 27
    },
    "league": "cs2",
    "matchId": "cs2-match-2400228",
    "match_id": "cs2-match-2400228",
    "timestamp": "2026-11-08T20:15:22.400Z",
    "event_type": "cs2.round.ended",
    "previous_state": {
      "score": {
        "team1": 13,
        "team2": 13
      },
      "round_number": 27
    }
  }
}
cs2.bomb.plantedActive

The bomb was planted: site, player and players alive per side.

When
On each plant in a live match.
Frequency
about 660 per day across all matches (about 28 per match)
Delivery
Median 395 ms, p95 1.3 s, from the moment Cito recorded the event to the moment a customer endpoint answered (6,197 recent deliveries).
Plan
Pro and Scale (real time)
Filters
league, matchId
Use it to
Show the bomb site and start a bomb timer.
Fields in data (7)
FieldTypeMeaning
dataobjectThe change itself, on score events.
event_typestringThe event name.
leaguestringLeague name, or the sport id on CS2 events.
match_idstringThe match id. Works on the match routes for this sport.
matchIdstringThe match id. Works on the match routes for this sport.
previous_statenullThe state before the change, when the event has one.
timestampstringWhen this event happened, ISO 8601 UTC.
Full example (real payload)
JSON
{
  "event": "cs2.bomb.planted",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T19:42:56.992Z",
  "event_type": "cs2.bomb.planted",
  "match_id": "cs2-match-2400320",
  "previous_state": null,
  "data": {
    "data": {
      "map": "de_dust2",
      "site": "A",
      "event_id": "0a7e3923f3a1b55cf4898789ec303f12",
      "t_players": 1,
      "timestamp": "2026-11-08T19:42:56.294Z",
      "ct_players": 1,
      "player_name": "starplajerz",
      "player_nick": "starplajerz",
      "round_number": 19
    },
    "league": "cs2",
    "matchId": "cs2-match-2400320",
    "match_id": "cs2-match-2400320",
    "timestamp": "2026-11-08T19:42:56.948Z",
    "event_type": "cs2.bomb.planted",
    "previous_state": null
  }
}
cs2.bomb.defusedActive

The bomb was defused.

When
On each defuse in a live match.
Frequency
about 1 per day across all matches
Plan
Pro and Scale (real time)
Filters
None
Use it to
Stop the bomb timer and mark the defuse.

No example recorded yet.

cs2.bomb.explodedActive

The bomb exploded.

When
On each explosion in a live match.
Frequency
about 1 per day across all matches
Plan
Pro and Scale (real time)
Filters
None
Use it to
Stop the bomb timer and mark the explosion.

No example recorded yet.

cs2.score.updatedActive

The live score changed.

When
When the map or round score of a live match changes.
Frequency
about 2,225 per day across all matches (about 90 per match)
Delivery
Median 365 ms, p95 1.3 s, from the moment Cito recorded the event to the moment a customer endpoint answered (8,955 recent deliveries).
Plan
Pro and Scale (real time)
Filters
league, matchId
Use it to
Update the on-screen score. previous_state.score holds the score before the change.

previous_state.score holds the score before the change. A score can be null while a map is being set up. Round-level detail (data.map, data.round_number) is on most events, not all.

Fields in data (9)
FieldTypeMeaning
dataobjectThe change itself, on score events.
event_typestringThe event name.
leaguestringLeague name, or the sport id on CS2 events.
match_idstringThe match id. Works on the match routes for this sport.
matchIdstringThe match id. Works on the match routes for this sport.
previous_stateobjectThe state before the change, when the event has one.
team1ScorenullMaps won by team 1 on match events, rounds on map events. Null while a map is being set up.
team2ScorenullMaps won by team 2 on match events, rounds on map events. Null while a map is being set up.
timestampstringWhen this event happened, ISO 8601 UTC.
Full example (real payload)
JSON
{
  "event": "cs2.score.updated",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T20:29:56.291Z",
  "event_type": "cs2.score.updated",
  "match_id": "cs2-match-2400228",
  "previous_state": {
    "score": {
      "team1": 1,
      "team2": 1
    }
  },
  "data": {
    "data": {
      "score": {
        "team1": null,
        "team2": null
      }
    },
    "league": "cs2",
    "matchId": "cs2-match-2400228",
    "match_id": "cs2-match-2400228",
    "timestamp": "2026-11-08T20:29:56.266Z",
    "event_type": "cs2.score.updated",
    "team1Score": null,
    "team2Score": null,
    "previous_state": {
      "score": {
        "team1": 1,
        "team2": 1
      }
    }
  }
}
cs2.player.state_changedActive

A player changed: health, money, weapon, armor, kills or similar. data.changes lists what changed.

When
Many times per round for every player in a live match. This is by far the highest-volume event: subscribe only if you need it, and answer quickly.
Frequency
about 58,003 per day across all matches (about 2,417 per match)
Delivery
Median 426 ms, p95 1.6 s, from the moment Cito recorded the event to the moment a customer endpoint answered (9,000 recent deliveries).
Plan
Pro and Scale (real time)
Filters
league, matchId
Use it to
Drive a full live player panel. Very high volume: only subscribe if you draw that panel, and answer fast.
Fields in data (7)
FieldTypeMeaning
dataobjectThe change itself, on score events.
event_typestringThe event name.
leaguestringLeague name, or the sport id on CS2 events.
match_idstringThe match id. Works on the match routes for this sport.
matchIdstringThe match id. Works on the match routes for this sport.
previous_stateobjectThe state before the change, when the event has one.
timestampstringWhen this event happened, ISO 8601 UTC.
Full example (real payload)
JSON
{
  "event": "cs2.player.state_changed",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T20:29:47.086Z",
  "event_type": "cs2.player.state_changed",
  "match_id": "cs2-match-2400094",
  "previous_state": {
    "score": {
      "team1": 1,
      "team2": 3
    },
    "player": {
      "hp": 100,
      "adr": 67.4,
      "name": "Lucaozy",
      "nick": "Lucaozy",
      "side": "CT",
      "alive": true,
      "kills": 4,
      "money": 4850,
      "deaths": 2,
      "helmet": true,
      "kevlar": true,
      "weapon": "ak47",
      "assists": 3,
      "player_id": "cs2-player-18717",
      "has_defuse_kit": true
    },
    "round_number": 5
  },
  "data": {
    "data": {
      "map": "de_nuke",
      "score": {
        "team1": 1,
        "team2": 3
      },
      "player": {
        "hp": 100,
        "adr": 67.4,
        "name": "Lucaozy",
        "nick": "Lucaozy",
        "side": "CT",
        "alive": true,
        "kills": 5,
        "money": 5150,
        "deaths": 2,
        "helmet": true,
        "kevlar": true,
        "weapon": "ak47",
        "assists": 3,
        "player_id": "cs2-player-18717",
        "has_defuse_kit": true
      },
      "changes": [
        "kill",
        "money_threshold_crossed"
      ],
      "round_number": 5
    },
    "league": "cs2",
    "matchId": "cs2-match-2400094",
    "match_id": "cs2-match-2400094",
    "timestamp": "2026-11-08T20:29:47.071Z",
    "event_type": "cs2.player.state_changed",
    "previous_state": {
      "score": {
        "team1": 1,
        "team2": 3
      },
      "player": {
        "hp": 100,
        "adr": 67.4,
        "name": "Lucaozy",
        "nick": "Lucaozy",
        "side": "CT",
        "alive": true,
        "kills": 4,
        "money": 4850,
        "deaths": 2,
        "helmet": true,
        "kevlar": true,
        "weapon": "ak47",
        "assists": 3,
        "player_id": "cs2-player-18717",
        "has_defuse_kit": true
      },
      "round_number": 5
    }
  }
}
cs2.live.stateActive

A snapshot of a live match: status, score and current map.

When
Repeatedly during a live match.
Frequency
about 5,582 per day across all matches (about 221 per match)
Delivery
Median 526 ms, p95 1.5 s, from the moment Cito recorded the event to the moment a customer endpoint answered (8,983 recent deliveries).
Plan
Pro and Scale (real time)
Filters
league, matchId
Use it to
Resync a live scoreboard when you may have missed a tick.
Fields in data (11)
FieldTypeMeaning
currentMapnullThe map in play.
dataobjectThe change itself, on score events.
event_typestringThe event name.
leaguestringLeague name, or the sport id on CS2 events.
match_idstringThe match id. Works on the match routes for this sport.
matchIdstringThe match id. Works on the match routes for this sport.
previous_stateobjectThe state before the change, when the event has one.
statusstringCurrent status of the match.
team1Scorenull or numberMaps won by team 1 on match events, rounds on map events. Null while a map is being set up.
team2Scorenull or numberMaps won by team 2 on match events, rounds on map events. Null while a map is being set up.
timestampstringWhen this event happened, ISO 8601 UTC.
Full example (real payload)
JSON
{
  "event": "cs2.live.state",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T20:29:17.981Z",
  "event_type": "cs2.live.state",
  "match_id": "cs2-match-2400094",
  "previous_state": {
    "score": {
      "team1": 0,
      "team2": 1
    },
    "status": "live"
  },
  "data": {
    "data": {
      "score": {
        "team1": 0,
        "team2": 1
      },
      "status": "live",
      "current_map": null
    },
    "league": "cs2",
    "status": "live",
    "matchId": "cs2-match-2400094",
    "match_id": "cs2-match-2400094",
    "timestamp": "2026-11-08T20:29:17.966Z",
    "currentMap": null,
    "event_type": "cs2.live.state",
    "team1Score": 0,
    "team2Score": 1,
    "previous_state": {
      "score": {
        "team1": 0,
        "team2": 1
      },
      "status": "live"
    }
  }
}
cs2.live.player_stats_updatedActive

Live player stats were refreshed. A count only: data.playerStatRows says how many rows changed.

When
During a live match.
Frequency
about 565 per day across all matches (about 57 per match)
Delivery
Median 284 ms, p95 501 ms, from the moment Cito recorded the event to the moment a customer endpoint answered (3,854 recent deliveries).
Plan
Pro and Scale (real time)
Filters
league, matchId
Use it to
Re-read /cs2/live/{matchId} for fresh player lines.

Treat it as a signal to re-read /cs2/live/{matchId}. It carries no player stats.

Fields in data (8)
FieldTypeMeaning
dataobjectThe change itself, on score events.
event_typestringThe event name.
leaguestringLeague name, or the sport id on CS2 events.
match_idstringThe match id. Works on the match routes for this sport.
matchIdstringThe match id. Works on the match routes for this sport.
playerStatRowsnumberHow many player rows changed. Read /cs2/live/{matchId} for the rows.
previous_statenullThe state before the change, when the event has one.
timestampstringWhen this event happened, ISO 8601 UTC.
Full example (real payload)
JSON
{
  "event": "cs2.live.player_stats_updated",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T19:12:51.400Z",
  "event_type": "cs2.live.player_stats_updated",
  "match_id": "cs2-match-2400319",
  "previous_state": null,
  "data": {
    "data": {
      "player_stat_rows": 10
    },
    "league": "cs2",
    "matchId": "cs2-match-2400319",
    "match_id": "cs2-match-2400319",
    "timestamp": "2026-11-08T19:12:51.385Z",
    "event_type": "cs2.live.player_stats_updated",
    "playerStatRows": 10,
    "previous_state": null
  }
}
cs2.ranking.updatedNone recorded yet

A CS2 team ranking update.

When
When rankings change.
Frequency
none recorded so far
Plan
Pro and Scale
Filters
None
Use it to
Refresh a rankings table.
You can subscribe, but none has been recorded. Do not rely on it.

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 "cs2.match.started":
      // handle cs2.match.started
      break;
    case "cs2.match.live_started":
      // handle cs2.match.live_started
      break;
    case "cs2.match.live_ended":
      // handle cs2.match.live_ended
      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 "cs2.match.started":
      // handle cs2.match.started
      break;
    case "cs2.match.live_started":
      // handle cs2.match.live_started
      break;
    case "cs2.match.live_ended":
      // handle cs2.match.live_ended
      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.started":
        pass  # handle cs2.match.started
    elif event == "cs2.match.live_started":
        pass  # handle cs2.match.live_started
    elif event == "cs2.match.live_ended":
        pass  # handle cs2.match.live_ended

@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.started":
        pass  # handle cs2.match.started
    elif event == "cs2.match.live_started":
        pass  # handle cs2.match.live_started
    elif event == "cs2.match.live_ended":
        pass  # handle cs2.match.live_ended

@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.started":
		// handle cs2.match.started
	case "cs2.match.live_started":
		// handle cs2.match.live_started
	case "cs2.match.live_ended":
		// handle cs2.match.live_ended
	}
}

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?