APIs Arquitetura

Idempotência em webhooks de pagamento: o dia em que a cobrança chegou duas vezes

Webhooks têm entrega at-least-once: o mesmo evento chega duas vezes e o cliente é cobrado em dobro. Por que verificar antes de inserir não resolve.

· 11 min de leitura

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:

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 TOCTOUtime-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çãoRespostaPor 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:

  1. Valide a assinatura e grave o evento numa fila ou tabela de entrada. Responda 200 imediatamente.
  2. 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:

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.