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