Proteção contra replay de webhooks: pare duplicatas com confiança
A proteção contra replay de webhooks evita cobranças e ações duplicadas verificando assinaturas, aplicando janelas de timestamp e armazenando chaves de idempotência com segurança.

Por que webhooks duplicados acontecem e por que isso importa
Um webhook é uma mensagem que um serviço envia para sua aplicação (geralmente uma requisição HTTP) para avisar que um evento ocorreu, como um pagamento bem-sucedido ou uma assinatura cancelada.
O detalhe é que “enviado” não significa “processado apenas uma vez”. A maioria dos provedores entrega webhooks com garantia de “pelo menos uma vez”. Se seu endpoint der timeout, retornar 5xx, ou a resposta se perder no caminho de volta, o provedor tenta novamente. Essas retentativas são duplicatas acidentais: o mesmo evento do mundo real entregue mais de uma vez.
Replays são diferentes. Um ataque de replay é quando alguém captura uma requisição webhook válida e a envia de novo depois para disparar o mesmo efeito duas vezes. A requisição pode parecer legítima, então sem proteção contra replay você pode aceitar um evento antigo como se fosse novo.
Se duplicatas ou replays forem tratados como eventos novos, os resultados são dolorosamente reais: cobranças ou reembolsos em dobro, e-mails duplicados, inventário reduzido duas vezes, assinaturas ou faturas duplicadas e análises que não batem com o que realmente aconteceu.
O objetivo é simples de dizer e fácil de errar: aceitar cada evento válido uma vez e ignorar repetições com segurança. “Com segurança” importa porque retentativas legítimas são normais, enquanto requisições adulteradas e replays antigos devem ser rejeitados.
Um bom handler trata cada webhook recebido como não confiável até que se prove o contrário. Ele verifica quem enviou, checa se é recente o suficiente para ser crível e registra um marcador estável de “já processado” para que uma segunda entrega vire um no-op.
Fontes comuns de duplicatas e replays
Entregas duplicadas são comportamento esperado. Mesmo quando tudo está funcionando, o mesmo evento pode chegar novamente.
A causa mais comum são retentativas após timeout ou resposta 5xx. Você pode ter terminado o processamento, mas o provedor não recebeu um sinal claro de sucesso. Falhas de rede produzem o mesmo resultado: a requisição é bem-sucedida, mas a resposta é perdida ou um proxy reinicia a conexão.
Duplicatas também podem vir do seu próprio sistema e ainda assim parecerem “problemas de webhook” quando você analisa os resultados. Um usuário clica duas vezes, uma automação entra em loop, ou uma fila reentrega após o crash de um worker.
Fontes típicas incluem retentativas do provedor, reenvios manuais em dashboards, perda de resposta após processamento bem-sucedido, redelivery de filas e loops de integração.
Replays são o primo mais preocupante. Um atacante (ou um cliente com bug) pode reenviar uma requisição antiga que antes era válida. Sem proteção contra replay, isso pode repetir ações como conceder acesso, alterar estado de conta ou gerar faturas.
Um caso simples de falha: seu handler cria uma fatura em payment_succeeded. Se o evento for reentregue três vezes, ou replayed um dia depois, você pode acabar com várias faturas a menos que verifique assinaturas, imponha uma janela de timestamp e dedupe por uma chave de idempotência.
Validação de assinatura: sua primeira linha de defesa
A validação de assinatura bloqueia a maioria dos webhooks forjados antes que toquem seu sistema. Uma assinatura válida prova que o payload não foi modificado em trânsito (integridade) e que foi produzido por alguém que conhece o segredo compartilhado (autenticidade).
Isso é uma parte importante da proteção contra replay porque impede terceiros aleatórios de inventar eventos. Não impede, por si só, que um remetente legítimo entregue o mesmo evento duas vezes. Apenas garante que você só lide com mensagens reais.
Onde times se queimam é em confiar na presença de um header em vez de verificá-lo. Uma requisição com X-Signature: abc123... não significa nada a menos que você recalcule a assinatura do seu lado usando o corpo bruto recebido.
Um fluxo de verificação sólido é:
- Rejeite imediatamente se o header de assinatura estiver ausente.
- Leia os bytes brutos do corpo (não um objeto JSON parseado).
- Calcule o HMAC esperado (ou o que o provedor especificar) sobre os bytes exatos.
- Compare usando uma função de tempo-constante.
- Só então parseie o JSON e faça qualquer trabalho no banco.
Comparação em tempo-constante vale a pena porque comparações normais de strings podem vazar informação por diferenças de tempo.
Também, falhe rápido. Se você validar a assinatura depois de parsear payloads grandes, dá aos atacantes uma maneira fácil de queimar CPU. Trate assinaturas ausentes ou inválidas como uma porta trancada: pare cedo, registre detalhes mínimos e retorne um erro sem fazer trabalho extra.
Janelas de timestamp: faça os replays expirarem
Uma janela de timestamp limita por quanto tempo uma requisição webhook capturada permanece útil. Funciona melhor quando o remetente inclui um timestamp e o assina junto com o corpo.
O fluxo é direto: verifique a assinatura e então confirme que o timestamp é recente o suficiente. Se alguém reenviar exatamente a mesma requisição horas depois, ela falha na checagem de frescor.
Como escolher a janela
Muitos times começam com um pequeno desvio permitido, como 5 minutos. Isso geralmente é suficiente para atrasos normais de rede e enfileiramento sem permitir que mensagens antigas passem.
Uma abordagem prática:
- Extraia o timestamp do header do provedor (ou do payload, se for esse o formato).
- Verifique a assinatura usando o mesmo valor de timestamp como parte da entrada assinada.
- Compare com o tempo do seu servidor e aceite apenas dentro da janela de desvio.
- Fora da janela, trate como replay mesmo que a assinatura seja válida.
Derivações de relógio são uma falha silenciosa. Se o tempo do seu servidor estiver errado, você rejeitará bons eventos. Mantenha os hosts sincronizados e compare contra o tempo do servidor, não do cliente.
Para eventos atrasados, decida com antecedência se você os rejeita estritamente ou os envia para revisão manual. Se seu provedor ocasionalmente entrega webhooks atrasados, registrar e revisar pode ser mais seguro do que processá-los automaticamente fora da janela.
Chaves de idempotência: como a deduplicação funciona na prática
Uma chave de idempotência é um rótulo “faça isso uma vez” para um evento webhook. Quando o mesmo evento chega novamente, você consulta a chave e retorna o mesmo resultado em vez de executar sua lógica de negócio de novo.
A chave precisa ser estável entre retentativas. Se o provedor te der um event_id ou message_id, use isso. Se não, construa uma a partir de campos que não vão mudar entre entregas, como um hash de nome_do_provedor + tipo_de_evento + id_recurso + timestamp_do_provedor. Não use seu próprio tempo de recebimento ou UUIDs aleatórios, porque duplicatas nunca vão casar.
O escopo também importa. Se você suporta múltiplos clientes, inclua o identificador do tenant na chave armazenada para que um cliente não bloqueie outro por acidente.
Um modelo simples é armazenar uma linha por chave de idempotência com um status e (opcionalmente) um resultado compacto:
- in-progress (aceito, trabalho não finalizado)
- processed (concluído, duplicatas podem retornar sucesso imediatamente)
- failed (terminou com erro, você pode optar por tentar de novo)
Mantenha chaves por tempo suficiente para cobrir seu risco. Se movimento de dinheiro estiver envolvido, guarde por mais tempo (dias ou semanas). Se o impacto for baixo, retenção menor pode bastar.
Uma salvaguarda prática: imponha unicidade no nível do banco de dados. Uma restrição UNIQUE transforma concorrência em “first writer wins”, mesmo que duas cópias cheguem ao mesmo tempo.
Armazenamento idempotente: o padrão mais simples e confiável
Se você quer dedupe que resista a retentativas, timeouts e requisições paralelas, armazene idempotência no seu banco de dados. Caches em memória expiram. Locks em processo quebram quando você escala. Uma restrição única no banco é chata, rápida e difícil de contornar.
Escolha uma chave de idempotência estável (frequentemente o event ID do provedor, ou um hash de tenant + event ID). Crie uma tabela como webhook_receipts com uma restrição UNIQUE nessa chave.
Inserir primeiro, depois processar
O fluxo mais seguro é escrever um recibo antes de fazer trabalho real. Duas requisições não podem “ganhar” ambas. Uma inserção tem sucesso, a outra falha, e a duplicata vira um no-op.
Um padrão confiável:
- Valide assinatura e timestamp, então compute a chave de idempotência.
- Tente inserir uma linha de recibo com status
received. - Se a inserção falhar por causa da restrição única, trate como duplicata e retorne um 2xx seguro.
- Se a inserção tiver sucesso, execute a lógica de negócio e então atualize o recibo para
processed(oufailed).
Retornar 2xx em duplicatas soa estranho, mas normalmente está correto. O remetente está perguntando “Você recebeu isto?” e você recebeu. Re-processar é a parte arriscada.
Armazene um recibo mínimo
Mantenha o recibo pequeno mas útil: idempotency_key, tenant_id, event_type, received_at, processed_at, status, e talvez um campo curto result como “created invoice 123.” Isso também dá uma trilha de auditoria quando precisar explicar por que algo aconteceu.
Passo a passo: construa um handler de webhook seguro
Confiabilidade e proteção contra replay são a mesma tarefa: aceitar um evento uma vez e só uma vez, mesmo que seja entregue muitas vezes.
Um fluxo de requisição que resiste a retentativas
Mantenha o caminho quente curto e divida em estágios:
- Verifique antes de parsear JSON. Leia o corpo bruto da requisição, valide a assinatura e cheque a janela de timestamp. Se falhar, retorne 4xx.
- Parseie e valide o esquema. Decode o JSON e confirme campos obrigatórios (event id, type, tenant/account).
- Calcule uma chave de idempotência. Prefira o event id do provedor.
- Registre a chave com uma escrita única. Se já existir, retorne 2xx imediatamente. Se for nova, só continue depois que a escrita tiver sucesso.
- Faça o trabalho de negócio fora do caminho crítico. Enfileire um job com o payload do evento (ou uma referência). Deduplicar na entrada do webhook, não dentro do worker.
Depois de retornar 2xx, você pode fazer ações mais lentas com segurança, como chamar APIs de pagamento, enviar e-mails ou atualizar seu banco.
Para troubleshooting, anexe um conjunto de correlação aos logs: request id, event id (idempotency key), tenant id, event type e a decisão (accepted vs duplicate). Se um cliente relatar uma cobrança em dobro, você pode traçar um evento através das retentativas rapidamente.
Ordenação, concorrência e casos multi-tenant
Webhooks não são uma fila. Você pode receber o evento B antes do evento A, ou receber o mesmo evento duas vezes ao mesmo tempo. Se seu código assumir ordenação limpa, você acabará sobrescrevendo dados bons com dados antigos ou aplicando efeitos colaterais duas vezes.
Entregas fora de ordem: aceite-as
Projete handlers para serem seguros mesmo quando eventos chegarem atrasados. Para eventos de atualização, só aplique mudanças se forem mais novas do que o que você já armazenou. “Mais novo” pode ser um número de versão, uma sequência ou um updated_at fornecido pelo remetente. Se você não tiver nenhum desses, mantenha seu próprio marcador de “último processado” por objeto e trate atualizações mais antigas como no-ops.
Também, não trate creates como algo especial. Se você processar um “update” antes de um “create”, seu handler deve upsertar o registro e depois ignorar o create obsoleto.
Concorrência: o mesmo evento pode chegar duas vezes ao mesmo tempo
A deduplicação precisa ser segura contra corrida. Duas requisições podem ambas passar numa checagem “já vi isso?” antes de qualquer uma gravar a resposta.
A restrição única no banco de dados é a solução mais limpa. Insira o registro de dedupe primeiro, depois faça o trabalho, depois marque como concluído. Se o trabalho for longo, armazene status (received, processing, succeeded, failed) e só tente novamente com segurança quando uma tentativa anterior claramente falhar ou expirar.
Chaves multi-tenant: evite colisões entre clientes
Se você atende múltiplos tenants, inclua o identificador do tenant na sua chave de dedupe. Caso contrário dois clientes podem compartilhar o mesmo event_id e bloquear um ao outro.
Um formato prático de chave é tenant_id + provider + event_id (ou tenant_id + provider + object_id + version).
Falhas parciais também importam. Se você cobrar um cartão mas travar antes de marcar o evento como sucedido, uma retentativa pode cobrar de novo a menos que você registre o que já aconteceu.
Erros comuns que causam processamento em duplicidade
A maioria dos problemas de processamento duplicado é previsível, não aleatória.
Verificar a assinatura tarde demais é clássico. Se você escrever no banco, enviar e-mail ou cobrar um cartão e só então checar a assinatura, uma requisição forjada ou replayed ainda pode causar dano. A validação tem que ocorrer antes de qualquer efeito colateral.
Outro problema frequente é ler o corpo da requisição do jeito errado. Alguns frameworks parseiam JSON e depois re-serializam (mudando espaçamento, ordem de campos ou encoding). Se a assinatura do provedor é calculada sobre os bytes brutos, a verificação falhará se você validar contra um corpo modificado. Não “aceite temporariamente” assinaturas inválidas para manter o sistema rodando. Isso transforma checagem de assinatura em teatro.
Outros padrões comuns:
- Deduplicar baseado apenas em timestamps. Dois eventos reais podem compartilhar um timestamp, e um atacante pode copiar um.
- Retornar 500 para duplicatas. O remetente vê um erro e tenta novamente com mais força, criando uma tempestade de retentativas.
- Tratar “já processado” como exceção em vez de resultado normal.
- Logar segredos ou embuti-los em código cliente.
Se você detectar o mesmo event ID duas vezes, responda com 2xx e não faça mais nada. Essa geralmente é a maneira mais segura de parar retentativas.
Um exemplo simples do mundo real: evitar cobranças duplicadas
Um pesadelo comum no suporte: um cliente diz que foi cobrado duas vezes. Seu provedor de pagamentos envia um webhook payment_succeeded, seu servidor cria um pedido, e então uma retentativa ou replay atinge o mesmo endpoint outra vez. Se seu handler executar a lógica de cobrança ou fulfillment duas vezes, você terá um problema real.
A proteção contra replay ajuda em camadas. A verificação de assinatura garante que apenas seu provedor possa enviar eventos válidos. Uma janela de timestamp limita por quanto tempo uma requisição capturada permanece útil. Mas retentativas legítimas ainda são normais, e aí a dedupe via chave de idempotência é o que mais importa.
Um padrão limpo é:
- Extraia o event ID do provedor (ou construa um a partir de campos estáveis).
- Use-o como chave de idempotência, por exemplo
provider:event_id:account_id. - Insira a chave no armazenamento com uma restrição única.
- Se a inserção tiver sucesso, processe o pedido.
- Se já existir, retorne 200 e não faça nada.
O que o cliente vê: uma cobrança, um recibo e um status de pedido consistente mesmo se o provedor tentar cinco vezes.
Checklist e próximos passos
Se você quer proteção contra replay de webhooks que aguente produção, foque em alguns pontos inegociáveis:
- Verifique a assinatura antes de qualquer lógica de negócio (e antes de logar campos que você não confia totalmente).
- Rejeite requisições com timestamps fora da sua janela permitida e mantenha seus servidores sincronizados.
- Deduplicate com uma chave de idempotência única armazenada atomicamente.
- Retorne 2xx consistentes para duplicatas para que o remetente pare de tentar.
- Registre com segurança: sem segredos, e inclua campos de correlação (event ID, request ID, tenant ID) para rastreamento.
Um teste rápido que pega a maioria dos erros: envie o mesmo payload de webhook cinco vezes seguidas, depois envie de novo após sua janela de timestamp expirar. Você deve ver uma ação de negócio, várias respostas rápidas “já processado” e uma rejeição limpa quando ficar velho demais.
Se você herdou um handler de webhook gerado por IA que está processando em dobro sob retentativas, normalmente são pequenos ajustes: verificação da assinatura sobre o corpo bruto, uma janela de timestamp e idempotência apoiada no banco. Se quiser uma segunda opinião, FixMyMess (fixmymess.ai) pode rodar uma auditoria de código gratuita para apontar onde verificação, endurecimento de segurança e dedupe estão falhando antes de você enviar mais mudanças.
Perguntas Frequentes
Why am I receiving the same webhook multiple times?
Most webhook providers only guarantee at least once delivery. If your endpoint times out, returns a 5xx, or the response gets lost, the provider retries the same event. Those retries are normal and you should design your handler to safely do nothing on repeats.
What’s the difference between webhook duplicates and replay attacks?
A duplicate is usually a legitimate retry of the same real event, caused by timeouts, errors, or response loss. A replay is when an old, previously valid request is sent again later to trigger the same effect twice. You should accept legitimate retries safely, but reject stale replays by enforcing freshness and deduping by a stable event key.
If I verify the signature, do I still need idempotency?
A signature proves the payload wasn’t changed and that the sender knows your shared secret, which blocks most spoofed requests. It does not stop the same valid event from being delivered multiple times, because retries can still carry a correct signature. Signature checks are necessary, but dedupe is what prevents double processing.
How do I validate webhook signatures correctly without false failures?
Validate against the raw request body bytes exactly as received, then compute the expected HMAC (or provider-specific algorithm) and compare using a constant-time function. If you verify a parsed or re-serialized JSON body, tiny formatting changes can break verification and tempt teams to “temporarily accept” invalid signatures, which is dangerous.
What timestamp window should I use to block replays?
Use a small skew window as a default, often around 5 minutes, so normal network delays still pass but captured requests expire quickly. The timestamp must be part of what’s signed; otherwise an attacker can change it. Keep your servers time-synced, because clock drift is a common reason good events get rejected.
What should I use as an idempotency key for webhook dedupe?
Start with the provider’s event_id or message_id if it exists, because it stays the same across retries. If you must build your own, derive it from stable fields like provider name, event type, resource ID, tenant ID, and a provider timestamp, often hashed together. Don’t use your own receive time or a random UUID, because duplicates won’t match.
Why is database-backed dedupe better than using a cache or in-memory lock?
A database write with a unique constraint is the most reliable way to make dedupe race-safe across multiple servers and parallel requests. The common pattern is “insert receipt first, then process,” so only one request wins and the others become no-ops. In-memory locks and caches tend to fail when you scale or restart.
Should I return 200 or an error when I detect a duplicate webhook?
For duplicates, return a consistent 2xx once you’ve confirmed you’ve already processed that event, so the provider stops retrying and you avoid a retry storm. For invalid signatures or timestamps outside your window, return a 4xx and do no work. The key idea is to avoid side effects unless the request is both authentic and new enough.
How do I handle out-of-order webhooks and concurrent deliveries?
Assume events can arrive out of order and design handlers to be safe when they do. Apply updates only if they’re newer than what you have (using a provider version, sequence, or updated_at when available), and prefer upserts so “update before create” doesn’t break you. Separately, make dedupe race-safe with a unique idempotency key so two identical deliveries can’t both run.
How do I stop webhook retries from causing double charges or duplicate invoices?
A common cause is processing the webhook twice because there’s no idempotency guard at the entrypoint, or because a crash happens after charging but before recording success. Check your logs for the same provider event ID appearing multiple times, and verify you’re inserting an idempotency receipt before side effects. If you inherited an AI-generated webhook handler that’s double-processing, FixMyMess can run a free code audit and apply the typical fixes—raw-body signature verification, timestamp windows, and database-backed idempotency—so retries become safe no-ops.