Um webhook que devolve 401 ou 403 cai num pequeno número de gavetas. Na maioria das vezes nem é um problema de assinatura — é um middleware ou um padrão do framework rejeitando a requisição antes do seu código rodar. Esta é a checklist de diagnóstico que usamos, na ordem em que usamos.
Primeiro, separe 401 de 403
Significam coisas diferentes e a correção é diferente.
401 Unauthorized: o servidor recebeu a requisição, olhou as credenciais (ou a assinatura) e não as aceitou. Seu handler provavelmente rodou, calculou uma assinatura esperada e rejeitou.
403 Forbidden: o servidor recebeu a requisição e se recusou a processá-la por outro motivo. Muitas vezes a requisição nunca chegou ao seu handler — um middleware ou padrão do framework enviou o 403.
Abra a captura de requisições do seu túnel e olhe a resposta. Se você vê o corpo de resposta do seu handler («invalid signature»), é um 401 do seu código. Se a resposta é genérica e seus logs do handler não mostram nada, o framework rejeitou antes do seu handler rodar.
As cinco causas mais prováveis
1. Middleware CSRF (Django, Rails, Laravel)
A proteção CSRF padrão rejeita POSTs sem token de sessão. Provedores de webhook não enviam um. Sintomas: 403, corpo de resposta genérico, sem logs do handler.
Correção: exclua a rota do webhook da proteção CSRF. No Django, @csrf_exempt. No Rails, skip_before_action :verify_authenticity_token, only: [:webhook]. No Laravel, adicione o caminho a VerifyCsrfToken::$except. O guia de webhooks do Django percorre a versão do Django de ponta a ponta.
2. Middleware de auth aplicado de forma ampla demais
Você adicionou middleware de auth globalmente (JWT, checagem de sessão, exigência de API key) e a rota do webhook o herdou. O provedor não envia seu cabeçalho de auth, então o middleware manda um 401 antes do seu handler rodar.
Correção: exclua o caminho do webhook do seu middleware de auth. A autenticação do webhook é a assinatura, não o esquema que protege o resto da sua API.
3. Verificação de assinatura falhando
Seu handler rodou, calculou uma assinatura esperada e não bateu. Cinco subcausas, mais ou menos em ordem decrescente de frequência:
- Corpo parseado por middleware antes da verificação rodar (o corpo cru se foi).
- Codificação errada (hex vs base64). Veja o guia de verificação de assinatura.
- Segredo errado (test vs live, dashboard vs CLI, arquivo env vs runtime).
- Timestamp velho demais (a assinatura é válida mas rançosa — provavelmente você testa com um payload antigo reenviado).
- Espaços em branco no fim do segredo carregado de um arquivo env.
4. URL de túnel errada registrada
Você reiniciou seu túnel e a URL rotacionou, mas o dashboard do provedor ainda tem a antiga. O sintoma parece um 401 porque a requisição nunca chegou a você — mas na verdade é uma requisição diferente chegando a um servidor diferente (muitas vezes a sessão de túnel anterior, que agora recusa ou devolve 401).
Correção: confirme que a URL no dashboard do provedor bate com sua sessão de túnel atual. Se precisa de uma URL estável, olhe os túneis com nome ou um subdomínio reservado.
5. Restrições de CORS, content-type ou método
Menos comum em webhooks, mas possível. Se sua rota só aceita application/json e o provedor envia application/x-www-form-urlencoded (Twilio, por exemplo), alguns frameworks dão 415 — mas um mal configurado pode dar 403. Ou sua rota está registrada para GET e o provedor faz POST.
O fluxo de diagnóstico de 90 segundos
Esta é a ordem que seguimos:
- Leia o corpo de resposta. Se tem as palavras do seu handler, a requisição chegou ao handler. Passe para depurar a assinatura. Se é uma página de erro genérica do framework, a requisição não chegou. Passe para depurar o middleware.
- Cheque os logs do handler. Algum statement de log do seu handler está disparando? Confirma se a requisição chegou a você.
- Cheque a URL registrada. Abra o dashboard do provedor. Confirme que a URL bate com seu túnel atual. Confirme que aponta para o caminho certo.
- Compare os cabeçalhos de entrada. A captura do túnel mostra os cabeçalhos exatos que o provedor enviou. Compare com o que seu código lê. Cabeçalhos são insensíveis a maiúsculas mas a forma de acessá-los difere por framework —
request.headers.get('Stripe-Signature')em alguns,request.META['HTTP_STRIPE_SIGNATURE']em outros. - Verifique que o corpo está cru no momento da assinatura. Imprima o comprimento do corpo logo antes de calcular a assinatura. Se for zero ou surpreendentemente pequeno, um middleware o comeu.
- Confira o segredo de novo. Compare a variável de ambiente no seu runtime com o dashboard.
console.log(process.env.WEBHOOK_SECRET.length)— o comprimento bate com o que o dashboard mostra?
Na nossa experiência, os passos 1 e 5 pegam 80% dos casos nos primeiros dois minutos.
Capture a requisição que falha
A ferramenta mais útil aqui é um túnel com captura e replay de requisições. Você não precisa esperar o provedor repetir — você reproduz a requisição capturada contra seu handler local enquanto depura. Cada tentativa é instantânea.
Se você usa um túnel que não captura requisições, está depurando com uma mão amarrada nas costas. Trocar por um que captura (ou rodar tcpdump, ou pôr nginx na frente do seu servidor de dev) se paga na primeira vez que você economiza uma hora.
Como o 401 aparece em cada provedor
- Stripe: um 401 do seu código significa que a verificação de assinatura falhou. O dashboard do Stripe mostra a entrega como falha e inclui seu corpo de resposta.
- GitHub: se seu handler devolve 401, o GitHub marca a entrega como falha e repete. A página de entregas recentes mostra a resposta.
- Shopify: um 401 de um handler de webhook é ok por segurança mas o Shopify vai repetir. Após 19 falhas consecutivas em 48 horas, o Shopify desativa a assinatura.
Para o contexto mais amplo de depuração de webhooks, veja como depurar webhooks em local. Entre na lista de espera do PortPreview para um túnel com captura embutida.