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:
| Event | When | What you do | Retries |
|---|---|---|---|
match_found | Players 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_aborted | A 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_result | A 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_complete | A tournament ended. | Save the final standings; distribute prizes per your policy. | forever |
tournament_round_started | A new round's pairings were generated (optional event). | UI hint only — safe to ignore. | 20 attempts |
fairplay_flag | The engine's heuristics flagged suspicious play. | Feed your moderation queue. The engine only signals — enforcement (bans, rating rollback) is yours. | 20 attempts |
// 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.
// 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 response | Engine behaviour |
|---|---|
2xx or 409 | Delivered. 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 error | Retried 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:
| Rule | Why |
|---|---|
| Dedupe by eventId | Delivery 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 needed | The 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 ordering | Events for the same match can arrive out of order. Reconstruct sequence from matchId + type, not arrival time. |
| Unknown event types still get 2xx | New 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:
# 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.