Por que a assinatura do webhook não bate?

Quase sempre por um destes motivos, nesta ordem:

  1. 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() ou json_encode($request->input()).
  2. O segredo é de outro ambiente. Segredo de teste com evento de produção, ou o da Stripe CLI (stripe listen) com evento do painel.
  3. A codificação está trocada. GitHub e Stripe usam hexadecimal; Shopify e Standard Webhooks usam base64.
  4. Faltou o que vai junto com o corpo. Stripe assina timestamp.corpo; Standard Webhooks assina id.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.