Webhooks
We POST signed HTTP callbacks to your server when session events occur. Webhooks only fire for live sessions โ never for test or training.
game.move, session.expired, chess.check, chess.promotion, chess.castle, chess.afk_warning, chess.afk_timeout, checkers.king, checkers.capture, checkers.afk_warning, checkers.afk_timeout, connect4.threat, and tictactoe.fork. None of these are dispatched anywhere in the current service. If you built against them, switch to reading move-by-move state from the WebSocket connection and the final result on game.ended instead.Events
| Event | When it fires | Games |
|---|---|---|
session.created | A live session is created via the API | All |
session.started | All expected players have connected and the game begins | All |
player.joined | A player joins via /sessions/join, or a bot is added via /sessions/:id/bots | All |
player.left | A connected player disconnects mid-game | All |
game.ended | The session reaches a terminal state, for any result reason | All |
Payload envelope
Every webhook shares the same outer envelope. The data field varies by event type, but always includes sessionId, game, mode, and players.
{
"event": "game.ended", // one of the 5 events above
"deliveryId": "550e8400-...", // string (UUID) โ unique per delivery attempt
"data": {
"sessionId": "a1b2c3d4-...",
"game": "chess", // chess | checkers | connect4 | tictactoe |
// subway-runner | pool | chkobe | archery
"mode": "live", // always "live" โ webhooks never fire otherwise
"players": [ { "id": "user_123", "displayName": "Alex" }, ... ]
// ...additional event-specific fields merged in 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 the raw body |
X-BetaGamer-Delivery | string (UUID) | Unique delivery ID, matches deliveryId in the body |
Content-Type | string | Always application/json |
Event payloads
session.created / session.started
{
"sessionId": "a1b2c3d4-...",
"game": "chess",
"mode": "live",
"players": [
{ "id": "user_123", "displayName": "Alex" },
{ "id": "user_456", "displayName": "Jordan" }
],
"matchType": "matchmaking", // matchmaking | private | bot | hosted
"roomCode": null, // string, only for private / hosted
"createdAt": "2026-06-02T14:00:00Z"
}player.joined
{
"sessionId": "a1b2c3d4-...",
"game": "chess",
"mode": "live",
"player": { "id": "user_456", "displayName": "Jordan" },
"players": [ /* full updated players[] including the new joiner */ ]
}Fires both when a human joins a private room via /sessions/join, and when a bot is added via /sessions/:id/bots โ check player.isBot to tell the two apart.
player.left
{
"sessionId": "a1b2c3d4-...",
"game": "chess",
"mode": "live",
"playerId": "user_456",
"players": [ /* remaining players after this one disconnected */ ]
}game.ended
{
"sessionId": "a1b2c3d4-...",
"game": "chess",
"mode": "live",
"players": [ /* SessionPlayer[] */ ],
"result": {
"reason": "checkmate", // see reason values below
"duration": 342, // integer โ seconds
"winnerGroupId": "g_1", // absent on a draw
"groups": [
{ "id": "g_1", "players": [ { "id": "user_123", "displayName": "Alex" } ], "won": true },
{ "id": "g_2", "players": [ { "id": "user_456", "displayName": "Jordan" } ], "won": false }
],
"finalState": { /* game-specific snapshot */ }
// chess also retains legacy top-level fields for backward compatibility:
// "winner": "user_123", "pgn": "1. e4 e5 2. Nf3 ...", "fen": "rnbqkb1r/..."
}
}Result reason values
One shared enum across every game โ not a per-game set of strings:
checkmateChess โ a king is checkmated.resignationA player resigned mid-game.timeoutA playerโs clock or turn timer ran out.disconnectA player failed to reconnect within the grace window.drawAgreed draw, stalemate, or a game-specific move limit reached.collisionSubway Runner โ the runner hit an obstacle.game_overGeneral terminal state โ board filled, target cleared, or match limit reached.Verifying 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 'player.left': /* pause / forfeit handling */ break;
}
res.sendStatus(200); // acknowledge within 5 seconds
});express.raw() (or equivalent) to read the raw body before parsing JSON. Parsing first changes the byte representation and breaks signature verification.Retry policy
Higher-tier plans (Pro and Enterprise) get up to 5 total attempts โ see rate limits & plans. Every attempt, delivered or not, is recorded and visible in your dashboard's delivery log.
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 a prior attempt timed out after your handler had already run.
const processed = new Set(); // use Redis or a DB table 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);
});