Blog

Chave de idempotência em webhook de pagamento: cobrar uma vez só

O provedor de pagamento entrega o mesmo evento três vezes e o cliente recebe três e-mails de confirmação, ou pior, três créditos na conta. Ninguém escreveu um bug: o webhook foi entregue mais de uma vez porque é assim que ele funciona, e o handler tratou cada entrega como se fosse a primeira. Este artigo mostra por que a reentrega não é falha do provedor e sim o contrato dele, por que o identificador do evento é a chave errada e qual é a certa, por que a checagem "já processei?" tem que ser uma restrição do banco e não um SELECT antes do INSERT, como lidar com eventos que chegam fora de ordem e descrevem estados que já foram superados, o que fazer quando o efeito colateral é externo e não participa da sua transação, e como testar tudo isso reenviando o mesmo payload de propósito.

2026-08-19 / Integrações / 16 min

01

Reentrega não é falha do provedor, é o contrato dele

A primeira coisa a aceitar é que o provedor de pagamento não promete entregar cada evento uma vez. Ele promete entregar pelo menos uma vez, e essa diferença é o artigo inteiro. Se o seu endpoint demora mais que o timeout dele, se responde 500 por causa de um deploy, se a conexão cai depois de o seu servidor ter processado mas antes de a resposta chegar, o provedor reenfileira e tenta de novo. Do lado dele, a tentativa anterior não teve confirmação e portanto não conta. Do lado do seu banco, ela contou perfeitamente.

O caso mais traiçoeiro é justamente esse último: o processamento deu certo, você gravou a transação, disparou o e-mail, liberou o acesso, e só então o processo caiu ou a rede engasgou antes do 200 sair. O provedor vê um timeout, marca a entrega como falha e tenta de novo em trinta segundos. Não existe nada que o seu handler possa responder para desfazer isso, porque a resposta nunca chegou. A única defesa possível está na segunda execução, não na primeira: ela precisa reconhecer que aquele trabalho já foi feito e não refazer.

  • Timeout do endpoint: o provedor corta em poucos segundos e reenfileira, mesmo que o seu handler ainda esteja rodando e vá terminar com sucesso.
  • Resposta perdida: o trabalho foi concluído e persistido, mas o 200 não chegou por queda de processo, deploy ou reset de conexão.
  • Retentativa por erro real: uma exceção no meio do handler devolve 500, o provedor tenta de novo e a parte que já tinha sido gravada roda pela segunda vez.
  • Reenvio manual: alguém clica em reenviar no painel do provedor durante uma investigação, e o evento antigo chega de novo semanas depois.
  • Entrega duplicada sem motivo aparente: sistemas de fila com garantia de pelo menos uma vez duplicam por conta própria, sem que nada tenha falhado do seu lado.

A conclusão prática é que o handler de webhook não é um endpoint comum. Ele é um consumidor de fila que precisa ser seguro para reexecução por construção, e todo desenho que assume "isso aqui roda uma vez" está errado desde o primeiro dia, mesmo que só quebre no dia do primeiro incidente.

02

A chave certa é a do efeito, não a do evento

O reflexo mais comum é deduplicar pelo identificador do evento que o provedor mandou. Funciona para o caso simples de reentrega literal do mesmo evento e falha em tudo o mais. Dois eventos com identificadores diferentes podem descrever o mesmo efeito no seu domínio: uma cobrança confirmada pode chegar como um evento de pagamento aprovado e depois como um evento de fatura paga, cada um com o seu id, ambos significando "credite este pedido". Deduplicar por id do evento processa os dois e credita duas vezes.

A pergunta correta não é "já vi este evento?" e sim "já produzi este efeito?". A chave de idempotência deve identificar a mudança de estado que você vai aplicar, não a mensagem que a anunciou. Na prática isso significa derivá-la do trio recurso, transição e origem: qual entidade do seu domínio muda, para qual estado ela vai, e a partir de qual referência externa. O identificador do evento continua útil, mas como registro de auditoria e como desempate, não como chave.

Estratégia de chaveO que ela deduplicaOnde falha
Id do evento do provedorReentrega literal da mesma mensagemDois eventos distintos que descrevem a mesma transição de estado
Hash do corpo inteiroPayloads byte a byte idênticosQualquer campo volátil no corpo, como timestamp de entrega, muda o hash e libera a duplicata
Id da cobrança do provedorTodos os eventos daquela cobrançaColapsa transições legítimas distintas da mesma cobrança, como aprovada e depois estornada
Recurso mais transição mais referência externaO efeito, independentemente de quantos eventos o anunciaramExige mapear cada tipo de evento para uma transição do seu domínio, o que é trabalho de modelagem
// webhook/idempotency-key.js
// A chave identifica o EFEITO no dominio, nao a mensagem que o anunciou.
// Dois eventos diferentes do provedor que produzem a mesma transicao
// colapsam na mesma chave e so o primeiro executa.
import { createHash } from 'node:crypto';

// Mapa explicito: tipo de evento do provedor -> transicao do seu dominio.
// O que nao esta aqui nao tem efeito e e apenas registrado.
const TRANSITION_BY_EVENT = {
  'payment_intent.succeeded': 'order.paid',
  'invoice.payment_succeeded': 'order.paid', // mesmo efeito, outro evento
  'charge.refunded': 'order.refunded',
  'charge.dispute.created': 'order.disputed',
};

export function effectKey(event) {
  const transition = TRANSITION_BY_EVENT[event.type];
  if (!transition) return null; // evento sem efeito: registrar e sair

  // externalRef ancora a chave na cobranca especifica do provedor.
  // Sem ela, dois pagamentos legitimos do MESMO pedido (uma segunda
  // tentativa apos estorno) colapsariam e o segundo seria engolido.
  const externalRef = event.data.object.id;
  const orderId = event.data.object.metadata.order_id;

  const canonical = [transition, orderId, externalRef].join('|');
  return {
    transition,
    orderId,
    externalRef,
    key: createHash('sha256').update(canonical).digest('hex'),
  };
}

Repare no papel da referência externa. Sem ela, a chave seria apenas pedido mais transição, e um cliente que teve o pagamento estornado e pagou de novo veria a segunda cobrança tratada como duplicata da primeira. Com ela, cada cobrança real do provedor tem a sua chave, e apenas as reentregas daquela cobrança específica colapsam. É a mesma armadilha de qualquer chave derivada: ela precisa ser larga o bastante para pegar as duplicatas e estreita o bastante para não engolir intenções legítimas.

03

A checagem tem que ser uma restrição, não um SELECT

Com a chave definida, o instinto é escrever a verificação mais óbvia: consultar se a chave já existe e, se não existir, processar e gravar. Esse desenho funciona em todos os testes e falha exatamente no cenário que motivou o artigo. Quando o provedor reentrega rápido, ou quando duas instâncias do seu serviço recebem a mesma entrega, as duas consultas rodam antes de qualquer uma das duas gravações. As duas veem a chave ausente, as duas processam, e o efeito duplica com o código de deduplicação instalado e funcionando.

Corrida entre duas entregas do mesmo evento

  instancia A                     instancia B
      |                               |
  SELECT chave -> ausente         SELECT chave -> ausente   <- as duas passam
      |                               |
  processa (credita)              processa (credita)        <- efeito DUPLICADO
      |                               |
  INSERT chave                    INSERT chave

  Com restricao UNICA no banco:

  instancia A                     instancia B
      |                               |
  INSERT chave -> OK              INSERT chave -> violacao  <- so uma vence
      |                               |
  processa (credita)              devolve 200 sem processar
      |                               |
  COMMIT (chave + efeito juntos)  (nenhum efeito aplicado)

A correção é inverter a ordem e delegar a exclusão mútua ao banco. A primeira coisa que a transação faz é inserir a chave numa tabela com restrição de unicidade. Quem consegue inserir ganhou o direito de processar; quem recebe violação de unicidade sabe que outra execução já assumiu aquele efeito e responde sucesso sem fazer nada. O ponto que sustenta a garantia é que a inserção da chave e a aplicação do efeito acontecem na mesma transação: se o processamento falhar e a transação for revertida, a chave desaparece junto e a próxima entrega poderá tentar de novo.

-- Tabela de chaves processadas. A restricao UNICA e o mecanismo
-- de exclusao mutua: nao e um indice para acelerar consulta, e a
-- propria garantia de que so uma execucao processa cada efeito.
CREATE TABLE processed_effects (
  key           TEXT PRIMARY KEY,
  transition    TEXT        NOT NULL,
  order_id      TEXT        NOT NULL,
  external_ref  TEXT        NOT NULL,
  event_id      TEXT        NOT NULL,  -- auditoria: qual entrega venceu
  processed_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Consulta operacional: quantas reentregas cada efeito recebeu depende
-- de um log separado de entregas, porque esta tabela guarda so a vencedora.
CREATE INDEX processed_effects_order_idx ON processed_effects (order_id);
// webhook/handler.js
// Ordem correta: reservar a chave PRIMEIRO, dentro da mesma transacao
// que aplica o efeito. Sem SELECT antes do INSERT.
import { effectKey } from './idempotency-key.js';

export async function handleWebhook(event, db) {
  const effect = effectKey(event);
  if (!effect) {
    await db.logIgnoredEvent(event.id, event.type);
    return { status: 200, body: 'ignored' };
  }

  try {
    await db.transaction(async (tx) => {
      // Se outra execucao ja reservou esta chave, o INSERT viola a
      // restricao UNICA e a transacao inteira aborta aqui, ANTES de
      // qualquer efeito ser aplicado.
      await tx.query(
        `INSERT INTO processed_effects (key, transition, order_id, external_ref, event_id)
         VALUES ($1, $2, $3, $4, $5)`,
        [effect.key, effect.transition, effect.orderId, effect.externalRef, event.id],
      );

      // Efeito de dominio na MESMA transacao: se ele falhar, a chave
      // some no rollback e a proxima entrega podera tentar de novo.
      await applyTransition(tx, effect);
    });
  } catch (err) {
    if (isUniqueViolation(err)) {
      // Duplicata: alguem ja processou este efeito. 200 para o provedor
      // parar de reentregar. Nao e erro, e o mecanismo funcionando.
      return { status: 200, body: 'duplicate' };
    }
    // Falha real: 500 para o provedor reentregar de proposito.
    throw err;
  }

  return { status: 200, body: 'processed' };
}

const isUniqueViolation = (err) => err.code === '23505'; // Postgres

Um detalhe de operação que costuma passar batido: a resposta para a duplicata é 200, não 409. Um código de erro faz o provedor reentregar o mesmo evento indefinidamente, e você acaba com uma fila de retentativas que nunca drena porque cada tentativa é corretamente rejeitada. Duplicata detectada é sucesso do ponto de vista do contrato: o efeito está aplicado, é isso que o provedor precisa saber.

04

Fora de ordem: idempotência não resolve regressão de estado

Idempotência garante que cada efeito seja aplicado no máximo uma vez. Ela não garante nada sobre a ordem em que os efeitos chegam, e essa é a segunda classe de bug do webhook de pagamento. Um evento de pagamento aprovado sai do provedor às 10h00 e é retentado às 10h05 por timeout. Um evento de estorno da mesma cobrança sai às 10h02 e é entregue de primeira. Do seu lado, o estorno chega antes da aprovação, e a aprovação retentada, tecnicamente não duplicada, sobrescreve o estorno e devolve o pedido para pago.

A defesa aqui não é outra chave, é uma ordem parcial explícita. Cada evento traz um instante de criação do lado do provedor, e cada recurso do seu domínio guarda o instante do último evento que o modificou. Uma transição só se aplica se o evento for mais novo que o último aplicado àquele recurso. Eventos velhos são registrados e descartados, sem erro, porque descrevem um passado que já foi superado. Quando o provedor oferece um número de sequência por recurso, use-o em vez do timestamp: relógio de provedor pode ter granularidade grosseira e dois eventos legítimos podem compartilhar o mesmo instante.

// webhook/apply-transition.js
// Guarda de ordem: uma transicao so se aplica se for mais nova que a
// ultima ja aplicada AO MESMO recurso. Evento velho nao e erro, e passado.
async function applyTransition(tx, effect, eventCreatedAt) {
  const updated = await tx.query(
    `UPDATE orders
        SET status = $1,
            last_event_at = $2
      WHERE id = $3
        AND (last_event_at IS NULL OR last_event_at < $2)
      RETURNING id`,
    [statusFor(effect.transition), eventCreatedAt, effect.orderId],
  );

  // Zero linhas: o pedido ja foi modificado por um evento MAIS NOVO.
  // A chave permanece gravada (o efeito foi considerado e resolvido),
  // e o evento antigo e apenas registrado para auditoria.
  if (updated.rowCount === 0) {
    await tx.query(
      `INSERT INTO stale_events (order_id, transition, event_created_at)
       VALUES ($1, $2, $3)`,
      [effect.orderId, effect.transition, eventCreatedAt],
    );
    return { applied: false, reason: 'stale' };
  }

  return { applied: true };
}

A guarda vive na cláusula WHERE do próprio UPDATE, e não num IF antes dele, pela mesma razão da seção anterior: ler o estado e depois decidir abre uma janela em que outra transação muda o estado no meio. Deixar a comparação dentro da instrução que escreve transforma a decisão numa operação atômica que o banco resolve sozinho.

05

Efeitos externos: o que não cabe na transação

Todo o desenho acima depende de o efeito caber na mesma transação da chave. Isso funciona para escrever no seu banco e falha para tudo que sai dele: enviar e-mail, chamar a API de um parceiro, publicar num tópico, disparar uma mensagem no WhatsApp. Se o handler grava a chave, comita e só depois envia o e-mail, uma queda entre as duas coisas deixa o efeito registrado como aplicado sem que tenha acontecido, e a reentrega seguinte será corretamente rejeitada como duplicata. O cliente nunca recebe o e-mail e a tabela diz que recebeu.

A solução padrão é não fazer o efeito externo dentro do handler. Na mesma transação em que você grava a chave e aplica a mudança de estado, insere também uma linha numa tabela de saída descrevendo a mensagem a enviar. Um processo separado lê essa tabela e faz o envio, marcando cada linha como entregue. Assim o webhook fica inteiramente transacional e o efeito externo herda a durabilidade do banco: se a transação comitou, a intenção de enviar está persistida e alguém vai enviar; se não comitou, nada foi registrado e a reentrega refaz tudo do zero.

  1. O handler abre a transação e insere a chave de idempotência na tabela de efeitos processados.
  2. Na mesma transação, aplica a mudança de estado no recurso do domínio, respeitando a guarda de ordem.
  3. Ainda na mesma transação, insere na tabela de saída a intenção de enviar o e-mail, com destinatário, template e dados.
  4. Comita. A partir daqui, ou tudo existe ou nada existe, e o provedor recebe 200.
  5. Um worker separado lê a tabela de saída, envia com a sua própria chave de idempotência no serviço de e-mail e marca a linha como entregue.
  6. Se o envio falhar, a linha continua pendente e o worker tenta de novo, sem qualquer relação com o retry do provedor de pagamento.

O ponto que amarra as duas pontas é que o worker também precisa ser seguro para reexecução, porque ele tem o mesmo problema numa escala menor: pode enviar e cair antes de marcar como entregue. Por isso o envio carrega a sua própria chave de idempotência, derivada do identificador da linha de saída, e o serviço de e-mail resolve a duplicata do lado dele. A propriedade se propaga de camada em camada, e é isso que permite retentar em qualquer ponto sem contar quantas vezes cada etapa rodou.

06

Testar reenviando o mesmo payload de propósito

Idempotência é uma propriedade que só existe se for testada, porque ela não aparece em nenhum teste de caminho feliz. Um handler completamente desprotegido passa em todos os testes que enviam cada evento uma vez. O teste que importa é o que envia duas vezes e verifica que o estado do mundo depois da segunda é idêntico ao estado depois da primeira, não apenas que a resposta foi 200.

// test/webhook-idempotency.test.js
// O teste que prova a propriedade: o estado apos N entregas do mesmo
// evento e igual ao estado apos 1. Comparar o ESTADO, nao a resposta.
import { handleWebhook } from '../webhook/handler.js';

test('entrega repetida nao duplica o efeito', async () => {
  const event = paymentSucceeded({ orderId: 'ord_1', chargeId: 'ch_1' });

  const first = await handleWebhook(event, db);
  const stateAfterFirst = await snapshot(db, 'ord_1');

  // Mesma entrega, tres vezes mais, inclusive em paralelo.
  const repeats = await Promise.all([
    handleWebhook(event, db),
    handleWebhook(event, db),
    handleWebhook(event, db),
  ]);

  expect(first.body).toBe('processed');
  expect(repeats.every((r) => r.status === 200)).toBe(true);
  expect(repeats.filter((r) => r.body === 'processed')).toHaveLength(0);
  expect(await snapshot(db, 'ord_1')).toEqual(stateAfterFirst);
  expect(await db.countOutbox('ord_1')).toBe(1); // um e-mail, nao quatro
});

test('evento distinto com o mesmo efeito tambem colapsa', async () => {
  // O provedor anuncia o mesmo pagamento por dois tipos de evento.
  await handleWebhook(paymentSucceeded({ orderId: 'ord_2', chargeId: 'ch_2' }), db);
  const after = await snapshot(db, 'ord_2');

  const result = await handleWebhook(
    invoicePaid({ orderId: 'ord_2', chargeId: 'ch_2' }), // id de evento diferente
    db,
  );

  expect(result.body).toBe('duplicate');
  expect(await snapshot(db, 'ord_2')).toEqual(after);
});

test('evento fora de ordem nao regride o estado', async () => {
  await handleWebhook(refunded({ orderId: 'ord_3', at: '10:02' }), db);
  await handleWebhook(paymentSucceeded({ orderId: 'ord_3', at: '10:00' }), db);

  expect((await snapshot(db, 'ord_3')).status).toBe('refunded');
});

Os três testes cobrem as três falhas distintas discutidas aqui, e vale notar que o segundo falharia num sistema que deduplica pelo identificador do evento, e o terceiro falharia num sistema perfeitamente idempotente que ignore ordem. São propriedades independentes: nenhuma implica a outra, e um handler correto precisa das duas. Rodar esses testes em paralelo, como no primeiro caso, é o que expõe a corrida entre o SELECT e o INSERT quando alguém, meses depois, tentar simplificar a reserva de chave.

  • Reenvie o mesmo payload em paralelo, não só em sequência: a versão sequencial passa mesmo com a implementação de SELECT antes do INSERT.
  • Compare o estado final do banco, não o código de resposta: um handler quebrado devolve 200 nas duas entregas e credita duas vezes.
  • Inclua um par de eventos distintos que descrevem o mesmo efeito, para provar que a chave é do efeito e não da mensagem.
  • Inclua um evento antigo depois de um novo, para provar que a guarda de ordem existe e não foi removida em algum refactor.
  • Conte as linhas na tabela de saída, porque é lá que a duplicata vira e-mail enviado duas vezes para o cliente real.

FAQ

Perguntas frequentes

Por que deduplicar pelo identificador do evento do provedor não é suficiente?

Porque o identificador do evento identifica a mensagem, e o que precisa acontecer no máximo uma vez é o efeito. Provedores de pagamento costumam anunciar a mesma mudança de estado por mais de um tipo de evento, por exemplo um evento de pagamento aprovado e outro de fatura paga referentes à mesma cobrança, cada um com o seu identificador próprio. Um handler que deduplica pelo id do evento vê duas mensagens distintas, processa as duas e aplica o efeito duas vezes, mesmo com a deduplicação instalada e funcionando exatamente como foi escrita. A chave correta é derivada da transição de domínio que você vai aplicar, do recurso afetado e da referência externa da cobrança, de modo que qualquer evento que descreva aquele mesmo efeito colapse na mesma chave. O identificador do evento continua valendo a pena guardar, mas como registro de auditoria de qual entrega venceu a corrida, e não como o critério de deduplicação.

Qual é o problema de consultar se a chave já existe antes de processar?

O problema é a janela entre a consulta e a gravação. Se duas entregas do mesmo evento chegam quase juntas, seja porque o provedor reentregou rápido ou porque duas instâncias do seu serviço receberam a mesma entrega, as duas consultas rodam antes de qualquer gravação acontecer, as duas encontram a chave ausente e as duas seguem para o processamento. O efeito duplica com o código de deduplicação presente e aparentemente correto, e o bug só aparece sob concorrência, o que faz dele um dos mais difíceis de reproduzir depois. A forma correta é inverter: a transação começa inserindo a chave numa tabela com restrição de unicidade, e quem recebe a violação sabe que perdeu a corrida e responde sucesso sem processar. A exclusão mútua passa a ser responsabilidade do banco, que resolve isso de forma atômica, em vez de depender de uma sequência de duas operações no seu código.

Como enviar e-mail ou chamar uma API externa sem quebrar a garantia?

Não fazendo isso dentro do handler. A garantia toda depende de a chave de idempotência e o efeito serem gravados na mesma transação, e uma chamada externa não participa dessa transação: se você comita e depois envia, uma queda no meio deixa a chave registrada sem que o envio tenha acontecido, e a reentrega seguinte é corretamente rejeitada como duplicata, então o cliente nunca recebe nada enquanto a tabela afirma que recebeu. O padrão que resolve é gravar, na mesma transação, uma linha numa tabela de saída descrevendo a mensagem a enviar, e deixar um worker separado ler essa tabela e fazer o envio de verdade, marcando cada linha como entregue. Assim o webhook fica inteiramente transacional e a intenção de enviar herda a durabilidade do banco. O worker precisa ser seguro para reexecução pela mesma razão, então o envio leva a sua própria chave de idempotência derivada do identificador da linha, e o serviço externo resolve a duplicata do lado dele.

O handler seguro é o que assume que vai rodar de novo

Webhook de pagamento entrega pelo menos uma vez, e nenhum código do lado do handler muda esse contrato. O que você controla é a segunda execução: se ela reconhece o efeito já aplicado, a reentrega é inofensiva; se não reconhece, todo timeout do provedor vira uma cobrança duplicada na conta de um cliente real. Posso revisar ou desenhar a camada de idempotência das suas integrações de pagamento, definindo a chave a partir do efeito e não do evento, movendo a exclusão mútua para uma restrição do banco, adicionando a guarda de ordem que impede regressão de estado e tirando os envios externos de dentro da transação, com os testes de reentrega que provam que a duplicata não passa.