"Webhook duplicado, cobrança duplicada: o bug de idempotência que peguei no OverAir antes de produção"

Stripe e Meta entregam o mesmo webhook mais de uma vez. Sem dedup, 0,5% dos clientes pagam 2x. Como peguei isso num stress test às 23h, e o padrão que uso.

Resposta direta, antes da história: o Stripe e a Meta entregam o mesmo webhook mais de uma vez, e se o seu handler não deduplica, uma fração dos seus clientes vai ser cobrada duas vezes. A doc do Stripe é literal: "Ocasionalmente, os endpoints de webhook podem receber o mesmo evento mais de uma vez. Pra se proteger contra eventos duplicados, registre os IDs dos eventos que você processou e não processe eventos já registrados" (Stripe Docs, 2026). A correção é uma tabela com o event.id como chave primária e um short-circuit antes de mexer em qualquer estado. Eu peguei isso no OverAir num stress test às 23h, na véspera de abrir o beta — antes de cobrar ninguém errado. Esse post é o momento exato em que o bug apareceu, o handler ingênuo que quase subiu pra produção, e o padrão que rodo hoje nos dois lados (Stripe e WhatsApp).

Sou o Ulisses, fundador da Hens. O OverAir tem zero clientes pagantes hoje — vou ser honesto com isso o post inteiro. Mas o handler de cobrança já tava escrito, testado em sandbox, "funcionando". E foi exatamente esse "funcionando" que quase me derrubou.

23h, o stress test que ninguém pediu

Era véspera de abrir o beta. O fluxo de checkout do OverAir tava verde havia uma semana: o cara paga no Stripe, o webhook checkout.session.completed chega, o handler cria a subscription, libera o plano, manda a confirmação no WhatsApp. Eu tinha clicado nesse fluxo umas trinta vezes no modo de teste. Funcionava sempre.

Mas tinha uma coisa me incomodando. Em sandbox você dispara um evento por vez. Em produção, com gente pagando ao mesmo tempo e a rede sendo a rede, eu não fazia ideia do que aconteceria. Então, às 23h, em vez de dormir, escrevi um script que disparava o mesmo evento de checkout várias vezes em paralelo — simulando a Meta e o Stripe reentregando, que é o que eles fazem quando seu endpoint demora a responder 200.

Rodei. Olhei o Firestore. Duas subscriptions pro mesmo usuário. Dois registros de cobrança. Eu lembro de pensar, em voz alta, no escritório vazio: "isso não pode estar acontecendo". O handler tava certo. O problema é que ele rodava duas vezes, e ninguém tinha falado pra ele que isso era possível.

Não era bug do meu código no sentido clássico. Era uma suposição errada embutida nele: a de que cada webhook chega uma vez. Essa suposição é falsa nas duas plataformas que o OverAir usa.

O que o Stripe realmente promete (e não é entrega única)

Aqui mora o erro mental que quase todo handler vibe-coded carrega. As ferramentas geram código que assume exactly-once delivery — cada evento, uma vez. As plataformas garantem o oposto.

O Stripe entrega at-least-once e retenta falhas com backoff exponencial por até 72 horas, então o mesmo evt_… pode chegar várias vezes — o ID não muda entre as retentativas (Stripe Docs, 2026). A própria doc de requisições idempotentes do Stripe existe pra isso: chaves de idempotência "garantem a execução segura de retentativas... e previnem a criação acidental de objetos duplicados" (Stripe API Reference, 2026).

O número que circula é ~0,5% de entregas duplicadas. Sendo honesto: o Stripe não publica essa porcentagem — é uma estimativa operacional, não um dado oficial. O que o Stripe publica é a parte que importa mais: duplicata não é edge case, é condição normal de operação. Em teste você nunca vê. Em produção aparece toda semana.

A Meta faz igual no WhatsApp Cloud API. A doc é explícita: as notificações são entregues at-least-once, e "se um request pro seu endpoint retornar um status diferente de 200, as retentativas continuam com frequência decrescente até ter sucesso, por até 7 dias" (Meta for Developers, 2026). Sete dias de reentrega. Some isso com o ciclo de vida da mensagem, que dispara um evento por mudança de status, e o número de duplicatas só sobe.

Quem trata isso como exceção tá projetando pro mundo errado. Duplicata é o caso normal. Entrega única é a ilusão.

O handler ingênuo que quase foi pra produção

Esse era o formato do meu handler antes do stress test. Limpei os detalhes, mas a forma é essa:

export const stripeWebhook = onRequest(async (req, res) => {
  const event = stripe.webhooks.constructEvent(
    req.rawBody,
    req.headers["stripe-signature"],
    process.env.STRIPE_WEBHOOK_SECRET,
  );

  switch (event.type) {
    case "checkout.session.completed":
      await handlePaymentSuccess(event.data.object); // cria subscription + libera plano
      break;
  }

  return res.json({ received: true });
});

Leia de novo. A assinatura tá verificada — ótimo, ninguém forja evento. Mas não tem nada entre receber o evento e handlePaymentSuccess. O Stripe reentrega o mesmo checkout.session.completed, esse código roda handlePaymentSuccess de novo, e o usuário ganha a segunda subscription. O handler não tem memória. Ele não sabe que já viu esse evento.

E é aqui que dói de verdade, porque o sintoma em produção não é um erro 500 berrando no log. É um cliente recebendo dois e-mails de cobrança e abrindo uma disputa.

A conta de não fazer dedup

Cada chargeback no Stripe custa uma taxa de disputa de US$ 15, debitada do seu saldo na hora, independente de quem tem razão. Desde junho de 2025 ainda tem a dispute countered fee — outros US$ 15 se você decidir contestar (reembolsável só se você ganhar) (Stripe Support, 2026). Perdeu a disputa contestando? US$ 30 no total, mais o valor estornado, mais o cliente que nunca mais volta.

Faz a conta com a estimativa de 0,5%:

Item Valor
Transações/mês 1.000
Taxa de duplicata (estimada) 0,5%
Cobranças duplicadas/mês 5
Taxa de disputa (US$ 15 cada) US$ 75/mês
Se contestar e perder (US$ 30 cada) US$ 150/mês
Clientes perdidos por mês 5

US$ 75 a US$ 150 por mês evaporando em taxa de disputa, num produto que talvez fature US$ 2.000. E cada linha dessa tabela é um humano que confiou no seu checkout e levou cobrança dobrada. Pra mim, esse é o tipo de bug que não se negocia — você não sobe um fluxo de cobrança sem dedup, ponto. Não é "a gente arruma depois". Depois é o chargeback.

A correção: event.id como chave primária

A solução é chata de tão simples, e é exatamente por isso que as ferramentas pulam. Antes de processar qualquer coisa, você pergunta: já vi esse evento? Esse é o handler do OverAir hoje:

// ── Verifica assinatura do Stripe ──
const event = stripe.webhooks.constructEvent(
  req.rawBody,
  req.headers["stripe-signature"],
  process.env.STRIPE_WEBHOOK_SECRET,
);

// ── Idempotência: rejeita eventos duplicados ──
const eventRef = db().collection("stripe_events").doc(event.id);
const existing = await eventRef.get();
if (existing.exists) {
  console.log(`[OverAir] Stripe: evento duplicado ${event.id}, pulando`);
  return res.json({ received: true, duplicate: true });
}

switch (event.type) {
  case "checkout.session.completed":
    await handlePaymentSuccess(event.data.object);
    break;
  // ...
}

// ── Registra o evento pra idempotência ──
await eventRef.set({ type: event.type, processedAt: new Date() });
return res.json({ received: true });

A coleção stripe_events usa o event.id como ID do documento. O Stripe garante que esse ID é estável entre as reentregas, então a segunda chegada bate na guarda existing.exists e sai pela porta sem tocar em handlePaymentSuccess. Funciona pro checkout.session.completed e pra qualquer outro tipo — subscription deletada, invoice falhada, Pix confirmado depois. Uma guarda, todos os eventos.

Mas se você parou de ler aqui achando que tá resolvido, ainda tem um buraco. E foi esse buraco que me fez voltar pro código uma segunda vez.

A race condition que o get-then-set ainda deixa aberta

Olha o padrão de novo: get(), checa existing.exists, depois set(). Entre o get e o set tem uma janela. Se dois webhooks do mesmo event.id chegam ao mesmo tempo — que é precisamente o que reentrega paralela faz — os dois rodam o get() antes de qualquer um rodar o set(). Os dois veem "não existe". Os dois passam na guarda. Os dois processam.

A guarda de leitura-depois-escrita não é atômica. Ela reduz a probabilidade de duplicata em ordens de grandeza, mas não a zera. Pra um fluxo de cobrança, "quase nunca" ainda é dinheiro vazando.

A correção certa é deixar o banco resolver a corrida, porque escrita única é uma coisa que todo banco sabe fazer atomicamente. No Firestore, troca o set() por um create(), que falha se o documento já existe — então você captura o erro e trata como duplicata:

try {
  await eventRef.create({ type: event.type, processedAt: new Date() });
} catch (err) {
  if (err.code === 6 /* ALREADY_EXISTS */) {
    return res.json({ received: true, duplicate: true });
  }
  throw err;
}
// só chega aqui quem ganhou a corrida do create — processa com segurança

Em SQL é o mesmo princípio com outro nome: event.id como UNIQUE constraint e um INSERT que captura o erro de chave duplicada (ou um INSERT ... ON CONFLICT DO NOTHING e checa quantas linhas afetou). Quem perde a corrida do INSERT sabe que perdeu, e pula. A atomicidade vem do banco, não do seu if.

Eu deixei o get-then-set no OverAir por um tempo porque o volume era baixo e a probabilidade, ridícula. Mas registrei a dívida e fechei antes de qualquer cobrança real. Recomendação honesta: se o webhook mexe em dinheiro, use a escrita atômica desde o dia um — o if na frente do set é a versão que te dá falsa sensação de segurança.

O mesmo bug mora no WhatsApp

A graça de ter os dois problemas no mesmo produto é ver que a forma é idêntica. O webhook do WhatsApp do OverAir não tem uma tabela stripe_events separada — ele usa o próprio message.id como ID do documento na fila de processamento:

const docRef = db().collection("pending_messages").doc(message.id);
await docRef.set({
  message,
  senderPhone: message.from,
  status: "pending",
  retryCount: 0,
  createdAt: new Date(),
  expireAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});

Quando a Meta reentrega a mesma mensagem, o segundo set() escreve no mesmo documento — pending_messages/{message.id} — e simplesmente sobrescreve, em vez de criar um segundo item de trabalho. O trigger que processa a fila dispara uma vez por documento, não por entrega. A deduplicação é uma consequência natural de escolher o message.id como chave, não um passo extra. É o mesmo conselho que a Meta dá: use o messages[].id (entrada) ou o statuses[].id (status) como chave de deduplicação (Meta for Developers, 2026).

Repara no padrão de fundo nos dois lados: o handler do webhook só enfileira e responde 200 na hora; o processamento de verdade roda depois, isolado. Isso não é elegância — é defesa. Se o seu handler processa de forma síncrona e demora mais que a janela de timeout da plataforma (5 a 10 segundos no WhatsApp), a plataforma assume que falhou e reentrega, e aí você mesmo fabrica as duplicatas que tá tentando evitar. Responde 200 rápido, processa numa fila. Os dois conselhos — dedup por ID e ACK imediato — são a mesma moeda.

O checklist que rodo antes de qualquer webhook ir pra produção

Toda vez que um webhook de pagamento ou de mensagem entra num sistema que eu entrego, eu passo por isso:

  1. Verifica a assinatura com o rawBody, nunca com o req.body já parseado — o Stripe e a Meta assinam os bytes crus, e o parser muda os bytes.
  2. Deduplica por IDevent.id no Stripe, message.id/statuses[].id na Meta — com escrita atômica (create/UNIQUE), não com get-depois-set.
  3. Responde 200 na hora e enfileira — processamento pesado fora do request, pra não estourar o timeout e provocar reentrega.
  4. Trata cada tipo de evento explicitamente e loga os que você ignora — default silencioso esconde evento novo que a plataforma passou a mandar.
  5. Roda um stress test de reentrega paralela antes do beta. Dispara o mesmo evento N vezes ao mesmo tempo e confere que o estado final é idêntico ao de uma entrega só. É o teste que me salvou às 23h.

Nenhum desses cinco passos aparece num handler gerado por IA por padrão. Eu testei isso no post sobre hardening de bots WhatsApp vibe-coded — pedi pra três ferramentas escreverem o handler e as três assumiram entrega única. Não é burrice da ferramenta. É que a doc da Meta e do Stripe não tá no contexto dela quando ela gera o seu código. Tá no meu, porque eu já paguei pra aprender.

A lição

Webhook não é uma função que você chama — é uma promessa frouxa de que uma mensagem vai chegar, talvez mais de uma vez, talvez fora de ordem. Todo handler que assume entrega única é um chargeback esperando uma data. A dedup por ID com escrita atômica custa dez linhas e te poupa a conversa que ninguém quer ter: explicar pro cliente por que ele foi cobrado duas vezes.

Se você tem um fluxo de cobrança em produção e nunca rodou um stress test de reentrega paralela, abre o handler agora. A chance de ele assumir entrega única é alta, e o Stripe te avisou na primeira linha da doc. Na Hens, esse é o tipo de coisa que a gente acha antes de subir — e o stress test das 23h é mais barato que a primeira disputa.

Fontes

Quer um app assim pro seu negócio?

A Hens constrói apps Flutter, bots WhatsApp com IA e backoffices sob medida — com o mesmo rigor dos nossos produtos próprios. Do MVP ao app em produção. Primeira conversa é direta, sem formulário.

Falar no WhatsApp →