Webhooks
We POST signed HTTP callbacks to your server when session events occur. Webhooks only fire for live sessions — never for test or training.
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 | 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. data varies by event, but always includes sessionId, game, mode, and players.
{
"event": "game.ended",
"deliveryId": "550e8400-...",
"data": {
"sessionId": "a1b2c3d4-...",
"game": "checkers", // checkers | tictactoe | connect4 | fight-arena | pool | ludo
"mode": "live",
"players": [ { "id": "user_123", "displayName": "Alex" }, ... ]
}
}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": "checkers",
"mode": "live",
"players": [
{ "id": "user_123", "displayName": "Alex" },
{ "id": "user_456", "displayName": "Jordan" }
],
"matchType": "matchmaking", // matchmaking | private | bot
"roomCode": null, // string, only for private
"createdAt": "2026-06-02T14:00:00Z"
}player.joined
{
"sessionId": "a1b2c3d4-...",
"game": "checkers",
"mode": "live",
"player": { "id": "user_456", "displayName": "Jordan" },
"players": [ /* full updated players[] including the new joiner */ ]
}Fires both when a human joins via /sessions/join, and when a bot is added — check player.isBot to tell them apart.
player.left
{
"sessionId": "a1b2c3d4-...",
"game": "checkers",
"mode": "live",
"playerId": "user_456",
"players": [ /* remaining players after this one disconnected */ ]
}game.ended
{
"sessionId": "a1b2c3d4-...",
"game": "checkers",
"mode": "live",
"players": [ /* SessionPlayer[] */ ],
"result": {
"reason": "resignation",
"duration": 342,
"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 */ }
}
}Result reason values
One shared enum across every game — not a per-game set of strings:
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.game_overGeneral terminal state — board filled, target cleared, or match limit reached.Verifying the signature
Always verify X-BetaGamer-Signature before processing, using 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)
);
}
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
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);
});