Callbacks & reliability

The receiving side of the integration: one webhook, six event types, and the delivery semantics — at-least-once from the engine, idempotent on your side, exactly-once in effect.

The six events

Every callback is a POST to your webhook with a unique eventId. What each one asks of you:

EventWhenWhat you doRetries
match_foundPlayers were seated (matchmaking pair, bot fallback, tournament round).Bookkeeping / notifications. The engine already pushed the game to both sockets. One real job: nudge an offline tournament entrant whose round just opened.20 attempts
match_abortedA game ended with no result — gate no-show or pre-play abort.Clear any in-game UI state; your own policy for notifying. The engine already requeued the matchmaking player who showed up / forfeited the tournament absentee.20 attempts
game_resultA game finished — the moment ratings change.Persist each player's ratingAfter into ratings[variant + ":" + speed], clamp to your tracked floor, release your busy view of the user. Unrated games carry no rating block — record only.forever
tournament_completeA tournament ended.Save the final standings; distribute prizes per your policy.forever
tournament_round_startedA new round's pairings were generated (optional event).UI hint only — safe to ignore.20 attempts
fairplay_flagThe engine's heuristics flagged suspicious play.Feed your moderation queue. The engine only signals — enforcement (bans, rating rollback) is yours.20 attempts
ts
// One endpoint, six event types. Reply 2xx once processed; 409 for a
// duplicate eventId (treated exactly like 2xx — delivered, retries stop).
app.post("/callbacks", (req, res) => {
  const ev = req.body;
  if (seen(ev.eventId)) return res.sendStatus(409);   // duplicate — idempotent
  switch (ev.type) {
    case "match_found":              notifyPlayers(ev);    break;
    case "match_aborted":            clearGameUI(ev);      break;
    case "game_result":              applyRatings(ev);     break;
    case "tournament_complete":      saveStandings(ev);    break;
    case "tournament_round_started": pingRoundUI(ev);      break;
    case "fairplay_flag":            reviewSignal(ev);     break;
    default: /* unknown type — still 2xx: never dead-letter on novelty */ break;
  }
  markSeen(ev.eventId);
  res.sendStatus(200);
});

Field-by-field payload tables for all six events: Callbacks reference.

game_result — the one that matters most

The moment ratings change. The engine computes Glicko-2; you store the result — the engine keeps no canonical copy.

jsonc
// game_result — the payload that changes ratings. It names its OWN category
// (variant + timeControl + speed), so you file it straight from the payload —
// no lookup, no remembering what match_found said:
{
  "eventId": "evt_003",
  "type": "game_result",
  "matchId": "match_abc",
  "tournamentId": null,
  "variant": "standard", "timeControl": "blitz_3_2", "speed": "blitz",
  "result": "white_win",
  "termination": "checkmate",
  "startedAt": "2026-07-08 09:01:00.000",
  "endedAt":   "2026-07-08 09:07:32.000",
  "movesUci": ["e2e4", "e7e5", "g1f3"],
  "players": [
    { "userId": "user_123", "username": "magnus_fan", "color": "white", "score": 1,
      "ratingBefore": { "r": 1512.3, "rd": 84.1, "vol": 0.0598, "lastGameAt": 1719828000000, "floor": 1400 },
      "ratingAfter":  { "r": 1524.7, "rd": 78.2, "vol": 0.0597 }, "delta": 12.4 },
    { "userId": "user_456", "username": "the_turk", "color": "black", "score": 0,
      "ratingBefore": { "r": 1600.0, "rd": 60.0, "vol": 0.0600 },
      "ratingAfter":  { "r": 1589.1, "rd": 58.4, "vol": 0.0601 }, "delta": -10.9 }
  ]
}
applyRating(ev);   // → ratings[ev.variant + ":" + ev.speed] = ratingAfter (clamp floor)

// Unrated game → identical payload, ratingBefore/After/delta simply OMITTED.
// Custom (non-catalog) time control → timeControl and speed are EMPTY strings:
// the game belongs to no rating category — record it, do not rate it.

⚠ The category travels with the result so that a result recovered via the pull backstop — arriving with no match_found before it — can still be filed correctly. Never reconstruct the category from the match id.

Delivery semantics

When a game finishes, the engine writes the callback row in the same DB transaction as the game result (an outbox). A worker drains due rows and POSTs them — so a crash on either side never loses an event. Your response decides what happens next:

Your responseEngine behaviour
2xx or 409Delivered. 409 means “duplicate eventId” — the engine treats it exactly like success.
permanent 4xx (≠ 409)Dead-lettered immediately + alert on the engine side — a rejected payload will never succeed by retrying. Never reply 4xx for transient problems.
5xx / timeout / transport errorRetried with backoff [1, 2, 5, 15, 30, 60]s (last repeats). game_result and tournament_complete retry forever; everything else gives up after 20 attempts and dead-letters (never silently — surfaced on the engine's admin status).

The four rules of a correct receiver:

RuleWhy
Dedupe by eventIdDelivery is at-least-once — duplicates WILL arrive (a retry can cross your ACK). Process each eventId once; reply 2xx/409 for repeats. eventIds are deterministic (e.g. match_aborted:<matchId>), so even a re-generated event dedupes.
Reply fast, process async if neededThe engine's timeout is what turns a slow handler into a retry storm. If processing is heavy, enqueue internally and ACK immediately — you already have the dedupe.
Never assume orderingEvents for the same match can arrive out of order. Reconstruct sequence from matchId + type, not arrival time.
Unknown event types still get 2xxNew optional events may appear. Ignoring + ACKing is forward-compatible; 4xx would dead-letter them.

Reconciliation — the pull backstop

Run this on a schedule (e.g. every few minutes). It exists for the day your webhook is down longer than the retry window, a deploy loses events, or you restore from backup:

http
# The pull backstop — anything the push path never delivered is recoverable.
# since = unix ms cursor (also accepts "Y-m-d H:i:s.v" or RFC3339); empty = from
# the beginning. limit default 200, max 1000. Results oldest-first.
GET /v1/matches/results?since=<cursor>&limit=200
X-Service-Auth: <secret>

→ 200
{
  "success": true, "response": "success", "responseCode": 200,
  "data": {
    "results": [ { /* identical game_result payloads */ } ],
    "count": 12,
    "nextCursor": "1720429652000"
  }
}
# Persist nextCursor between runs; dedupe by the SAME eventId as the webhook.

At-least-once (engine) + idempotent (you) = exactly-once effect: ratings are never lost, never double-applied. The pull path shares the webhook's eventIds, so both paths dedupe against the same set.