Docs/Tennis API/Webhooks

Tennis webhooks

Instead of polling for results, get a signed POST when a match starts, each set ends, the result is final and the full serve statistics are in. ATP, WTA, Challenger and ITF. On Pro and Scale.

Which events do I need?

Live scoreboard

tennis.match.startedtennis.set.completedtennis.match.completed

Match start, each set, and the final. For point-by-point score use /tennis/live/stream.

Settle bets

tennis.match.completedtennis.match.stats_ready

The final result with outcome (retirement and walkover included), then full stats for stat markets.

Results only

tennis.match.completedtennis.match.stats_ready

The result, then the serve statistics when they are in.

Alerts

tennis.match.startedtennis.match.completed

Match start and result. Add a player filter to follow someone.

Filters

Set on the webhook, for example {"filters":{"matchId":"..."}}. 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.

matchId
Only this match. Take the id from the schedule or live routes.
tour
Leave empty for every tour.
tournamentId
Find ids with /tennis/tournaments?q=.
playerId
Matches either player. Find ids with /tennis/players/search?q=.

Recipes

Results as they finish

Subscribe to tennis.match.completed and branch on data.outcome.
JavaScript
if (event === "tennis.match.completed") {
  console.log(`${data.winner.name} d. ${data.loser.name} ${data.score} (${data.outcome})`);
}

Follow a player or a tournament

playerId matches either player. Find ids with /tennis/players/search?q= and /tennis/tournaments?q=.
JSON
{ "events": ["tennis.match.started", "tennis.match.completed"], "filters": { "tour": "ATP", "playerId": ["atp_206173"] } }

Store serve statistics

Wait for tennis.match.stats_ready, which carries the full match, instead of polling after the result.
JavaScript
if (event === "tennis.match.stats_ready") {
  await saveStats(data.id, data.stats, data.point_stats);
}

More: recipes for every sport.

Good to know

  • Match, player and tournament ids are the same ones /tennis/matches returns, and the envelope match_id equals data.id.
  • On tennis.match.completed, player1 is the winner, as on /matches/{id}. On tennis.set.completed, set_score lists player1 first.
  • tennis.match.stats_ready is not sent when a match's statistics stay partial, or for ITF matches, which are score-only.
  • Old matches never fire: events are sent as matches happen, not for the archive.

All 4 events

4 of 4 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=tennis.

tennis.match.startedActive

A tennis match went live. data is the match as /tennis/matches/live returns it.

When
A match goes live (first point played). Once per match.
Frequency
about 4 per day across all matches
Delivery
Median 128 ms, p95 282 ms, from the moment Cito recorded the event to the moment a customer endpoint answered (29 recent deliveries).
Plan
Pro and Scale
Filters
matchId, tour, tournamentId, playerId
Use it to
Flip a match to live and start your scoreboard.

The envelope match_id equals data.id.

Fields in data (34)
FieldTypeMeaning
best_ofnumber3 or 5 sets.
canonical_idstringThe id to store. Same as id for almost all matches.
canonical_tournament_idstringThe tournament id to store.
courtnull or stringCourt name when known.
current_setnumberThe set being played.
game_scorestringPoint score of the current game.
idstringThe match id. Works on /tennis/matches/{id}. Same as the envelope match_id.
is_simulatedboolean
last_updated_atstringWhen this match last changed.
live_match_idstringThe id on /tennis/matches/live.
losernullLosing player. Null while live.
match_idstringThe match id. Works on the match routes for this sport.
outcomenullCOMPLETED, RETIREMENT, WALKOVER, DEFAULT or ABANDONED.
player1objectFirst player. On completed matches player1 is the winner, as on /matches/{id}.
player2objectSecond player.
roundstringRound code, for example F or Q.
round_namestringRound name in words.
scheduled_atstringScheduled start, ISO 8601 UTC.
servernumberWho is serving: 1 or 2.
setsarrayEach set with games per player and any tiebreak.
started_atstringWhen the match started.
stats_availablebooleanTrue when serve statistics exist for this match.
stats_points_countednull or number
stats_unavailable_codenull or string
statusstringIN_PROGRESS or COMPLETED.
surfacestringHard, Clay, Grass or Carpet.
tiebreak_scorenullTiebreak points while one is being played.
tourstringATP, WTA and so on.
tournament_display_namestring
tournament_idstringTournament id. Works on /tennis/tournaments/{id}.
tournament_namestringTournament name.
tournament_short_namestring
winnernullWinning player. Null while live.
winner_sidenull1 or 2.
Full example (real payload)
JSON
{
  "event": "tennis.match.started",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T18:30:03.776Z",
  "event_type": "tennis.match.started",
  "match_id": "{matchId}",
  "previous_state": null,
  "data": {
    "id": "{matchId}",
    "sets": [
      {
        "score": "0-0",
        "set_num": 1,
        "tiebreak": null,
        "set_number": 1,
        "is_completed": false,
        "player1_games": 0,
        "player2_games": 0,
        "tiebreak_points": null
      }
    ],
    "tour": "ATP",
    "court": "Cancha Central",
    "loser": null,
    "round": "Q",
    "server": 1,
    "status": "IN_PROGRESS",
    "winner": null,
    "best_of": 3,
    "outcome": null,
    "player1": {
      "id": "atp_200335",
      "ioc": "BRA",
      "name": "Felipe Meligeni",
      "seed": null,
      "sets_won": 0,
      "is_serving": true,
      "canonical_id": "atp_200335",
      "current_game_score": "15"
    },
    "player2": {
      "id": "atp_111797",
      "ioc": "CHI",
      "name": "Nicolas Jarry",
      "seed": null,
      "sets_won": 0,
      "is_serving": false,
      "canonical_id": "atp_111797",
      "current_game_score": "0"
    },
    "surface": "Clay",
    "match_id": "{matchId}",
    "game_score": "15-0",
    "round_name": "Qualifying (round unspecified)",
    "started_at": "2026-11-08T18:20:00+00:00",
    "current_set": 1,
    "winner_side": null,
    "canonical_id": "{matchId}",
    "is_simulated": false,
    "scheduled_at": "2026-11-08T18:20:00+00:00",
    "live_match_id": "{id}",
    "tournament_id": "{id}-challenger-lima-6948-7",
    "tiebreak_score": null,
    "last_updated_at": "2026-11-08T18:29:54.118590+00:00",
    "stats_available": false,
    "tournament_name": "Lima",
    "stats_points_counted": null,
    "tournament_short_name": "Lima",
    "stats_unavailable_code": "not_published_yet",
    "canonical_tournament_id": "{id}-challenger-lima-6948-7",
    "tournament_display_name": "Lima CH"
  }
}
tennis.set.completedActive

A set finished: data.set_number, data.set_score, data.set_winner_side, data.set_winner, plus the live match.

When
A set finishes. Once per set; the last set arrives just before tennis.match.completed.
Frequency
about 8 per day across all matches (about 2 per match)
Delivery
Median 125 ms, p95 299 ms, from the moment Cito recorded the event to the moment a customer endpoint answered (56 recent deliveries).
Plan
Pro and Scale
Filters
matchId, tour, tournamentId, playerId
Use it to
Update the set score and alert on set winners.

data.set_score lists player1 first.

Fields in data (41)
FieldTypeMeaning
best_ofnumber3 or 5 sets.
canonical_idstringThe id to store. Same as id for almost all matches.
canonical_tournament_idstringThe tournament id to store.
courtnull or stringCourt name when known.
current_setnumberThe set being played.
game_scorestringPoint score of the current game.
idstringThe match id. Works on /tennis/matches/{id}. Same as the envelope match_id.
is_simulatedboolean
last_updated_atstringWhen this match last changed.
live_match_idstringThe id on /tennis/matches/live.
losernull or objectLosing player. Null while live.
match_idstringThe match id. Works on the match routes for this sport.
outcomenull or stringCOMPLETED, RETIREMENT, WALKOVER, DEFAULT or ABANDONED.
player1objectFirst player. On completed matches player1 is the winner, as on /matches/{id}.
player2objectSecond player.
result_availablenull
result_unavailable_reasonnull
roundstringRound code, for example F or Q.
round_namestringRound name in words.
scheduled_atstringScheduled start, ISO 8601 UTC.
servernumberWho is serving: 1 or 2.
setobjectThe set that finished, with games and tiebreak.
set_numbernumberWhich set finished.
set_scorestringGames in that set, player1 first.
set_winnerobjectPlayer who won the set.
set_winner_sidenumber1 or 2.
setsarrayEach set with games per player and any tiebreak.
started_atstringWhen the match started.
stats_availablebooleanTrue when serve statistics exist for this match.
stats_points_countednull or number
stats_unavailable_codenull or string
statusstringIN_PROGRESS or COMPLETED.
surfacestringHard, Clay, Grass or Carpet.
tiebreak_scorenullTiebreak points while one is being played.
tourstringATP, WTA and so on.
tournament_display_namestring
tournament_idstringTournament id. Works on /tennis/tournaments/{id}.
tournament_namestringTournament name.
tournament_short_namestring
winnernull or objectWinning player. Null while live.
winner_sidenull or number1 or 2.
Full example (real payload)
JSON
{
  "event": "tennis.set.completed",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T17:19:04.284Z",
  "event_type": "tennis.set.completed",
  "match_id": "{matchId}",
  "previous_state": null,
  "data": {
    "id": "{matchId}",
    "set": {
      "score": "6-7",
      "set_num": 2,
      "tiebreak": "4-7",
      "set_number": 2,
      "is_completed": true,
      "player1_games": 6,
      "player2_games": 7,
      "tiebreak_points": {
        "loser": 4,
        "winner": 7,
        "player1": 4,
        "player2": 7,
        "in_progress": false
      }
    },
    "sets": [
      {
        "score": "1-6",
        "set_num": 1,
        "tiebreak": null,
        "set_number": 1,
        "is_completed": true,
        "player1_games": 1,
        "player2_games": 6,
        "tiebreak_points": null
      },
      {
        "score": "6-7",
        "set_num": 2,
        "tiebreak": "4-7",
        "set_number": 2,
        "is_completed": true,
        "player1_games": 6,
        "player2_games": 7,
        "tiebreak_points": {
          "loser": 4,
          "winner": 7,
          "player1": 4,
          "player2": 7,
          "in_progress": false
        }
      }
    ],
    "tour": "ATP",
    "court": "CENTRE COURT",
    "loser": {
      "id": "atp_111200",
      "age": null,
      "ioc": "SWE",
      "name": "Elias Ymer",
      "rank": 245,
      "seed": null,
      "entry": null,
      "is_winner": false,
      "rank_date": "2026-11-02",
      "canonical_id": "atp_111200"
    },
    "round": "R32",
    "server": 1,
    "status": "FINISHED",
    "winner": {
      "id": "atp_207678",
      "age": null,
      "ioc": "ARG",
      "name": "Juan Manuel Cerundolo",
      "rank": 54,
      "seed": null,
      "entry": null,
      "is_winner": true,
      "rank_date": "2026-11-02",
      "canonical_id": "atp_207678"
    },
    "best_of": 3,
    "outcome": "COMPLETED",
    "player1": {
      "id": "atp_111200",
      "ioc": "SWE",
      "name": "Elias Ymer",
      "seed": null,
      "sets_won": 0,
      "is_serving": true,
      "canonical_id": "atp_111200",
      "current_game_score": "0"
    },
    "player2": {
      "id": "atp_207678",
      "ioc": "ARG",
      "name": "Juan Manuel Cerundolo",
      "seed": null,
      "sets_won": 2,
      "is_serving": false,
      "canonical_id": "atp_207678",
      "current_game_score": "0"
    },
    "surface": "Hard",
    "match_id": "{matchId}",
    "set_score": "6-7",
    "game_score": "0-0",
    "round_name": "Round of 32",
    "set_number": 2,
    "set_winner": {
      "id": "atp_207678",
      "ioc": "ARG",
      "name": "Juan Manuel Cerundolo",
      "seed": null,
      "sets_won": 2,
      "is_serving": false,
      "canonical_id": "atp_207678",
      "current_game_score": "0"
    },
    "started_at": "2026-11-08T15:30:00+00:00",
    "current_set": 2,
    "winner_side": 2,
    "canonical_id": "{matchId}",
    "is_simulated": false,
    "scheduled_at": "2026-11-08T15:30:00+00:00",
    "live_match_id": "{id}",
    "tournament_id": "atp_2026_0429",
    "tiebreak_score": null,
    "last_updated_at": "2026-11-08T17:18:59.328323+00:00",
    "set_winner_side": 2,
    "stats_available": true,
    "tournament_name": "Stockholm",
    "result_available": null,
    "tournament_short_name": "Stockholm",
    "stats_unavailable_code": null,
    "canonical_tournament_id": "atp_2026_0429",
    "tournament_display_name": "Stockholm",
    "result_unavailable_reason": null
  }
}
tennis.match.completedActive

A tennis result is final. data is the match as /tennis/matches/{id} returns it, without stats and point_stats.

When
The result is final. Once per match.
Frequency
about 3 per day across all matches
Plan
Pro and Scale
Filters
matchId, tour, tournamentId, playerId
Use it to
Settle on data.winner, data.score and data.outcome. Handle RETIREMENT and WALKOVER by your own rules.

As on /tennis/matches/{id}, player1 is the winner. data.outcome is COMPLETED, RETIREMENT, WALKOVER, DEFAULT or ABANDONED.

Fields in data (52)
FieldTypeMeaning
best_ofnumber3 or 5 sets.
canonical_idstringThe id to store. Same as id for almost all matches.
canonical_tournament_idstringThe tournament id to store.
courtnull or stringCourt name when known.
current_setnumberThe set being played.
date_is_tournament_startboolean
duration_minutesnullMatch length in minutes.
game_scorestringPoint score of the current game.
idstringThe match id. Works on /tennis/matches/{id}. Same as the envelope match_id.
is_simulatedboolean
last_updated_atstringWhen this match last changed.
live_match_idnull or stringThe id on /tennis/matches/live.
loserobjectLosing player. Null while live.
loser_idstringLosing player id.
loser_partnernull
loser_partner_idnull
loser_sets_wonnumberSets won by the loser.
match_datestringMatch date, YYYY-MM-DD.
match_idstringThe match id. Works on the match routes for this sport.
outcomestringCOMPLETED, RETIREMENT, WALKOVER, DEFAULT or ABANDONED.
player1objectFirst player. On completed matches player1 is the winner, as on /matches/{id}.
player2objectSecond player.
qualifying_roundnull
result_availableboolean or null
result_unavailable_reasonnull
roundstringRound code, for example F or Q.
round_namestringRound name in words.
scheduled_atnull or stringScheduled start, ISO 8601 UTC.
scorestringFinal score with the winner first, for example 7-6 7-5.
servernumberWho is serving: 1 or 2.
setsarrayEach set with games per player and any tiebreak.
start_time_kindnull
start_time_knownboolean
started_atstringWhen the match started.
stats_availablebooleanTrue when serve statistics exist for this match.
stats_coverageobjectHow complete the statistics are. serve_stats is "full" when this event fires.
stats_unavailable_codenull
statusstringIN_PROGRESS or COMPLETED.
surfacestringHard, Clay, Grass or Carpet.
tiebreak_scorenullTiebreak points while one is being played.
tourstringATP, WTA and so on.
tournamentobjectTournament details.
tournament_display_namestring
tournament_idstringTournament id. Works on /tennis/tournaments/{id}.
tournament_namestringTournament name.
tournament_short_namestring
winnerobjectWinning player. Null while live.
winner_idstringWinning player id.
winner_partnernull
winner_partner_idnull
winner_sets_wonnumberSets won by the winner.
winner_sidenumber1 or 2.
Full example (real payload)
JSON
{
  "event": "tennis.match.completed",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T18:17:13.519Z",
  "event_type": "tennis.match.completed",
  "match_id": "{matchId}",
  "previous_state": null,
  "data": {
    "id": "{matchId}",
    "sets": [
      {
        "tiebreak": null,
        "set_number": 1,
        "loser_games": 6,
        "winner_games": 7,
        "player1_games": 7,
        "player2_games": 6,
        "tiebreak_points": null
      },
      {
        "tiebreak": null,
        "set_number": 2,
        "loser_games": 5,
        "winner_games": 7,
        "player1_games": 7,
        "player2_games": 5,
        "tiebreak_points": null
      }
    ],
    "tour": "ATP",
    "court": null,
    "loser": {
      "id": "atp_127108",
      "age": null,
      "ioc": "BRA",
      "name": "Igor Ribeiro Marcondes",
      "rank": 279,
      "seed": null,
      "entry": null,
      "is_winner": false,
      "rank_date": "2026-11-02",
      "canonical_id": "atp_127108"
    },
    "round": "Q",
    "score": "7-6 7-5",
    "status": "COMPLETED",
    "winner": {
      "id": "atp_212944",
      "age": null,
      "ioc": "JPN",
      "name": "Ryo Tabata",
      "rank": 934,
      "seed": null,
      "entry": null,
      "is_winner": true,
      "rank_date": "2026-11-02",
      "canonical_id": "atp_212944"
    },
    "best_of": 3,
    "outcome": "COMPLETED",
    "player1": {
      "id": "atp_212944",
      "age": null,
      "ioc": "JPN",
      "name": "Ryo Tabata",
      "rank": 934,
      "seed": null,
      "entry": null,
      "is_winner": true,
      "rank_date": "2026-11-02",
      "canonical_id": "atp_212944"
    },
    "player2": {
      "id": "atp_127108",
      "age": null,
      "ioc": "BRA",
      "name": "Igor Ribeiro Marcondes",
      "rank": 279,
      "seed": null,
      "entry": null,
      "is_winner": false,
      "rank_date": "2026-11-02",
      "canonical_id": "atp_127108"
    },
    "surface": "Clay",
    "loser_id": "atp_127108",
    "match_id": "{matchId}",
    "winner_id": "atp_212944",
    "match_date": "2026-11-08",
    "round_name": "Qualifying (round unspecified)",
    "tournament": {
      "id": "{id}-challenger-lima-6948-7",
      "city": "Lima",
      "name": "Lima CH",
      "year": 2026,
      "level": "C",
      "country": "PER",
      "surface": "Clay",
      "category": "challenger",
      "short_name": "Lima",
      "canonical_id": "{id}-challenger-lima-6948-7",
      "display_name": "Lima CH",
      "edition_category": "Challenger 75"
    },
    "winner_side": 1,
    "canonical_id": "{matchId}",
    "is_simulated": false,
    "scheduled_at": null,
    "live_match_id": null,
    "loser_partner": null,
    "tournament_id": "{id}-challenger-lima-6948-7",
    "loser_sets_won": 0,
    "stats_coverage": {
      "note": "Some serve statistics were not published for this match; the fields in missing are null.",
      "reason": "partially_published",
      "missing": [
        "break_points_saved",
        "break_points_faced"
      ],
      "serve_stats": "partial",
      "expected_later": false,
      "missing_by_side": {
        "loser": [
          "break_points_saved",
          "break_points_faced"
        ],
        "winner": [
          "break_points_saved",
          "break_points_faced"
        ]
      }
    },
    "winner_partner": null,
    "start_time_kind": null,
    "tournament_name": "Lima CH",
    "winner_sets_won": 2,
    "duration_minutes": null,
    "loser_partner_id": null,
    "qualifying_round": null,
    "result_available": true,
    "start_time_known": false,
    "winner_partner_id": null,
    "tournament_short_name": "Lima",
    "canonical_tournament_id": "{id}-challenger-lima-6948-7",
    "tournament_display_name": "Lima CH",
    "date_is_tournament_start": false,
    "result_unavailable_reason": null
  }
}
tennis.match.stats_readyActive

The full serve statistics of a completed match are available. data is the full /tennis/matches/{id} body.

When
data.stats_coverage.serve_stats becomes "full", which can be minutes or hours after the result. Once per match; not sent for matches whose statistics stay partial.
Frequency
about 4 per week
Plan
Pro and Scale
Filters
matchId, tour, tournamentId, playerId
Use it to
Store the full serve statistics. Do not poll for them after the result: wait for this.
Fields in data (46)
FieldTypeMeaning
best_ofnumber3 or 5 sets.
canonical_idstringThe id to store. Same as id for almost all matches.
canonical_tournament_idstringThe tournament id to store.
courtnull or stringCourt name when known.
date_is_tournament_startboolean
duration_minutesnull or numberMatch length in minutes.
idstringThe match id. Works on /tennis/matches/{id}. Same as the envelope match_id.
is_simulatedboolean
live_match_idnullThe id on /tennis/matches/live.
loserobjectLosing player. Null while live.
loser_idstringLosing player id.
loser_partnernull
loser_partner_idnull
loser_sets_wonnumberSets won by the loser.
match_datestringMatch date, YYYY-MM-DD.
match_idstringThe match id. Works on the match routes for this sport.
outcomestringCOMPLETED, RETIREMENT, WALKOVER, DEFAULT or ABANDONED.
player1objectFirst player. On completed matches player1 is the winner, as on /matches/{id}.
player2objectSecond player.
point_statsnull or objectPoint-level statistics.
qualifying_roundnull
result_availableboolean
result_unavailable_reasonnull
roundstringRound code, for example F or Q.
round_namestringRound name in words.
scheduled_atnull or stringScheduled start, ISO 8601 UTC.
scorestringFinal score with the winner first, for example 7-6 7-5.
setsarrayEach set with games per player and any tiebreak.
start_time_kindnull or string
start_time_knownboolean
statsobjectServe and return statistics for winner and loser.
stats_coverageobjectHow complete the statistics are. serve_stats is "full" when this event fires.
statusstringIN_PROGRESS or COMPLETED.
surfacestringHard, Clay, Grass or Carpet.
tourstringATP, WTA and so on.
tournamentobjectTournament details.
tournament_display_namestring
tournament_idstringTournament id. Works on /tennis/tournaments/{id}.
tournament_namestringTournament name.
tournament_short_namestring
winnerobjectWinning player. Null while live.
winner_idstringWinning player id.
winner_partnernull
winner_partner_idnull
winner_sets_wonnumberSets won by the winner.
winner_sidenumber1 or 2.
Full example (real payload)
JSON
{
  "event": "tennis.match.stats_ready",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T20:21:49.492Z",
  "event_type": "tennis.match.stats_ready",
  "match_id": "{matchId}",
  "previous_state": null,
  "data": {
    "id": "{matchId}",
    "sets": [
      {
        "tiebreak": {
          "loser_points": 3,
          "winner_points": 7
        },
        "set_number": 1,
        "loser_games": 6,
        "winner_games": 7,
        "player1_games": 7,
        "player2_games": 6,
        "tiebreak_points": {
          "loser": 3,
          "winner": 7,
          "player1": 7,
          "player2": 3,
          "in_progress": false
        }
      },
      {
        "tiebreak": null,
        "set_number": 2,
        "loser_games": 4,
        "winner_games": 6,
        "player1_games": 6,
        "player2_games": 4,
        "tiebreak_points": null
      }
    ],
    "tour": "ATP",
    "court": "Center",
    "loser": {
      "id": "{playerId}",
      "age": null,
      "ioc": null,
      "name": "Stephanos Schinas",
      "rank": null,
      "seed": null,
      "entry": null,
      "is_winner": false,
      "rank_date": null,
      "canonical_id": "{playerId}"
    },
    "round": "Q",
    "score": "7-6 6-4",
    "stats": {
      "loser": {
        "aces": 7,
        "serve_points": 69,
        "double_faults": 5,
        "first_serve_pct": 60.9,
        "first_serves_in": 42,
        "first_serves_won": 31,
        "total_points_won": 65,
        "return_points_won": 19,
        "second_serves_won": 15,
        "break_points_faced": 4,
        "break_points_saved": 2,
        "max_games_in_a_row": 2,
        "points_won_last_10": 6,
        "service_points_won": 46,
        "first_serve_win_pct": 73.8,
        "first_serve_won_pct": 73.8,
        "max_points_in_a_row": 5,
        "return_points_played": 66,
        "second_serve_win_pct": 55.6,
        "second_serve_won_pct": 55.6,
        "service_games_played": 11,
        "service_points_played": 69,
        "break_points_converted": 1,
        "break_points_saved_pct": 50,
        "first_serve_points_won": 31,
        "first_return_points_won": 12,
        "second_serve_points_won": 15,
        "second_return_points_won": 7,
        "break_point_opportunities": 2
      },
      "winner": {
        "aces": 0,
        "serve_points": 66,
        "double_faults": 0,
        "first_serve_pct": 66.7,
        "first_serves_in": 44,
        "first_serves_won": 32,
        "total_points_won": 70,
        "return_points_won": 23,
        "second_serves_won": 15,
        "break_points_faced": 2,
        "break_points_saved": 1,
        "max_games_in_a_row": 3,
        "points_won_last_10": 4,
        "service_points_won": 47,
        "first_serve_win_pct": 72.7,
        "first_serve_won_pct": 72.7,
        "max_points_in_a_row": 5,
        "return_points_played": 69,
        "second_serve_win_pct": 68.2,
        "second_serve_won_pct": 68.2,
        "service_games_played": 11,
        "service_points_played": 66,
        "break_points_converted": 2,
        "break_points_saved_pct": 50,
        "first_serve_points_won": 32,
        "first_return_points_won": 11,
        "second_serve_points_won": 15,
        "second_return_points_won": 12,
        "break_point_opportunities": 4
      }
    },
    "status": "COMPLETED",
    "winner": {
      "id": "{playerId}",
      "age": null,
      "ioc": "UKR",
      "name": "Nikita Mashtakov",
      "rank": null,
      "seed": null,
      "entry": null,
      "is_winner": true,
      "rank_date": null,
      "canonical_id": "{playerId}"
    },
    "best_of": 3,
    "outcome": "COMPLETED",
    "player1": {
      "id": "{playerId}",
      "age": null,
      "ioc": "UKR",
      "name": "Nikita Mashtakov",
      "rank": null,
      "seed": null,
      "entry": null,
      "is_winner": true,
      "rank_date": null,
      "canonical_id": "{playerId}"
    },
    "player2": {
      "id": "{playerId}",
      "age": null,
      "ioc": null,
      "name": "Stephanos Schinas",
      "rank": null,
      "seed": null,
      "entry": null,
      "is_winner": false,
      "rank_date": null,
      "canonical_id": "{playerId}"
    },
    "surface": "Hard",
    "loser_id": "{playerId}",
    "match_id": "{matchId}",
    "winner_id": "{playerId}",
    "match_date": "2026-11-08",
    "round_name": "Qualifying (round unspecified)",
    "tournament": {
      "id": "{id}-challenger-hersonissos-8606-9",
      "city": "Hersonissos",
      "name": "Hersonissos CH",
      "year": 2026,
      "level": "C",
      "country": "GRE",
      "surface": "Hard",
      "category": "challenger",
      "short_name": "Hersonissos",
      "canonical_id": "{id}-challenger-hersonissos-8606-9",
      "display_name": "Hersonissos CH",
      "edition_category": "Challenger 50"
    },
    "point_stats": {
      "loser": {
        "games_won": 10,
        "points_won": 65,
        "service_games": 11,
        "max_games_in_row": 2,
        "max_points_in_row": 5,
        "return_points_won": 19,
        "service_games_won": 9,
        "break_points_faced": 4,
        "break_points_saved": 2,
        "points_won_last_10": 6,
        "service_points_won": 46,
        "service_points_played": 69,
        "break_points_converted": 1,
        "break_point_opportunities": 2
      },
      "winner": {
        "games_won": 13,
        "points_won": 70,
        "service_games": 11,
        "max_games_in_row": 3,
        "max_points_in_row": 5,
        "return_points_won": 23,
        "service_games_won": 10,
        "break_points_faced": 2,
        "break_points_saved": 1,
        "points_won_last_10": 4,
        "service_points_won": 47,
        "service_points_played": 66,
        "break_points_converted": 2,
        "break_point_opportunities": 4
      }
    },
    "winner_side": 1,
    "canonical_id": "{matchId}",
    "is_simulated": false,
    "scheduled_at": "2026-11-08T14:45:54+00:00",
    "live_match_id": null,
    "loser_partner": null,
    "tournament_id": "{id}-challenger-hersonissos-8606-9",
    "loser_sets_won": 0,
    "stats_coverage": {
      "note": null,
      "reason": null,
      "missing": [],
      "serve_stats": "full",
      "expected_later": false,
      "missing_by_side": {
        "loser": [],
        "winner": []
      }
    },
    "winner_partner": null,
    "start_time_kind": "actual",
    "tournament_name": "Hersonissos CH",
    "winner_sets_won": 2,
    "duration_minutes": 94,
    "loser_partner_id": null,
    "qualifying_round": null,
    "result_available": true,
    "start_time_known": true,
    "winner_partner_id": null,
    "tournament_short_name": "Hersonissos",
    "canonical_tournament_id": "{id}-challenger-hersonissos-8606-9",
    "tournament_display_name": "Hersonissos CH",
    "date_is_tournament_start": false,
    "result_unavailable_reason": null
  }
}

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 "tennis.match.started":
      // handle tennis.match.started
      break;
    case "tennis.set.completed":
      // handle tennis.set.completed
      break;
    case "tennis.match.completed":
      // handle tennis.match.completed
      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 "tennis.match.started":
      // handle tennis.match.started
      break;
    case "tennis.set.completed":
      // handle tennis.set.completed
      break;
    case "tennis.match.completed":
      // handle tennis.match.completed
      break;
  }
}
Python / FastAPI
Python
# main.py  (pip install fastapi uvicorn)
import hashlib, hmac, json, os
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request

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

def handle(event, data):
    if event == "tennis.match.started":
        pass  # handle tennis.match.started
    elif event == "tennis.set.completed":
        pass  # handle tennis.set.completed
    elif event == "tennis.match.completed":
        pass  # handle tennis.match.completed

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

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

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

def handle(event, data):
    if event == "tennis.match.started":
        pass  # handle tennis.match.started
    elif event == "tennis.set.completed":
        pass  # handle tennis.set.completed
    elif event == "tennis.match.completed":
        pass  # handle tennis.match.completed

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

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

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

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

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

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

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

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

func main() {
	http.HandleFunc("/webhooks/cito", citoWebhook)
	log.Fatal(http.ListenAndServe(":3000", nil))
}
Was this page helpful?