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:
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.
// 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
// 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 sucessoSignifica 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.
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.