Como evitei cobrar um PIX duas vezes
Webhook de PIX chega duplicado. Como autentiquei com HMAC, confirmei com lock pessimista no Postgres e registrei tudo num ledger append-only.
Um usuário paga um PIX no app do banco. Meu servidor não participa disso: ele só descobre o pagamento quando o provedor de pagamento (o PSP) faz uma chamada HTTP para uma URL minha. Essa chamada é o webhook. E o PSP só para de insistir quando recebe um 2xx de volta.
Isso cria um problema que todo sistema de pagamento tem: o mesmo aviso pode chegar duas vezes, ou duas ao mesmo tempo, em workers diferentes. No Kravva, uma confirmação faz quatro coisas: marca a transação como paga, libera o acesso, lança a venda no financeiro e credita a comissão de um parceiro. O usuário pagou uma vez, mas se eu rodar essas quatro coisas duas vezes, contabilizo duas vendas e pago comissão em dobro. É esse o "cobrar duas vezes" do título: o efeito do meu lado, não o débito na conta do cliente.
Tem um segundo problema: a URL é pública. Qualquer pessoa pode enviar um JSON dizendo "paguei" e, se eu acreditar, libero acesso pago de graça.
Resolvi em três camadas: autenticar a chamada, torná-la idempotente e registrar o dinheiro de um jeito que não se reescreve.
Camada 1: provar que o aviso veio do PSP
HMAC é um hash que mistura uma chave: HMAC(chave, mensagem). Sem a chave, ninguém calcula o resultado certo. Pense num lacre de cera com um anel que só o PSP tem. Ele assina o corpo da requisição e manda o resultado num header; eu recalculo e comparo.
Dois detalhes que custam caro quando esquecidos:
- A assinatura vale para os bytes exatos que o PSP enviou. Se eu deixar o framework converter o JSON em objeto e reserializar, espaços e ordem das chaves podem mudar e a assinatura "falha". Por isso o NestJS sobe com
rawBody: truee eu valido o corpo cru. - A comparação precisa ser em tempo constante. Um
===para no primeiro caractere diferente, e um atacante que mede o tempo de resposta consegue descobrir a assinatura aos poucos (timing attack). OtimingSafeEqualdo Node compara tudo sempre, mas exige buffers do mesmo tamanho, então checo o tamanho antes.
// Simplificado do código real
const esperada = createHmac('sha256', chave)
.update(Buffer.from(corpoCru, 'utf8')) // bytes originais
.digest('base64');
const a = Buffer.from(esperada);
const b = Buffer.from(assinaturaDoHeader);
// tamanho primeiro, depois comparação em tempo constante
return a.length === b.length && timingSafeEqual(a, b);
Uma ressalva honesta: no meu PSP, a chave usada na assinatura é publicada na documentação deles. Ou seja, a assinatura confere integridade e formato, mas não basta sozinha. Por isso exijo um segundo fator: um segredo meu, só conhecido por mim e pelo PSP, anexado à URL cadastrada. Sem os dois, a resposta é 401.
Camada 2: confirmar uma única vez
Idempotência é a propriedade de uma operação que, repetida, tem o mesmo efeito de executada uma vez. Webhook é entrega "pelo menos uma vez": exatamente uma vez não existe na rede. Então a idempotência tem que estar do meu lado.
O instinto é "coloco numa transação". Só que uma transação sozinha não impede a corrida. No nível padrão do Postgres, duas entregas simultâneas leem "pendente", ambas decidem que precisam confirmar e ambas gravam "pago".
A saída é o lock pessimista: assumir que o conflito vai acontecer e travar antes. SELECT ... FOR UPDATE lê a linha e a mantém travada até o fim da transação. Quem chega depois espera. Analogia: banheiro com chave na recepção. A segunda pessoa não tenta a porta e briga com a primeira; ela espera a chave voltar e, quando entra, vê que já foi limpo.
Escolhi o pessimista em vez do otimista (versão na linha, quem perde tenta de novo) porque o conflito é provável, já que PSPs reenviam em rajada, e a seção crítica é curta.
// TypeORM, simplificado
return this.dataSource.transaction(async (manager) => {
// vira SELECT ... FOR UPDATE
const tx = await manager.findOne(Transacao, {
where: { id: transacaoId },
lock: { mode: 'pessimistic_write' },
});
if (!tx) throw new Error('Transação não encontrada');
const jaEstavaPago = tx.statusPagamento === 'pago';
if (!jaEstavaPago) {
tx.statusPagamento = 'pago';
await manager.save(tx);
await this.razao.registrarVendaSeAusente(manager, tx);
await this.creditarComissao(manager, tx); // só na 1ª vez
}
// roda sempre: repara acesso que ficou faltando
await this.garantirAcessos(manager, tx);
});
Repare em dois detalhes. A checagem jaEstavaPago acontece com a linha travada, então a segunda entrega enxerga o estado já commitado pela primeira. E a concessão de acesso roda sempre, de propósito: se um estado parcial ficou para trás, a reentrega conserta o acesso sem recreditar a comissão.
Há ainda uma regra sobre efeitos colaterais: o aviso em tempo real ao usuário só sai depois do commit. Se a transação desfizer, ninguém é avisado de um acesso que não existe.
Camada 3: um ledger que só cresce
O dinheiro vai para um ledger (razão): uma tabela em que linhas nunca são editadas nem apagadas. Uma correção é uma linha nova que compensa a anterior. Estorno é um lançamento negativo, e o saldo é a soma.
tipo | valor_centavos | competência
venda | +4990 | 2026-08
estorno | -4990 | 2026-09 (linha nova, negativa)
Três decisões aqui:
- Centavos inteiros.
0.1 + 0.2em JavaScript dá0.30000000000000004. Em dinheiro o erro acumula. R$ 49,90 é4990. - Estorno na competência do evento. Se ele reescrevesse agosto, um mês já apurado mudaria de valor. Com a linha negativa em setembro, "quanto vendi em agosto?" tem a mesma resposta hoje e daqui a um ano.
- Constraint única como rede de segurança. O código evita a duplicidade, mas o banco é a última barreira se o código falhar.
-- no máximo uma venda e um estorno por transação
CREATE UNIQUE INDEX uq_lancamento_transacao_tipo
ON lancamentos_financeiros (transacao_id, tipo)
WHERE deleted_at IS NULL;
Os escritores do ledger são "se ausente" (inserem só se o lançamento ainda não existe) e recebem o EntityManager do chamador. Assim vender e lançar acontecem na mesma transação: juntos ou nenhum.
O que testar
Teste de idempotência com mock não prova nada, porque mock não reproduz lock. Os meus rodam contra um Postgres real e cobrem reentrega, falha no meio e duas entregas concorrentes.
Para a concorrência, não torço para o escalonador sobrepor as chamadas. Uso um "portão": uma conexão separada trava a linha, disparo as duas entregas, espero as duas ficarem bloqueadas, solto o portão e verifico o resultado: um acesso, uma comissão, um evento de tempo real.
O que eu faria diferente
Olhando com honestidade para o meu código:
- O histórico de webhooks não tem
UNIQUE. A dedupe ali é "consulta e insere", e duas entregas simultâneas passam pela consulta antes de qualquer insert. O efeito financeiro está protegido pelo lock, mas o histórico pode duplicar. O certo é umUNIQUEcomON CONFLICT DO NOTHING. - O
FOR UPDATEtambém busca porgateway_id, que não tem índice. Com a tabela crescendo, a busca vira varredura e o lock dura mais. - Transação inexistente responde 500. O PSP reenvia sem parar. Falha permanente deve ser registrada e respondida com 2xx.
- A imutabilidade do ledger é só convenção. Nada no banco impede um
UPDATE, e odeleted_atherdado abre uma brecha para o índice parcial deixar de enxergar uma linha. Eu colocaria um trigger que recusaUPDATEeDELETE. - Comissão fica fora do ledger. Hoje é um incremento num saldo mutável, então o ledger não fecha contra o caixa. Partidas dobradas resolveriam.
Resumo para levar
- Webhook é entrega "pelo menos uma vez": o mesmo aviso chega repetido ou em paralelo, e o seu código precisa aguentar.
- Valide o HMAC sobre o corpo cru, com
timingSafeEqual, e não confie numa assinatura cuja chave seja pública sem um segundo fator. - Transação não basta: use
SELECT ... FOR UPDATE, cheque o estado com a linha travada e deixe constraints únicas como última barreira. - Dinheiro em ledger append-only, centavos inteiros, estorno como linha negativa; só avise o usuário depois do commit.
- Teste a concorrência contra Postgres real, forçando a sobreposição.