Idempotência em webhook de pagamento
Equipe AltiviaCloud · 5 de setembro de 2026 · 4 min de leitura
Todo provedor entrega pelo menos uma vez, nenhum entrega exatamente uma vez. Por que a guarda óbvia não protege e como deixar o banco decidir quem credita.
Todo provedor de pagamento entrega webhook pelo menos uma vez. Nenhum promete entregar exatamente uma vez. A diferença entre essas duas frases é dinheiro creditado duas vezes na carteira de um cliente.
Por que a mesma cobrança chega duas vezes
Três motivos, todos normais:
Eventos diferentes para o mesmo pagamento. Um Pix costuma disparar PAYMENT_RECEIVED e PAYMENT_CONFIRMED com segundos — às vezes milissegundos — de diferença. São eventos distintos descrevendo o mesmo dinheiro.
Retentativa. Seu endpoint demorou a responder, ou devolveu 500, e o provedor tenta de novo. Se ele processou e só falhou ao responder, você recebe o mesmo evento duas vezes e já executou uma.
Reenvio manual. Alguém clicou em "reenviar" no painel do provedor para depurar algo.
Nenhum desses casos é excepcional. Projetar para eles é o normal.
A armadilha que parece proteção
O código quase certo é assim:
const transacao = await db.transacao.findFirst({ where: { referencia: pagamentoId } });
if (transacao.status === "COMPLETED") return; // já processado
await db.$transaction(async (tx) => {
await tx.carteira.update({ /* credita */ });
await tx.transacao.update({ data: { status: "COMPLETED" } });
});
Parece que a guarda resolve. Não resolve. Entre o findFirst e o update existe uma janela, e os dois eventos do Pix chegam dentro dela. As duas execuções leem PENDING, as duas passam pela guarda, as duas creditam.
O detalhe cruel é que isso quase nunca aparece em teste. Em desenvolvimento você dispara um evento por vez e a guarda funciona. Em produção, os dois eventos chegam juntos.
A correção: deixe o banco decidir
A troca de estado precisa ser a primeira operação dentro da transação, e precisa ser condicional:
const creditou = await db.$transaction(async (tx) => {
const reservou = await tx.transacao.updateMany({
where: { id: transacao.id, status: { not: "COMPLETED" } },
data: { status: "COMPLETED" },
});
if (reservou.count === 0) return false; // outro evento chegou primeiro
await tx.carteira.update({
where: { id: transacao.carteiraId },
data: { saldo: { increment: transacao.valor } },
});
return true;
});
O que mudou: em vez de ler e depois decidir, o UPDATE ... WHERE status <> 'COMPLETED' trava a linha e reavalia a condição. Quem chegar depois recebe count = 0 e desiste. Não há janela, porque a checagem e a escrita são a mesma operação.
Use increment, não leia-some-escreva
Repare no increment no crédito. A versão ingênua é:
const carteira = await tx.carteira.findUnique(...);
await tx.carteira.update({ data: { saldo: carteira.saldo + valor } });
Isso reintroduz o mesmo problema em outro lugar. Se uma compra debitar entre a leitura e a escrita, o débito é sobrescrito e desaparece. O saldo fecha errado e ninguém entende por quê — a transação da compra existe no extrato, mas o valor está de volta.
increment e decrement deixam a aritmética no banco, onde ela é atômica.
Do outro lado: o débito
O mesmo raciocínio vale para gastar. Validar saldo com um if sobre um valor lido antes deixa duas compras simultâneas passarem pela mesma validação:
const debitou = await tx.carteira.updateMany({
where: { id: carteiraId, saldo: { gte: valor } },
data: { saldo: { decrement: valor } },
});
if (debitou.count === 0) throw new Error("Saldo insuficiente");
O saldo vira condição do próprio UPDATE. É a mesma técnica.
O resto do endpoint
Confira a assinatura antes de confiar no corpo. Endpoint de webhook sem verificação é uma API pública para quem descobrir a URL. E chave que valida webhook é chave de produção: fora do repositório.
Responda rápido. Confirme com 2xx e faça o trabalho pesado depois. Endpoint lento vira endpoint retentado, e aí você testa a sua idempotência de verdade.
Registre o que ignorou. Quando o segundo evento cair fora, deixe um log dizendo isso. No dia em que alguém perguntar por que o extrato tem um crédito e o provedor mostra dois eventos, esse log responde em trinta segundos.
Como testar
Não confie em disparar um evento por vez. Dispare os dois em paralelo, contra o banco de verdade:
await Promise.all([
processarWebhook("PAYMENT_RECEIVED", pagamentoId),
processarWebhook("PAYMENT_CONFIRMED", pagamentoId),
]);
Depois confira o saldo. Se creditou duas vezes, a guarda está no lugar errado. Esse teste leva dez minutos para escrever e é a única forma de saber antes que o cliente descubra.
Vale o mesmo cuidado do outro lado: se o seu teste passa porque as operações acabaram serializando por acaso, ele não provou nada. Simule o intervalo real entre a leitura e a escrita — é ele que abre a janela.
É assim que o saldo da AltiviaCloud é creditado quando o Pix confirma. Como funciona a cobrança está em preços.