Todos os artigos
webhook designidempotencyretriesdistributed systems

Retries e idempotência de webhooks sem lágrimas

Todo grande provedor de webhook — Stripe, GitHub, Shopify, Twilio, Slack — entrega o mesmo evento mais de uma vez de propósito. Chamam isso de entrega ao-menos-uma-vez. Se o seu handler não foi feito para isso, você terá clientes cobrados em dobro, labels de PR do GitHub duplicados e notificações do Slack disparadas duas vezes. A correção é pequena e subestimada: projete cada handler para ser seguro sob entrega repetida.

Por que duplicatas acontecem

Provedores fazem retry quando não recebem um 2xx limpo a tempo. A janela de retry varia — Stripe: até 3 dias com backoff exponencial; GitHub: 8 tentativas em ~8 horas; Shopify: 48 horas. Na maioria das vezes a entrega original deu certo; a rede só perdeu a resposta no caminho de volta. O provedor não sabe disso. Então faz retry. Então o seu handler roda duas vezes.

Isso não é um bug a corrigir; é uma propriedade do sistema. Construa pensando nisso.

O padrão de idempotência que realmente funciona

Todo evento de um grande provedor inclui um ID de evento único. Stripe: evt_xxx. GitHub: cabeçalho X-GitHub-Delivery (um UUID). Shopify: X-Shopify-Webhook-Id. Guarde o ID na primeira vez que processar o evento. No retry, consulte-o — se já viu, retorne 200 sem rodar o handler de novo.

async function handleWebhook(event) {
  const eventId = event.id;

  // Atomic insert-if-not-exists
  const inserted = await db.processedEvents.insertIfNew({
    eventId,
    receivedAt: new Date(),
  });

  if (!inserted) {
    // We've handled this one. Be polite about it.
    return { status: 200, body: 'already processed' };
  }

  // First time we've seen it. Do the work.
  await actuallyHandleEvent(event);

  return { status: 200 };
}

A forma que importa: o insert e a lógica do handler devem ser atômicos juntos, ou o insert deve vir primeiro. Se você chamar handleEvent primeiro e depois inserir o ID, um crash no meio do handler não deixa registro e o retry vai reprocessar.

Onde guardar a chave de dedup

  • A mesma transação de BD que a escrita de negócio. O melhor. A chave de dedup e o efeito colateral ou commitam ambos ou nenhum. Use uma restrição única na coluna do ID de evento.
  • Redis com um TTL longo. Ok para eventos de baixo risco. Ajuste o TTL à maior janela de retry de qualquer provedor com que você integra — comece em 7 dias.
  • Cache em memória. Não. Seu processo reinicia, o cache some, e o próximo retry consegue rodar de novo um handler que já rodou.

Como "não idempotente" aparece na prática

Um exemplo real que vivemos: um handler do Stripe invoice.payment_succeeded que chamava account.credit(amount) diretamente. Cada pagamento bem-sucedido somava saldo. Na maioria das vezes exatamente uma vez. Ocasionalmente duas, quando um retry pegava o mesmo evento. Descobrimos porque um cliente perguntou educadamente por e-mail por que a fatura dele de US$ 50 havia creditado US$ 100 na conta.

A correção não foi mudar a política de retry do Stripe nem brigar com o provedor. Foi um índice único em events_processed.stripe_event_id e uma guarda no topo do handler. O patch inteiro tinha onze linhas.

O timing do handler também importa

Provedores têm timeouts rígidos. GitHub: 10 segundos. Endpoints de interação do Discord: 3 segundos. Stripe e Shopify são mais tolerantes (~30 segundos), mas você ainda leva retry se for lento. O padrão é o mesmo: responda rápido, faça o trabalho async.

async function handleWebhook(event) {
  if (await alreadyProcessed(event.id)) {
    return { status: 200 };
  }

  // Don't await the heavy work — queue it.
  await queue.enqueue('process-stripe-event', event);

  // 200 immediately. Worker picks up the event.
  return { status: 200 };
}

O worker da fila cuida do que é lento. O handler de webhook é basicamente um deduper + um produtor de fila. Esse padrão continua correto sob retries porque o produtor de fila em si é idempotente (insert-if-new no ID de evento), e qualquer enqueue duplicado é pego pela mesma guarda no worker.

Teste localmente, por favor

Testar idempotência é exatamente para o que o replay de túnel foi feito. Capture uma entrega de evento no histórico de requisições do seu túnel e reproduza-a dez vezes contra seu handler local. A primeira chamada deve escrever no seu banco. As nove seguintes devem retornar 200 sem tocá-lo. Observe os logs do BD para confirmar.

Esse é o teste que a maioria dos times pula e que a maioria dos times não deveria pular.

Políticas de retry por provedor

  • Stripe: faz retry por 3 dias, backoff exponencial. Desiste após 16 tentativas.
  • GitHub: 8 tentativas, concluídas em ~8 horas. Depois marca a entrega como falha.
  • Shopify: 48 horas, backoff exponencial. Desativa a assinatura do webhook após 19 falhas consecutivas em 48 horas.
  • Twilio: um retry por padrão, configurável até 3.
  • Slack: 3 retries com backoff (1 s, 30 s, 5 min).

Defina seu TTL de dedup para a maior janela que importa, mais uma margem.

A conclusão

A entrega duplicada é um contrato, não um defeito. Se você subiu um handler de webhook que chama increment, send_email ou charge_card sem chave de dedup, você subiu um bug. Corrigi-lo é pequeno. Descobri-lo após uma reclamação de cliente é caro.

Para mais sobre testar isso de ponta a ponta, o guia de depuração com replay cobre o fluxo. A verificação de assinatura vem antes disso — o guia de assinatura cobre isso. Entre na lista de espera do PortPreview para túnel + replay construídos em torno exatamente desse fluxo.

Perguntas frequentes

Por que os provedores de webhook enviam o mesmo evento duas vezes?
Os provedores usam entrega ao-menos-uma-vez. Se não recebem uma resposta 2xx a tempo, fazem retry — mesmo quando o handler original de fato teve sucesso, mas a resposta se perdeu. Trate a entrega duplicada como uma feature garantida do sistema, não um caso extremo.
Como torno um handler de webhook idempotente?
Use o ID de evento que o provedor envia (Stripe evt_xxx, cabeçalho X-GitHub-Delivery do GitHub, X-Shopify-Webhook-Id do Shopify) como chave de deduplicação. Insira-o com uma restrição única no início do handler; se o insert falhar porque já existe, retorne 200 sem rodar a lógica de negócio de novo.
Por quanto tempo devo manter a chave de dedup?
No mínimo o tempo da maior janela de retry do provedor. O Stripe faz retry por 3 dias, o Shopify por 48 horas. Um TTL de 7 dias cobre todos os grandes provedores com folga. Se tiver armazenamento, mantenha-a para sempre — também é útil para auditoria.