O handler de webhook devolve 200, o provedor marca a entrega como concluída, e três segundos depois a mesma notificação chega de novo. O resultado costuma ser cobrança em dobro, e-mail de boas-vindas duplicado ou estoque baixado duas vezes. A causa quase nunca é bug do provedor: é o contrato de entrega, que na prática é at-least-once. Quem consome precisa tratar duplicidade como caso normal, não como exceção.
Por que o duplicado acontece mesmo com 200
Três mecanismos geram repetição, e cada um pede uma defesa diferente. O primeiro é o timeout: o provedor espera de poucos segundos até cerca de trinta pela resposta, e se o handler demora mais, considera falha e reenvia, mesmo que o processamento tenha terminado depois. O segundo é a perda da resposta: o handler processou, gravou no banco e respondeu 200, mas a conexão caiu antes de o provedor receber. Para ele, a entrega falhou. O terceiro é o replay manual, quando alguém clica em “reenviar” no painel do provedor para recuperar um período de indisponibilidade.
Em todos os casos o payload chega idêntico, normalmente com o mesmo identificador de evento. É esse identificador, não o conteúdo, que deve ancorar a deduplicação. Comparar hash do corpo parece atraente, mas falha quando o provedor reenvia o mesmo evento com um campo de tentativa ou timestamp de entrega diferente no corpo.
Dedupe por chave única no banco, não em memória
A tentação inicial é guardar os IDs já vistos num cache em memória ou no Redis com TTL. Funciona em desenvolvimento e quebra em produção por dois motivos: com mais de uma réplica, a checagem “já vi?” e a gravação “agora vi” deixam de ser atômicas entre processos, e o TTL expira antes de um replay manual feito no dia seguinte.
A solução com menos partes móveis é uma tabela de eventos processados com restrição de unicidade sobre o ID do evento, e a inserção acontece na mesma transação que o efeito colateral de negócio. Se a inserção falha por violação de unicidade, o evento já foi tratado e o handler responde 200 sem refazer nada.
BEGIN;
INSERT INTO eventos_processados (provedor, evento_id, recebido_em)
VALUES ('gateway_x', 'evt_8841', now())
ON CONFLICT (provedor, evento_id) DO NOTHING;
-- se 0 linhas afetadas: duplicado, faça ROLLBACK e responda 200
-- se 1 linha afetada: aplique o efeito de negócio e faça COMMIT
A chave composta por provedor e ID evita colisão quando o mesmo sistema consome webhooks de mais de uma origem. O ponto que mais se erra aqui é separar a inserção da marcação do efeito: se o evento é gravado como processado e o efeito falha em seguida, o retry do provedor será descartado como duplicado e o dado se perde em silêncio. Por isso, uma única transação.
Ordem não é garantida
Deduplicar resolve repetição; não resolve ordem. Um evento de “pagamento aprovado” pode chegar depois do “pagamento estornado” se o primeiro caiu em retry e o segundo passou direto. Aplicar eventos na ordem de chegada produz um pedido marcado como pago quando o estado correto é estornado.
Há duas estratégias defensáveis. A primeira é comparar versão: se o payload traz um número de sequência ou timestamp de criação do evento, o handler só aplica a mudança quando ele é mais novo que o último estado gravado. A segunda, mais robusta, é tratar o webhook apenas como aviso e buscar o estado atual do recurso na API do provedor antes de gravar. O custo é uma chamada extra por evento; o ganho é que ordem e repetição deixam de importar, porque o estado lido é sempre o corrente.
Para fluxos financeiros, a segunda opção é a escolha mais segura. Para notificações de baixo risco, como “usuário abriu o e-mail”, comparar timestamp basta e evita a chamada.
Responder rápido e processar depois
O handler que valida a assinatura, grava o evento bruto e responde 200 em poucas dezenas de milissegundos elimina a maior fonte de timeout. O processamento real passa a uma fila interna, consumida por workers que aplicam a lógica de negócio e a deduplicação descritas acima.
| Abordagem | Latência da resposta | Risco principal | Quando usar |
|---|---|---|---|
| Processar dentro do handler | Alta e variável | Timeout gera reentrega durante o processamento | Efeito trivial, sem I/O externo |
| Gravar bruto e enfileirar | Baixa e estável | Fila vira ponto de falha adicional | Padrão para qualquer fluxo com efeito de negócio |
| Buscar estado na API ao processar | Irrelevante, já assíncrono | Limite de taxa da API do provedor | Eventos financeiros e de estado crítico |
A validação de assinatura precisa vir antes de gravar qualquer coisa, e deve usar o corpo bruto da requisição. Frameworks que fazem parse do JSON e reserializam alteram espaços e ordem de chaves, e a verificação de HMAC passa a falhar de forma intermitente, o que costuma ser diagnosticado errado como “o provedor mudou algo”.
Testar duplicidade antes que o provedor teste por você
Poucas equipes têm teste automatizado para entrega repetida, e é justamente o caso que aparece em produção numa sexta à noite. O teste mínimo tem três cenários: o mesmo evento enviado duas vezes em sequência, o mesmo evento enviado duas vezes em paralelo e dois eventos do mesmo recurso chegando fora de ordem. O segundo é o que mais revela problema, porque duas requisições simultâneas passam juntas pela checagem “já processado?” quando ela é feita com um SELECT antes do INSERT. A restrição de unicidade resolve isso no banco, e o teste confirma que a segunda transação realmente falha em vez de gravar um segundo registro.
Vale também simular o caso em que o efeito de negócio lança exceção depois da inserção do evento. Se a transação está correta, o evento não fica marcado como processado e o retry do provedor refaz o trabalho. Se ficar marcado, há um buraco: o dado do cliente se perde sem erro visível, e só aparece quando alguém concilia números no fim do mês.
Um detalhe operacional costuma ser esquecido: o ambiente de homologação do provedor raramente reenvia com a mesma agressividade que o de produção. Muitos provedores deixam disparar o reenvio manualmente por evento, e usar esse recurso em homologação, com um handler propositalmente lento, reproduz o timeout real sem esperar um incidente.
Retries do lado do consumidor e dead letter
Quando o worker falha ao processar, o reprocessamento precisa de limite. Três a cinco tentativas com espera crescente cobrem falhas transitórias, como banco indisponível por alguns segundos. Passado o limite, o evento vai para uma fila de descarte com o payload bruto e o erro, e alguém é avisado. Reprocessar para sempre um evento que sempre falha, por exemplo com um campo obrigatório ausente, trava a fila inteira se o consumo for sequencial e esconde o problema se for paralelo.
O erro a evitar é responder 500 ao provedor para “aproveitar” o retry dele como mecanismo de reprocessamento. Isso acopla a política de repetição ao comportamento de um terceiro, que varia entre provedores e pode desativar o endpoint depois de falhas seguidas.
Retenção, replay e o que monitorar
Guardar o payload bruto de cada evento por algumas semanas paga o custo de armazenamento na primeira investigação de divergência financeira. Com o bruto em mãos, reprocessar um período vira uma consulta, não um pedido de suporte ao provedor.
Três métricas contam a saúde do fluxo sem ruído: taxa de eventos descartados como duplicados, tempo entre recebimento e conclusão do processamento, e quantidade de eventos na fila acima de um limiar de idade. Um aumento súbito de duplicados quase sempre indica que o handler ficou lento e o provedor está reenviando; vale tratar esse número como alerta de latência, não como sinal de que a deduplicação “está funcionando bem”.
A posição aqui é clara: chave única transacional mais resposta imediata cobre a grande maioria dos casos, e a busca do estado na API entra apenas onde um erro de ordem custa dinheiro. Qualquer arquitetura mais elaborada que isso merece justificativa em incidente real, não em previsão.
Deixe um comentário