Blog

· 7 min de leitura

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.

  • nestjs
  • postgres
  • idempotencia

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: true e 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). O timingSafeEqual do 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.2 em 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 é um UNIQUE com ON CONFLICT DO NOTHING.
  • O FOR UPDATE também busca por gateway_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 o deleted_at herdado abre uma brecha para o índice parcial deixar de enxergar uma linha. Eu colocaria um trigger que recusa UPDATE e DELETE.
  • 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.

Escrito por Maycon Liborio, desenvolvedor full stack. Se isso te ajudou ou você discorda de algo, me chama no LinkedIn.

Mais posts

Maycon Liborio

São José dos Campos, SP

Aceito projetos freelance pontuais.

© 2026 · Feito à mão com Nuxt, CSS moderno e zero bibliotecas de UI. Dados gerados do meu perfil-mestre a cada build.

esc

Navegar

Projetos

Posts

Ações

↑↓ navegar↵ abrir/ ou ⌘K alterna