Já escrevi sobre o mecanismo geral de idempotência. Pagamento merece um texto próprio porque o fluxo tem quatro pontos de entrada de duplicata, e cada um pede uma defesa diferente.
Tratar os quatro com a mesma chave de idempotência é o erro que faz o time achar que resolveu.
Ponto 1: o usuário clica duas vezes
O mais banal e o mais frequente. Botão sem estado de carregamento, conexão lenta, dedo ansioso.
Desabilitar o botão é experiência do usuário, não proteção. A requisição já saiu, e mesmo que não saísse, quem quiser mandar duas vezes manda pela API.
A defesa é a chave de idempotência gerada quando a intenção de pagamento nasce, não quando o botão é clicado:
// no momento em que o checkout é montado, não no submit
const [idempotencyKey] = useState(() => crypto.randomUUID());
Gerar no submit produz uma chave nova por clique, que é exatamente o que você não quer.
Ponto 2: o retry do seu próprio cliente HTTP
Timeout de leitura configurado em cinco segundos, o gateway responde em seis, sua biblioteca tenta de novo.
Do outro lado, a primeira cobrança foi processada. A segunda também vai ser, se você não mandou chave de idempotência pro gateway. E aqui a chave tem que ser a mesma nas duas tentativas, o que significa que ela não pode ser gerada dentro da função de retry.
// errado: cada tentativa vira uma cobrança diferente
await retry(() => gateway.charge({ ...data, idempotencyKey: uuid() }));
// certo: a chave pertence à intenção
const key = uuid();
await retry(() => gateway.charge({ ...data, idempotencyKey: key }));
Regra geral que vale pra tudo: chave por intenção, nunca por tentativa.
E revise o timeout. Operação de pagamento é lenta por natureza, porque envolve adquirente e bandeira. Timeout curto demais transforma sucesso em retry desnecessário.
Ponto 3: o webhook reenviado
O gateway avisa que o pagamento foi aprovado. Se não receber 200 rápido, reenvia. Até seis ou sete vezes, dependendo do provedor, e às vezes em paralelo quando a fila dele acumulou.
Aqui a defesa é constraint no banco, usando o identificador do evento que o provedor manda:
alter table payment_event
add constraint payment_event_provider_unique unique (provider, provider_event_id);
Verificar antes com SELECT e depois inserir tem uma corrida no meio, e essa corrida acontece de verdade quando dois reenvios chegam juntos. Deixe o banco arbitrar.
O handler responde rápido e empurra o trabalho pra fila:
@Post("webhooks/:provider")
async receive(@Param("provider") provider: string, @Body() body: unknown,
@Headers("signature") sig: string) {
this.verifySignature(provider, body, sig); // sempre, antes de tudo
const { created, id } = await this.events.insertIgnore(provider, body);
if (created) await this.queue.add("process-payment-event", { id });
return { received: true }; // 200 em milissegundos
}
Repare que a resposta é 200 mesmo quando o evento já existia. Devolver erro pra um reenvio faz o gateway tentar de novo, e você entra num laço que só termina quando o provedor desiste e marca a integração como quebrada.
Ponto 4: o reprocessamento em lote
Esse é o mais perigoso, porque é operado por gente sob pressão.
Um lote falhou no meio. Alguém roda de novo. Os itens que já tinham sido processados são cobrados outra vez.
A defesa não é chave de idempotência: é o item do lote carregar um identificador natural e estável, e o processamento verificar antes de agir.
-- a cobrança de uma assinatura num ciclo só pode existir uma vez
alter table subscription_charge
add constraint subscription_charge_cycle_unique
unique (subscription_id, billing_cycle);
billing_cycle sendo algo como 2026-08. Rodar o lote de agosto quinze vezes produz a mesma cobrança uma vez só.
Esse tipo de constraint derivada do negócio é bem mais forte que qualquer chave técnica, porque ela sobrevive a alguém rodando o script errado com os parâmetros errados.
A parte que não é idempotência
Duas coisas costumam ser confundidas com esse problema e pedem outra solução.
Estorno não deve ser idempotente pela chave do pagamento. Ele é uma operação própria, com sua chave e seu registro. Modelar estorno como “desfazer o pagamento” leva a apagar história, e história é o produto num ledger.
E cobrança recorrente legítima não é duplicata. O mesmo cliente, mesmo valor, mesmo cartão, um mês depois, é uma cobrança nova. Se a sua proteção for baseada em comparar valor e cliente numa janela de tempo, você vai bloquear cobrança válida. Por isso a chave é explícita e o ciclo entra na constraint.
Como testar
Um teste que vale por dez:
it("processa o mesmo webhook duas vezes sem duplicar crédito", async () => {
const event = paymentApprovedFixture();
await Promise.all([postWebhook(event), postWebhook(event)]);
await drainQueue();
const entries = await ledger.entriesFor(event.subscriptionId);
expect(entries).toHaveLength(3); // as pernas de uma transação só
});
O Promise.all é o detalhe importante. Chamar duas vezes em sequência passa mesmo em código com corrida. Em paralelo é onde o bug aparece.
Se você nunca rodou esse teste no seu fluxo de pagamento, vale mais que ler o resto deste texto.