Webhooks Explicados: Payloads Assinados e Retentativas Confiáveis
Sua integração funciona em staging: o provedor de pagamento dispara um evento, seu endpoint o recebe, o pedido muda para paid. Então, em produção, um cliente paga duas vezes, uma entrega desaparece silenciosamente durante um deploy, e alguém reproduz uma requisição capturada para marcar um pedido como reembolsado. Todas as três falhas compartilham a mesma causa raiz: o webhook foi tratado como um simples POST HTTP em vez de um canal de entrega pelo menos uma vez, crítico para a segurança.
Este guia cobre as duas metades de uma implementação correta de webhook — verificar que um payload é autêntico e fazer a entrega sobreviver a retentativas — com código que você pode adaptar a qualquer stack.
O que um webhook realmente é
Um webhook é uma requisição HTTP servidor-a-servidor que um sistema envia a outro quando um evento acontece. Ele inverte o modelo usual de polling: em vez de seu app perguntar "tem algo novo?" a cada minuto, o produtor empurra o evento para uma URL que você registrou.
Duas propriedades decorrem desse design, e ambas importam:
- Seu endpoint é público. Qualquer pessoa na internet pode fazer POST nele, então toda requisição deve ser autenticada.
- A entrega é pelo menos uma vez. Redes falham, servidores reiniciam e timeouts são ambíguos — o remetente não pode saber se seu handler executou. Então ele retenta, e você pode ver o mesmo evento mais de uma vez.
Tudo abaixo é consequência desses dois fatos.
Assinando payloads com HMAC
A abordagem padrão é um segredo compartilhado mais um HMAC sobre o corpo bruto da requisição. O remetente calcula uma assinatura e a coloca em um cabeçalho; você recalcula e compara. Se coincidirem, o payload veio de alguém que conhece o segredo e não foi modificado em trânsito.
Três detalhes separam uma implementação funcional de uma quebrada:
- Assine os bytes brutos. Faça o parse do JSON somente após a verificação. Re-serializar o corpo altera espaços em branco e ordem das chaves, e a assinatura nunca vai coincidir.
- Use comparação em tempo constante. Um
==ingênuo vaza informações de timing. Use a comparação segura embutida da linguagem. - Inclua um timestamp na string assinada. Assinar
timestamp + "." + bodypermite rejeitar requisições antigas, o que bloqueia ataques de replay.
Verificando uma assinatura em Node.js
const crypto = require('crypto');
function verifyWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
const signature = headers['x-signature']; // e.g. "t=1712345678,v1=abc123..."
const timestamp = headers['x-timestamp'];
if (!signature || !timestamp) return false;
// 1. Rejeitar requisições antigas (proteção contra replay)
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (!Number.isFinite(age) || age > toleranceSeconds) return false;
// 2. Recalcular sobre os bytes exatos recebidos
const signedPayload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
// 3. Comparação em tempo constante
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
No Express, isso exige express.raw({ type: 'application/json' }) na rota do webhook para que o corpo chegue como Buffer. Se um middleware global express.json() rodar primeiro, o corpo bruto se perde e a verificação falhará — um bug muito comum.
Verificando em Python
import hmac, hashlib, time
def verify(raw_body: bytes, timestamp: str, signature: str, secret: bytes, tolerance: int = 300) -> bool:
if abs(time.time() - int(timestamp)) > tolerance:
return False
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
Sempre retorne 401 em uma verificação falha e registre o ID da requisição — mas nunca registre o segredo ou a assinatura completa.
Projetando retentativas que realmente funcionam
Retentativas só são úteis se seu endpoint se comportar de forma previsível. O contrato é simples: retorne 2xx rapidamente assim que aceitar o evento de forma durável, e deixe todo o resto ser retentado.
| Sua resposta | Comportamento do remetente | O que fazer em vez disso |
|---|---|---|
200 / 204 | Marcar como entregue | Correto — confirme após enfileirar |
400 / 401 | Geralmente sem retentativa | Corrija o tratamento de assinatura ou payload |
429 / 503 | Retentar com backoff | Retorne quando sobrecarregado, não um 500 genérico |
500 / timeout | Retentar | Mova trabalho lento para uma fila |
Duas regras mantêm isso saudável:
- Confirme rápido. Verifique, persista o evento em uma fila ou tabela, responda
200. Faça o trabalho real de forma assíncrona. Um handler que chama três APIs antes de responder vai dar timeout e disparar entregas duplicadas. - Use backoff exponencial com jitter. Se um remetente retenta imediatamente, uma breve indisponibilidade vira uma avalanche de requisições. Um cronograma como 10s, 30s, 2m, 10m, 1h com jitter aleatório espalha a carga.
Torne os handlers idempotentes
Como a entrega é pelo menos uma vez, o mesmo ID de evento pode chegar duas vezes — uma após um timeout em que seu handler na verdade teve sucesso. Armazene IDs de eventos processados e ignore duplicatas:
INSERT INTO processed_events (event_id, received_at)
VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING;
-- Se 0 linhas inseridas, isto é uma duplicata: retorne 200 e pare.
Quando possível, torne a operação de negócio em si idempotente — defina o status do pedido como paid em vez de incrementar um contador, e use o ID do evento como chave de idempotência ao chamar serviços downstream.
Uma checklist antes de ir para produção
- Verifique o HMAC sobre bytes brutos com comparação em tempo constante.
- Rejeite timestamps mais antigos que alguns minutos para impedir replays.
- Persista o evento e então retorne
2xxdentro de um ou dois segundos. - Deduplique pelo ID do evento antes de fazer qualquer trabalho.
- Registre o ID do evento, tipo e resultado — nunca o segredo.
- Exponha uma forma de reproduzir eventos manualmente a partir do seu dashboard ou ferramenta de admin.
- Teste com assinaturas deliberadamente quebradas, entregas duplicadas e handlers lentos.
Quando você estiver depurando uma integração com falha, o corpo bruto da requisição é o que você mais vai encarar. Colá-lo em um formatador JSON torna objetos de evento aninhados legíveis sem alterar os bytes que você já capturou.
FAQ
Por que minha verificação de assinatura falha mesmo que o payload pareça idêntico?
Quase sempre porque o corpo foi parseado e re-serializado antes do hash. A ordem das chaves JSON, espaços em branco e escape unicode mudam durante parse/stringify, então os bytes não coincidem mais com o que o remetente assinou. Capture o corpo bruto e faça o hash dele.
Devo retornar 200 antes ou depois de processar o evento?
Depois de armazená-lo de forma durável, antes de fazer o trabalho pesado. Se você processar primeiro, chamadas downstream lentas causam timeouts, o remetente retenta e você obtém duplicatas. Enfileire o evento, responda imediatamente e processe de forma assíncrona.
HTTPS é suficiente para proteger um endpoint de webhook?
Não. TLS protege os dados em trânsito, mas não prova quem enviou a requisição — qualquer pessoa pode fazer POST em uma URL pública. A assinatura HMAC com um segredo compartilhado autentica o remetente, e um timestamp assinado impede o replay de requisições capturadas.
Conclusão
Trate webhooks como qualquer entrada não confiável: verifique a assinatura sobre os bytes brutos, confira o timestamp, deduplique pelo ID do evento e confirme somente após o evento estar armazenado com segurança. Faça isso, e as retentativas deixam de ser uma fonte de cobranças duplicadas e se tornam o que deveriam ser — uma rede de segurança.