Skip to main content

Webhooks

One signed POST per battle, when it ends. This is what you settle on, not the socket, which is best-effort and can be missed by a two-second disconnect.

Verify the signature

Two mistakes make verification silently wrong, and both are easy to make:

lib/webhook-signature.ts
export function verifyWebhookSignature(
rawBody: string,
signatureHeader: string | null,
secret: string,
): boolean {
if (!signatureHeader) return false

const expected = createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')

const a = Buffer.from(expected, 'utf8')
const b = Buffer.from(signatureHeader, 'utf8')

// timingSafeEqual throws on a length mismatch, so the length check has to
// come first. Length is not secret — the digest is always 64 hex chars.
return a.length === b.length && timingSafeEqual(a, b)
}

Mistake one: hashing re-serialised JSON. Hash the exact bytes received. JSON.stringify(await request.json()) produces different bytes, because key order and number formatting change, and will never match.

app/api/webhooks/slotbattle/route.ts
// Read the RAW body first. Verifying over re-serialised JSON can never match.
const rawBody = await request.text()

if (!verifyWebhookSignature(rawBody, request.headers.get('x-slotbattle-sign'), secret)) {
return new Response('invalid signature', { status: 401 })
}

const payload = JSON.parse(rawBody) as WebhookPayload

Mistake two: comparing with ===. String comparison short-circuits on the first differing byte, so its timing leaks the expected signature one byte at a time. Use a constant-time comparison.

Test that it rejects

All four of these were run against the demo:

BODY='{"battle_id":"btl_...","status":"completed","seats":[...]}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac 'your-secret' -hex | sed 's/.*= //')

# valid → 204
curl -X POST localhost:5174/api/webhooks/slotbattle -H "X-Slotbattle-Sign: $SIG" -d "$BODY"

# wrong signature → 401
curl -X POST localhost:5174/api/webhooks/slotbattle -H "X-Slotbattle-Sign: deadbeef" -d "$BODY"

# no signature → 401
curl -X POST localhost:5174/api/webhooks/slotbattle -d "$BODY"

# tampered body, original signature → 401
curl -X POST localhost:5174/api/webhooks/slotbattle -H "X-Slotbattle-Sign: $SIG" \
-d "${BODY/138.0/999.0}"

The last case is the one that matters: change a multiplier, keep the signature. It is the actual attack, and a broken implementation passes it.

Picking the winner

lib/settlements.ts
// Only seats that actually succeeded can win. `status: "completed"` means the
// BATTLE finished, not that every seat did — a seat whose recording failed
// arrives with ok: false inside an otherwise completed battle.
const winner = seats
.filter((seat) => seat.ok)
.reduce<Settlement['winner']>((best, seat) => {
if (!best || seat.multiplier > best.multiplier) {
return { seatRef: seat.seatRef, multiplier: seat.multiplier }
}
return best
}, null)
status: "completed" does not mean every seat succeeded

It means the battle finished. Check ok per seat. A seat whose recording failed comes back with ok: false inside an otherwise completed battle, and paying it out as a win is a real-money bug that reconciliation finds weeks later.

Be idempotent anyway

const key = `${payload.battle_id}:${payload.status}`
if (settlements.has(key)) return

SlotBattle de-duplicates its own terminal events, but the guarantee is at-least-once, not exactly-once. A network timeout after your handler committed means the same battle arrives again.

A real casino does this inside the same transaction as the wallet credit, so a duplicate delivery cannot pay twice.

Answer fast

await recordSettlement(payload)
return new Response(null, { status: 204 })

Delivery retries three times inline and then falls to a reconciler with a 30-second backoff. A slow endpoint burns that budget and delays the result for everyone in the battle. Write to a queue and return, then settle asynchronously.

Configure it

The webhook is per casino, in the console under Settings → Webhook: the URL and the secret.

URL and secret must be set together

The credential group is all-or-nothing, so setting the URL and leaving the secret blank is refused at the write. Half configured, it would sign your results with the platform's secret.

The secret never comes back: reading the settings tells you whether it is set, not what it is. Submitting the form without it leaves the stored one alone, so pressing Save cannot silently clear it.

The endpoint must be reachable from the instance. In development that usually means a tunnel, such as ngrok or cloudflared, pointed at your local server.

Why the socket is not enough

A viewer, or your own listener, can miss battle.completed by being disconnected for two seconds, and nothing will redeliver it. Use the socket to keep the UI live, and the webhook to move money.

Going to production