Pular para o conteúdo principal

Webhooks

Um POST assinado por batalha, quando ela acaba. É nisso que você liquida, não no socket, que é melhor esforço e pode ser perdido por uma desconexão de dois segundos.

Verifique a assinatura

Dois erros deixam a verificação silenciosamente errada, e os dois são fáceis de cometer:

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

Erro um: fazer hash de JSON reserializado. Faça o hash dos bytes exatos que chegaram. JSON.stringify(await request.json()) produz bytes diferentes, porque ordem de chaves e formatação de número mudam, e nunca vai bater.

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

Erro dois: comparar com ===. Comparação de string encerra no primeiro byte diferente, então o tempo dela vaza a assinatura esperada um byte por vez. Use comparação de tempo constante.

Teste que ela recusa

Os quatro casos abaixo foram rodados contra a 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}"

O último caso é o que importa: mudar um multiplicador e manter a assinatura. É o ataque real, e uma implementação quebrada passa nele.

Escolhendo o vencedor

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" não significa que todo assento teve sucesso

Significa que a batalha terminou. Cheque ok por assento. Um assento cuja gravação falhou volta com ok: false dentro de uma batalha por outro lado completa, e pagá-lo como vitória é um bug de dinheiro real que a conciliação encontra semanas depois.

Seja idempotente mesmo assim

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

O SlotBattle deduplica os próprios eventos terminais, mas a garantia é ao menos uma vez, não exatamente uma vez. Um timeout de rede depois de o seu handler ter commitado faz a mesma batalha chegar de novo.

Um casino real faz isso dentro da mesma transação do crédito na carteira, para uma entrega duplicada não pagar duas vezes.

Responda rápido

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

A entrega retenta três vezes inline e depois cai para um reconciliador com backoff de 30 segundos. Um endpoint lento queima esse orçamento e atrasa o resultado para todo mundo na batalha. Escreva numa fila e retorne, depois liquide de forma assíncrona.

Configure

O webhook é por casino, no console em Configurações → Webhook: a URL e o segredo.

URL e segredo precisam ser definidos juntos

O grupo de credencial é tudo ou nada, então definir a URL e deixar o segredo em branco é recusado na escrita. Meio configurado, ele assinaria os seus resultados com o segredo da plataforma.

O segredo também nunca volta: ler as configurações diz se ele está definido, não qual é. Enviar o formulário sem ele deixa o armazenado intacto, então apertar Salvar não consegue limpá-lo em silêncio.

O endpoint precisa ser alcançável a partir da instância. Em desenvolvimento isso normalmente significa um túnel, como ngrok ou cloudflared, apontado para o seu servidor local.

Por que o socket não basta

Um espectador, ou o seu próprio listener, pode perder o battle.completed por ficar dois segundos desconectado, e nada vai reentregar. Use o socket para manter a UI viva, e o webhook para mover dinheiro.

Indo para produção