Por que a assinatura do webhook não bate?
Quase sempre por um destes motivos, nesta ordem:
- O corpo foi alterado antes da validação. O framework converteu o JSON e você assinou de novo o resultado, com outra ordem de chaves ou sem os espaços. Valide sempre o corpo bruto: no Laravel,
$request->getContent(), nunca$request->all()oujson_encode($request->input()). - O segredo é de outro ambiente. Segredo de teste com evento de produção, ou o da Stripe CLI (
stripe listen) com evento do painel. - A codificação está trocada. GitHub e Stripe usam hexadecimal; Shopify e Standard Webhooks usam base64.
- Faltou o que vai junto com o corpo. Stripe assina
timestamp.corpo; Standard Webhooks assinaid.timestamp.corpo. Assinar só o corpo nunca vai bater.
Como validar a assinatura de um webhook no Laravel?
Um middleware na rota do webhook resolve: ele calcula o HMAC do corpo bruto com o segredo e compara com o header usando hash_equals. Exemplo para o GitHub:
// app/Http/Middleware/VerifyGitHubWebhook.php
public function handle(Request $request, Closure $next): Response
{
$expected = 'sha256='.hash_hmac('sha256', $request->getContent(), config('services.github.webhook_secret'));
if (! hash_equals($expected, (string) $request->header('X-Hub-Signature-256'))) {
abort(401, 'Assinatura inválida.');
}
return $next($request);
}
Se a rota estiver em routes/web.php, tire ela da verificação de CSRF em bootstrap/app.php, com $middleware->validateCsrfTokens(except: ['webhooks/*']). Na Stripe, prefira a biblioteca oficial, que já confere o timestamp: \Stripe\Webhook::constructEvent($payload, $header, $secret).
Perguntas frequentes
Por que usar hash_equals e não ===?
O === para de comparar no primeiro caractere diferente, e essa diferença de tempo pode ser medida para descobrir a assinatura aos poucos. O hash_equals leva o mesmo tempo para qualquer entrada.
O que é a tolerância de timestamp?
É a proteção contra replay: alguém que capturou um webhook válido não consegue reenviá-lo horas depois, porque o timestamp entra na assinatura e o servidor rejeita eventos fora de uma janela, normalmente de 5 minutos. Por isso um evento antigo, copiado do log, falha na biblioteca oficial mesmo com a assinatura certa.
E o Asaas?
O Asaas não assina o corpo com HMAC. Ele envia no header asaas-access-token o token que você cadastrou no webhook. Basta comparar esse header com o seu token usando hash_equals.
É seguro colar meu segredo aqui?
O cálculo acontece no seu navegador, com a Web Crypto API, e a página não envia o que você digita para lugar nenhum. Além disso, a política de segurança do site proíbe o navegador de mandar dados desta página para outros endereços. Mesmo assim, para testar, prefira o segredo do ambiente de testes.