O provedor de pagamento reenviou o mesmo evento payment.succeeded três vezes em quatro minutos, e o handler creditou o saldo três vezes. Não houve bug no provedor: a entrega “pelo menos uma vez” é o contrato documentado de praticamente todo sistema de webhooks, e o consumidor é quem precisa tornar a repetição inofensiva. O checklist abaixo cobre o que deve estar pronto antes de um endpoint de webhook receber tráfego real.
1. Defina a chave de idempotência antes de escrever o handler
Idempotência começa pela escolha da chave, e a escolha errada é a causa mais comum de duplicidade que sobrevive a testes. Há três candidatos, nesta ordem de preferência: o identificador do evento entregue pelo provedor (o event.id), o identificador do recurso combinado com o tipo de transição, ou um hash do corpo. O terceiro é o pior, porque o provedor pode reenviar o mesmo evento com campos de metadados diferentes, como contagem de tentativas ou timestamp de entrega, e o hash deixa de coincidir.
O identificador do recurso sozinho também não serve. Um pedido passa por created, paid e refunded, e a chave order_123 descartaria as transições legítimas depois da primeira. A chave precisa identificar o evento, não o objeto.
- Use
event.idquando o provedor garante unicidade por evento. - Sem esse campo, componha
recurso_id + tipo_do_evento + versão. - Evite hash do payload inteiro e evite qualquer chave que inclua horário de entrega.
2. Grave a chave e execute o efeito na mesma transação
O erro clássico é consultar “já processei esse id?” e, se não, processar e depois gravar o id. Entre a consulta e a gravação existe uma janela: duas entregas simultâneas passam pela consulta, ambas veem “não processado” e ambas executam o efeito. Retry de provedor costuma chegar justamente em rajada, então a janela é exercida com frequência.
A correção é inverter a ordem e deixar o banco arbitrar. Uma tabela de eventos processados com restrição de unicidade na chave, inserida dentro da mesma transação que aplica o efeito de negócio, resolve a corrida: a segunda transação falha na inserção, desfaz tudo e o handler responde com sucesso, porque a duplicata não é erro.
BEGIN;
INSERT INTO eventos_processados (chave, recebido_em)
VALUES ($1, now())
ON CONFLICT (chave) DO NOTHING;
-- se 0 linhas afetadas: COMMIT e responda 200 sem aplicar o efeito
UPDATE contas SET saldo = saldo + $2 WHERE id = $3;
COMMIT;
Dois detalhes importam aqui. Primeiro, a checagem de “0 linhas afetadas” precisa acontecer no código da aplicação antes do UPDATE; o SQL acima mostra a estrutura, não o fluxo de controle completo. Segundo, o efeito colateral que sai do banco, como e-mail, chamada a outra API ou publicação em fila, não participa da transação e exige tratamento próprio, tema da seção 4.
3. Responda rápido e processe depois, mas só se a fila também for idempotente
A maioria dos provedores considera a entrega falha se a resposta não chegar em poucos segundos, e o limite varia de provedor para provedor. Handler que faz trabalho pesado de forma síncrona produz timeout, o provedor reenvia, e o sistema cria o próprio problema de duplicidade. O padrão usual é validar a assinatura, gravar o evento bruto, responder 200 e processar de forma assíncrona.
Esse padrão desloca o problema em vez de eliminá-lo. A fila interna também entrega pelo menos uma vez, então o worker precisa da mesma proteção de unicidade da seção 2. Quem ignora isso passa a ter idempotência na borda e duplicidade no worker, um defeito mais difícil de reproduzir porque só aparece quando a fila reentrega após falha do consumidor.
Há também a decisão sobre o que gravar de imediato. Gravar o corpo bruto junto com os cabeçalhos de assinatura permite reprocessar depois de um deploy com bug, sem depender de o provedor reenviar. Vale a pena pelo custo de armazenamento, que costuma ser desprezível diante do custo de uma conciliação manual.
4. Trate os efeitos que não cabem na transação
Enviar e-mail de confirmação, emitir nota fiscal e chamar uma API de terceiros ficam fora do banco. Aqui existem duas estratégias, e a escolha depende de o destino aceitar ou não uma chave de idempotência própria.
| Situação do destino | Estratégia | Risco residual |
|---|---|---|
| Aceita chave de idempotência (cabeçalho ou campo) | Reenviar a mesma chave derivada do evento a cada tentativa | Baixo; o destino descarta a repetição |
| Não aceita chave | Outbox: gravar a intenção na transação e um worker envia marcando o status | Duplicata se o worker cair entre enviar e marcar |
| Efeito irreversível e sem chave (e-mail, SMS) | Outbox com status e política explícita: prefere-se perder um envio a duplicar? | Decisão de negócio, não técnica |
A última linha da tabela costuma ser omitida nas revisões de arquitetura. Em e-mail transacional, o custo de um envio duplicado é pequeno e o de um envio perdido pode ser alto, então a política recomendada é aceitar a duplicata rara. Para cobrança, a política inverte: duplicar é o pior desfecho, e um envio perdido gera um alerta e uma ação manual.
Valide a assinatura e a janela de tempo
Idempotência não substitui autenticação. O endpoint precisa verificar a assinatura HMAC sobre o corpo bruto, antes de qualquer parse de JSON, porque reserializar o objeto muda a ordem das chaves e quebra a verificação. Frameworks que entregam o corpo já parseado são fonte frequente de falha aqui; configure a rota para preservar os bytes originais.
Provedores sérios incluem um timestamp assinado. Rejeitar eventos com timestamp fora de uma janela curta limita o ataque de replay, e essa verificação convive bem com a tabela de eventos processados, que só precisa reter chaves dentro da janela de reentrega do provedor. Reter para sempre cresce sem benefício; um prazo de retenção de algumas semanas, alinhado à política de retry documentada do provedor, mantém a tabela pequena. Confirme o prazo exato na documentação do seu provedor em vez de assumir.
5. Eventos fora de ordem: idempotência não resolve sozinha
Entrega duplicada e entrega fora de ordem são problemas diferentes, e tratar um com a solução do outro deixa buracos. Se subscription.canceled chega antes de subscription.updated porque o segundo teve uma tentativa falha, aplicar os eventos na ordem de chegada deixa a assinatura ativa no seu sistema quando o estado real é cancelado. A tabela de eventos processados não detecta isso: ambos os ids são novos.
Há duas saídas e a escolha depende do que o provedor expõe. A primeira é comparar um campo monotônico do evento, como um número de versão ou o timestamp de criação do evento (não o de entrega), e descartar qualquer evento mais antigo do que o último aplicado ao mesmo recurso. A segunda, mais robusta e mais cara, é tratar o webhook apenas como um aviso de que algo mudou e buscar o estado atual do recurso na API do provedor antes de aplicar. Essa abordagem de “notificação mais consulta” elimina a classe inteira de problemas de ordem ao custo de uma chamada extra por evento e de depender da disponibilidade da API.
A recomendação aqui é usar a consulta para recursos cujo estado final importa mais do que o histórico, como assinaturas e status de pedido, e a comparação de versão para fluxos em que cada transição gera um efeito próprio, como lançamentos financeiros. Misturar as duas no mesmo handler é razoável, desde que a regra de cada tipo de evento esteja escrita num lugar só.
6. Teste a duplicata de verdade antes de publicar
Teste unitário com um handler chamado duas vezes em sequência não prova nada sobre concorrência. O teste que pega a corrida dispara a mesma requisição em paralelo contra o endpoint real, com banco real, e verifica que o efeito aconteceu uma vez só. Dez requisições simultâneas com o mesmo event.id bastam para expor a janela da seção 2.
seq 1 10 | xargs -P 10 -I{} curl -s -o /dev/null -w "%{http_code}\n" \
-X POST https://staging.exemplo.com/webhooks/pagamento \
-H "Content-Type: application/json" \
-H "X-Assinatura: $ASSINATURA" \
--data @evento-fixo.json | sort | uniq -c
Depois do disparo, conte as linhas afetadas no banco. O resultado esperado é uma única alteração de saldo e dez respostas 200. Se aparecerem dez alterações, a transação não está protegendo o efeito; se aparecerem respostas 500, a violação de unicidade está escapando como erro em vez de ser tratada como duplicata.
Por fim, monitore a taxa de duplicatas descartadas. Um pico indica que o provedor está reenviando por causa de lentidão na resposta, o que sinaliza um problema de latência no endpoint antes de qualquer cliente reclamar.