โ—† Beta Gamer

Webhooks

We POST signed HTTP callbacks to your server when session events occur. Webhooks only fire for live sessions โ€” never for test or training.

โš 
Earlier documentation also listed 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

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 added via /sessions/:id/botsAll
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. 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

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

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

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);
});
Beta Gamer GaaS API โ€” questions? support@beta-gamer.com