O cliente comprou uma vez. O extrato mostra duas cobranças. O suporte abre um chamado, o financeiro estorna na mão, e você vai olhar o log para entender o que aconteceu.
O gateway não cobrou duas vezes. Ele te avisou duas vezes — e o seu código tratou os dois avisos como se fossem compras diferentes. O culpado não é o pagamento. É o webhook.
Webhook não é uma promessa de entrega única
A intuição de todo mundo é errada aqui. A gente imagina o webhook como uma notificação que chega uma vez: aconteceu tal coisa, tome ciência. Não é assim que funciona.
Provedores de pagamento — Stripe, Mercado Pago, Pagar.me, qualquer um sério — entregam webhooks com garantia at-least-once: pelo menos uma vez. Nunca exatamente uma vez. E "pelo menos uma" inclui "várias".
O motivo é o próprio contrato de confiabilidade. O provedor manda o evento e espera um 2xx de volta. Se ele não recebe esse 2xx, ele assume que você não recebeu o evento e reenvia. Só que o 2xx pode se perder por vários motivos que não têm nada a ver com você ter processado ou não:
- Você processou em 200ms, mas a resposta demorou e o provedor deu timeout antes de recebê-la
- Uma instabilidade de rede engoliu a resposta no caminho de volta
- O seu deploy reiniciou o processo entre o processamento e o envio da resposta
- Um balanceador derrubou a conexão
Em todos esses casos o evento foi processado, mas o provedor não sabe disso. Da perspectiva dele, o certo a fazer é reenviar. Da perspectiva do seu código ingênuo, chegou um pagamento novo.
Reenvio não é bug do provedor. É a feature que garante que você não perde eventos. O preço dela é que a responsabilidade de deduplicar é sua.
A solução ingênua que todo mundo escreve primeiro
A primeira versão que vem à cabeça é verificar se o evento já foi visto antes de processar:
async function tratarWebhook(evento) {
const jaExiste = await db.query(
'SELECT 1 FROM eventos_processados WHERE id = $1',
[evento.id]
);
if (jaExiste.rowCount > 0) return; // já processei, ignoro
await creditarPedido(evento);
await db.query(
'INSERT INTO eventos_processados (id) VALUES ($1)',
[evento.id]
);
}
Parece correto. Em teste manual, funciona. Em produção, ele falha exatamente no cenário que deveria resolver — porque tem uma condição de corrida no meio.
Quando o provedor reenvia por timeout, é comum as duas entregas chegarem quase juntas. Se o seu servidor tem mais de um worker (e ele tem), as duas requisições rodam em paralelo:
Worker A Worker B
-------- --------
SELECT ... WHERE id = 'evt_1'
→ 0 linhas (ainda não existe)
SELECT ... WHERE id = 'evt_1'
→ 0 linhas (ainda não existe)
creditarPedido() ✗ cobra
creditarPedido() ✗ cobra de novo
INSERT evt_1
INSERT evt_1
Os dois workers verificam antes de qualquer um inserir. Os dois veem "não existe". Os dois processam. Isso se chama TOCTOU — time-of-check to time-of-use: a verdade que você checou já não vale no momento em que você age sobre ela.
O intervalo entre o SELECT e o INSERT é pequeno, mas não é zero. E "pequeno" é exatamente o tamanho da janela onde o dobro de cobrança acontece.
A regra: quem decide a unicidade é o banco
A correção não é verificar melhor. É parar de verificar na aplicação e deixar o banco recusar a duplicata. Um SELECT seguido de decisão é sempre uma corrida; uma restrição de unicidade é atômica por definição.
CREATE TABLE eventos_processados (
id TEXT PRIMARY KEY, -- id do evento no provedor
processado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
Com a chave primária no id do evento, o próprio banco garante que dois INSERT do mesmo evento não coexistem. O truque é inverter a ordem: registrar o evento antes de processar, e deixar o INSERT ser o porteiro.
async function tratarWebhook(evento) {
const r = await db.query(
`INSERT INTO eventos_processados (id) VALUES ($1)
ON CONFLICT (id) DO NOTHING`,
[evento.id]
);
// 0 linhas afetadas = alguém já inseriu esse id = duplicata
if (r.rowCount === 0) return;
await creditarPedido(evento);
}
Agora, quando os dois workers correm em paralelo, só um consegue inserir. O outro bate no ON CONFLICT, recebe rowCount === 0 e retorna sem fazer nada. O banco serializa a disputa que a aplicação não conseguia serializar sozinha.
Regra que vale para toda deduplicação: a unicidade tem que ser imposta por uma restrição, não por uma consulta. Se o seu código decide "existe? então não faço", há uma corrida ali. Se o banco recusa o segundo registro, não há.
Mas agora tem um bug pior escondido
Olhe de novo o último exemplo. O INSERT e o creditarPedido() são duas operações separadas. O que acontece se o processo morre entre as duas linhas?
INSERT eventos_processados ✓ commitou: evento marcado como processado
✗ processo reinicia aqui
creditarPedido() nunca rodou
O evento fica marcado como processado, mas o pedido nunca foi creditado. E como está marcado, o reenvio do provedor vai ser ignorado — você trocou a cobrança dupla por um pagamento que some. É o erro oposto, e é pior, porque é silencioso: ninguém abre chamado por uma coisa que simplesmente não aconteceu.
A solução é fazer as duas coisas na mesma transação. Ou as duas acontecem, ou nenhuma:
async function tratarWebhook(evento) {
const cliente = await pool.connect();
try {
await cliente.query('BEGIN');
const r = await cliente.query(
`INSERT INTO eventos_processados (id) VALUES ($1)
ON CONFLICT (id) DO NOTHING`,
[evento.id]
);
if (r.rowCount === 0) { // duplicata
await cliente.query('ROLLBACK');
return;
}
// mesma transação: o efeito e a marca de "processado"
// fazem commit juntos ou desaparecem juntos
await creditarPedido(cliente, evento);
await cliente.query('COMMIT');
} catch (err) {
await cliente.query('ROLLBACK');
throw err; // deixa o provedor reenviar — ver adiante
} finally {
cliente.release();
}
}
Agora, se o processo cair no meio, a transação não fez commit: nem a marca nem o crédito persistem. O reenvio do provedor encontra o evento ainda não registrado e processa do zero. Consistência de verdade — não a aparência dela.
O detalhe que amarra tudo: o crédito precisa acontecer no mesmo banco da tabela de eventos, usando a mesma conexão (cliente). Se creditarPedido escreve em outro serviço via HTTP, você voltou a ter duas operações sem atomicidade entre elas — o problema de dual write que já é assunto de outro artigo.
O que responder para o provedor
O código de status HTTP que você devolve controla o comportamento de reenvio. Errar isso transforma o mecanismo de segurança em uma tempestade de retries.
| Situação | Resposta | Por quê |
|---|---|---|
| Processado com sucesso | 200 |
Confirma o recebimento; o provedor para de reenviar |
| Duplicata já processada | 200 |
Para você é ruído, mas para o provedor está tudo certo. Nunca retorne erro aqui |
| Falha temporária (banco fora, timeout) | 5xx |
Você quer o reenvio: a idempotência garante que não haverá efeito duplicado |
| Payload inválido / assinatura errada | 400 |
Reenviar não vai consertar; recusa definitiva |
O erro clássico é retornar 500 numa duplicata porque o INSERT "falhou". Da ótica do provedor, 500 significa "ele não recebeu, vou reenviar" — e você entra num laço: reenvio gera 500, que gera reenvio. Duplicata é 200. Sempre.
Responda rápido, processe com calma
Provedores dão timeouts curtos para o webhook — Stripe, por exemplo, espera resposta em poucos segundos. Se o seu processamento é pesado (gerar nota fiscal, disparar e-mails, chamar outros serviços), fazer tudo antes de responder é pedir para estourar o timeout e provocar reenvio.
O padrão é separar registrar de processar:
- Valide a assinatura e grave o evento numa fila ou tabela de entrada. Responda
200imediatamente. - Um worker separado consome esses eventos e faz o trabalho pesado — com a mesma lógica de idempotência, porque a fila também é at-least-once.
Repare que a idempotência não sai de cena ao usar fila — ela se duplica. Tanto a borda (o webhook) quanto o worker precisam deduplicar, porque as duas etapas entregam pelo menos uma vez. Idempotência não é um ponto no sistema; é uma propriedade de toda etapa que pode reentregar.
Dois cuidados que mordem depois
Eventos fora de ordem
At-least-once não garante ordem. Você pode receber pagamento.aprovado antes de pagamento.criado, ou um reembolso antes do evento de pagamento que ele reembolsa. Se o seu código assume sequência, ele quebra com dados reais.
A defesa é não tratar eventos como comandos ("faça X"), e sim como transições de estado sobre o pedido. Um pedido pago que recebe outro pagamento.aprovado continua pago. Um evento que chega cedo demais para um pedido que ainda não existe é guardado e reprocessado, não descartado. Modelar o pagamento como máquina de estados resolve ordem e duplicação de uma vez só.
A tabela de eventos cresce para sempre
A eventos_processados só cresce. Em volume alto, ela vira um problema de armazenamento e de índice. Você não precisa guardar para sempre: o provedor reenvia por uma janela limitada (horas a poucos dias). Um job que remove registros mais antigos que, digamos, 30 dias mantém a dedup eficaz sem inchar a tabela — desde que a retenção seja folgadamente maior que a janela de reenvio do provedor.
A outra direção: quando você chama o gateway
Até aqui falamos de webhooks entrando. O mesmo problema existe na saída, quando é você que chama a API do gateway para criar uma cobrança e a resposta se perde. Você não sabe se cobrou; se tentar de novo, pode cobrar duas vezes.
A solução é simétrica, e os bons gateways já a oferecem: a chave de idempotência. Você gera um identificador único para a operação e o envia num cabeçalho:
await fetch('https://api.gateway.com/v1/charges', {
method: 'POST',
headers: {
'Authorization': `Bearer ${chave}`,
'Idempotency-Key': pedido.id, // mesmo id = mesma cobrança
},
body: JSON.stringify({ valor: 25000, moeda: 'brl' }),
});
Se a requisição chegou mas a resposta se perdeu, repetir com a mesma Idempotency-Key faz o gateway devolver o resultado da cobrança original em vez de criar outra. É o mesmo princípio da tabela de eventos, agora do lado deles. Sempre que uma operação com efeito financeiro pode ser repetida, deve carregar uma chave que a torne segura de repetir.
Quando você não precisa disso tudo
Idempotência tem custo: uma tabela a mais, transações mais cuidadosas, um worker. Nem todo webhook justifica:
- Efeito naturalmente idempotente. Se o webhook só faz "marcar pedido como pago" — uma escrita que dá no mesmo resultado quantas vezes rodar — processar duas vezes não causa dano. Formalize a dedup mesmo assim, mas a urgência é menor.
- Sem efeito colateral externo. Um webhook que só atualiza um campo interno, sem cobrar, sem enviar, sem chamar terceiros, tem margem de erro bem maior.
- Volume trivial e operação manual. Num sistema com pouquíssimas transações e conferência humana, o custo de engenharia pode superar o risco. Raro em pagamentos — mas existe.
A régua é simples: o efeito é reversível e barato, ou irreversível e caro? Cobrar o cartão de um cliente é irreversível e caro. Aí não se economiza em idempotência.
Em resumo
Webhook de pagamento chega pelo menos uma vez, o que significa que um dia chega duas. A cobrança dupla não é falha do gateway — é o seu código confiando numa entrega única que nunca foi prometida.
A correção cabe em três frases: deixe o banco impor a unicidade com uma restrição, não com um SELECT; una o efeito e a marca de processado na mesma transação; e responda 200 às duplicatas para não alimentar a tempestade de reenvios. O resto é cuidar de ordem, retenção e do caminho de saída.