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.