Webhooks
We POST signed HTTP callbacks to your server when game events occur. Webhooks only fire for live sessions — never for test or training.
Events
| Event | When it fires | Games |
|---|---|---|
session.created | Session is created via API | All |
session.started | Both players connect and game begins | All |
session.expired | Session times out before players join | All |
game.move | A player makes a move | All |
game.ended | Game finishes (any reason) | All |
chess.check | A king is placed in check | chess |
chess.promotion | A pawn is promoted | chess |
chess.castle | A castling move is made | chess |
chess.afk_warning | Active player idle 60 s — 30 s to act | chess |
chess.afk_timeout | Player forfeited for inactivity (90 s) | chess |
checkers.king | A piece is kinged | checkers |
checkers.capture | One or more pieces captured (incl. multi-jump) | checkers |
checkers.afk_warning | Active player idle 60 s — 30 s to act | checkers |
checkers.afk_timeout | Player forfeited for inactivity (90 s) | checkers |
connect4.threat | A player has 3-in-a-row with open end | connect4 |
tictactoe.fork | A player creates a double-threat fork | tictactoe |
Payload envelope
Every webhook shares the same outer envelope. The data field varies by event type.
{
"event": "game.ended", // string — event type
"deliveryId": "550e8400-...", // string (UUID) — unique per delivery attempt
"sessionId": "a1b2c3d4-...", // string (UUID)
"game": "chess", // "chess" | "checkers" | "connect4" | "tictactoe" | "subway-runner"
"mode": "live", // always "live"
"timestamp": "2026-03-15T09:00:00Z",// ISO 8601 UTC
"data": { ... } // event-specific payload (see below)
}HTTP headers
| Header | Type | Description |
|---|---|---|
X-BetaGamer-Event | string | Event type, e.g. game.ended |
X-BetaGamer-Signature | string | sha256=<hmac-hex> — HMAC-SHA256 of raw body |
X-BetaGamer-Delivery | string (UUID) | Unique delivery ID, matches deliveryId in body |
Content-Type | string | Always application/json |
Event payloads
session.created / session.started / session.expired
{
"players": [
{ "id": "user_123", "displayName": "Alex" },
{ "id": "user_456", "displayName": "Jordan" }
],
"matchType": "matchmaking" // "matchmaking" | "private" | "bot"
}game.move
{
"playerId": "user_123",
"moveIndex": 4, // integer — 0-based move number
"move": "e2e4", // string — game-specific notation (see below)
"timestamp": "2026-03-15T09:00:05Z"
}| Game | move format | Example |
|---|---|---|
| chess | UCI notation | "e2e4", "e7e8q" (promotion) |
| checkers | "fromSquare-toSquare" | "11-15", "22x13" (capture) |
| connect4 | column index (0–6) | "3" |
| tictactoe | "row,col" (0–2) | "1,2" |
| subway-runner | action string | "jump", "slide", "left", "right" |
game.ended
{
"players": [
{ "id": "user_123", "displayName": "Alex" },
{ "id": "user_456", "displayName": "Jordan" }
],
"result": {
"winner": "user_123", // string (player id) | null (draw)
"reason": "checkmate", // see reason values per game below
"duration": 342, // integer — seconds
"moveCount": 38, // integer
"scores": { "user_123": 1, "user_456": 0 }, // float — 0.5 each for draw
"pgn": "1. e4 e5 ..." // chess only — full PGN string | null for other games
}
}| Game | reason values |
|---|---|
| chess | "checkmate" | "resignation" | "timeout" | "stalemate" | "draw_agreement" | "insufficient_material" | "repetition" |
| checkers | "victory" | "resignation" | "afk_timeout" | "draw" | "disconnect" |
| connect4 | "four_in_a_row" | "resignation" | "timeout" | "draw" |
| tictactoe | "three_in_a_row" | "draw" | "timeout" |
| subway-runner | "collision" | "timeout" | "distance_limit" |
Game-specific events
Some games emit additional events beyond the standard set. See each game's webhook reference for full payload details.
chess→
chess.checkchess.promotionchess.castlecheckers→
checkers.kingcheckers.capturecheckers.afk_warningcheckers.afk_timeoutconnect4→
connect4.threattictactoe→
tictactoe.forkVerifying the signature
Always verify X-BetaGamer-Signature before processing. Use your webhookSecret from the dashboard.
const crypto = require('crypto');
function verifyWebhook(rawBody, signatureHeader, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody) // raw Buffer — NOT parsed JSON
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected)
);
}
// Express example
app.post('/webhooks/beta-gamer', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-betagamer-signature'];
if (!verifyWebhook(req.body, sig, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const { event, data } = JSON.parse(req.body);
switch (event) {
case 'game.ended': /* award points, update leaderboard */ break;
case 'game.move': /* broadcast to spectators */ break;
case 'chess.check': /* notify player */ break;
}
res.sendStatus(200); // acknowledge within 5 seconds
});⚠️ Use
express.raw() (or equivalent) to read the raw body before parsing JSON. Parsing first changes the byte representation and breaks signature verification.Retry policy
Timeout
5 seconds per attempt
Max retries
3 (after initial attempt)
Backoff
Exponential: 2s, 4s, 8s
Ack
Return HTTP 200 to acknowledge
Idempotency
Each delivery has a unique deliveryId. Store processed IDs to safely handle duplicates — your server may receive the same event more than once if it returned a non-200 but still processed it.
const processed = new Set(); // use Redis or DB in production
app.post('/webhooks/beta-gamer', express.raw({ type: 'application/json' }), (req, res) => {
// ... verify signature ...
const { deliveryId, event, data } = JSON.parse(req.body);
if (processed.has(deliveryId)) return res.sendStatus(200);
processed.add(deliveryId);
// handle event...
res.sendStatus(200);
});Beta Gamer GaaS API — questions? support@beta-gamer.com