Docs/Realtime & Reference/Webhooks

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

EventWhen it firesGames
session.createdA live session is created via the APIAll
session.startedAll expected players have connected and the game beginsAll
player.joinedA player joins via /sessions/join, or a bot is addedAll
player.leftA connected player disconnects mid-gameAll
game.endedThe session reaches a terminal state, for any result reasonAll

Payload envelope

Every webhook shares the same outer envelope. data varies by event, but always includes sessionId, game, mode, and players.

json
{
  "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

HeaderTypeDescription
X-BetaGamer-EventstringEvent type, e.g. game.ended
X-BetaGamer-Signaturestringsha256=<hmac-hex> — HMAC-SHA256 of the raw body
X-BetaGamer-Deliverystring (UUID)Unique delivery ID, matches deliveryId in the body
Content-TypestringAlways application/json

Event payloads

session.created / session.started

json
{
  "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

json
{
  "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

json
{
  "sessionId": "a1b2c3d4-...",
  "game":      "checkers",
  "mode":      "live",
  "playerId":  "user_456",
  "players":   [ /* remaining players after this one disconnected */ ]
}

game.ended

json
{
  "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.

js
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
});
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
Total attempts
3 (1 initial + 2 retries)
Backoff
Exponential: 2s, then 4s
Ack
Return HTTP 2xx to mark delivered

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.

js
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);
});