Skip to content

Webhooks

Slotty Labs sends webhook events to your server when important actions occur. This guide covers all event types, payload formats, signing, and retry policies.

Event Types

These are the events the platform emits today. Subscribe to them on your dashboard under Settings → Webhooks — the panel lists exactly this set, and the API rejects unknown event names.

CategoryEvent TypeDescription
Gameround.completedA game round finished (win or loss). High volume — one event per round.
Gameround.cancelledA round was rolled back and the bet refunded (e.g., server error)
Gameround.jackpot_wonA player hit your progressive jackpot — see Progressive Jackpots
Walletwallet.deposit_confirmedA crypto or fiat deposit was credited to a player
Walletwallet.withdrawal_completedA withdrawal was approved and funds sent
Walletwallet.withdrawal_failedA withdrawal was rejected and the funds returned to the player
Playerplayer.createdA new player launched a game for the first time

Every payload carries your external playerId (plus our internal playerUuid), and money as both a decimal string (amount) and native minor units (amountMinor). Crypto wallet events include chain and txHash when available.

Catalog grows with the platform

Additional event types (responsible-gaming, system notices) will appear in the dashboard as they go live — anything listed there is guaranteed to be emitted end-to-end.

Payload Envelope

All webhook payloads follow this structure:

typescript
interface WebhookPayload<T = Record<string, unknown>> {
  id: string;              // Unique event ID (UUID v7, for idempotency)
  type: string;            // Event type, e.g. "round.completed"
  apiVersion: 'v1';        // API version that produced this event
  tenantId: string;        // Your operator tenant ID
  timestamp: string;       // ISO 8601 when event occurred
  deliveredAt: string;     // ISO 8601 when this delivery attempt was made
  data: T;                 // Event-specific payload
  meta: {
    attemptNumber: number; // Delivery attempt number (1-based)
    maxAttempts: number;   // Total attempts before dead-letter
    webhookId: string;     // Your webhook endpoint configuration ID
  };
}

Event Payload Examples

Money is always strings

All monetary amounts are strings of integer minor units (e.g. "1000" = €10.00) — never floating-point numbers.

round.completed

typescript
interface RoundCompletedEventData {
  roundId: string;
  playerId: string;         // Your external player ID (as passed at SSO time)
  gameId: string;
  gameType: 'single_step' | 'multi_step' | 'session_based';
  currency: string;
  totalBet: string;          // minor units
  totalWin: string;          // minor units
  netResult: string;         // minor units (totalWin - totalBet)
  actionCount: number;
  endReason: 'completed' | 'cash_out' | 'timeout' | 'hammer_break' | 'fbi_caught' | 'killed';
  balanceAfter: string;      // minor units
  paytableVariant: string;
  provablyFair: {
    serverSeedHash: string;
    clientSeed: string;
    nonce: number;
    verificationUrl: string;
  } | null;
  startedAt: string;         // ISO 8601
  completedAt: string;       // ISO 8601
}

round.jackpot_won

Sent the moment a progressive jackpot award is committed to the ledger (never for a rolled-back round). See Progressive Jackpots for how pools work.

typescript
interface RoundJackpotWonEventData {
  poolId: string;
  poolName: string;          // e.g. "USDT Progressive Jackpot"
  cycleNumber: number;       // which cycle of this pool was won
  currency: string;
  amount: string;            // whole units, e.g. "125.500000"
  amountMinor: string;       // minor units, e.g. "125500000"
  playerId: string;          // internal player UUID
  playerExternalId: string | null; // YOUR player id (as passed at SSO time)
  gameId: string;            // internal game UUID
  gameSlug: string;          // e.g. "fox"
  roundId: string | null;
  transactionId: string;     // ledger transaction of the payout
  lockedToBonus: boolean;    // true if the win went to the player's bonus
                             // balance (they had an unconverted bonus)
}

Webhook Signing

Every webhook request includes a signature for verification:

How Signing Works

payload = "${timestamp}.${body}"
signature = HMAC-SHA256(webhookSecret, payload)

Request Headers

HeaderDescription
X-Slotty-Signaturesha256=<hex-encoded HMAC>
X-Slotty-TimestampUnix timestamp (seconds) of when the webhook was sent
Content-Typeapplication/json

Verification with SDK

typescript
import { WebhookVerifier } from '@slottylabs/sdk';

const verifier = new WebhookVerifier('whsec_xyz789...');

app.post('/webhooks/slotty', (req, res) => {
  try {
    const event = verifier.constructEvent(
      req.rawBody,                           // Raw request body string
      req.headers['x-slotty-signature'],     // Signature header
      req.headers['x-slotty-timestamp'],     // Timestamp header
    );

    console.log(`Received event: ${event.type}`, event.data);
    res.status(200).json({ received: true });
  } catch (err) {
    console.error('Webhook verification failed:', err.message);
    res.status(400).json({ error: 'Invalid signature' });
  }
});

Manual Verification

typescript
import crypto from 'crypto';

function verifyWebhook(
  rawBody: string,
  signature: string,
  timestamp: string,
  secret: string,
): boolean {
  // Check timestamp tolerance (±5 minutes)
  const now = Math.floor(Date.now() / 1000);
  const ts = parseInt(timestamp, 10);
  if (Math.abs(now - ts) > 300) return false;

  // Compute expected signature
  const payload = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  const actual = signature.replace('sha256=', '');

  // Timing-safe comparison
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(actual),
  );
}

Retry Policy

If your endpoint doesn't respond with a 2xx status within 10 seconds, Slotty Labs will retry:

AttemptDelay After Previous
1Immediate
230 seconds
32 minutes
410 minutes
51 hour
64 hours
712 hours
824 hours

After 8 failed attempts, the event is moved to a dead-letter queue. You can replay dead-lettered events from the operator dashboard.

Idempotency

At-Least-Once Delivery

Webhooks are delivered with at-least-once semantics. Your endpoint may receive the same event more than once, especially during retries.

Always use the event id field to deduplicate events on your end.

typescript
// Example: Redis-based idempotency check
const processed = await redis.get(`webhook:processed:${event.id}`);
if (processed) {
  return res.status(200).json({ received: true }); // Already handled
}

// Process the event...
await handleEvent(event);

// Mark as processed (TTL: 7 days)
await redis.set(`webhook:processed:${event.id}`, '1', 'EX', 604800);