Pular para o conteúdo
upgbp

Webhook executando duas vezes: como garantir idempotência

Todo provedor de webhook entrega pelo menos uma vez, o que significa às vezes duas. A trava que resolve é uma constraint de banco, não um if.

9 min de leitura

O erro tratado aqui

Webhook signature verification failed: No signatures found matching the expected signature for payload

Ambiente testado

  • Next.js 15.3
  • Stripe SDK 18
  • Prisma 6.5
  • Postgres 15
Neste artigo (9)

Contexto: o que estava rodando

Uma rota de webhook em Next.js recebendo eventos de pagamento da Stripe. O fluxo era o esperado: recebe checkout.session.completed, marca o pedido como pago, credita o saldo do usuário e dispara o e-mail de confirmação.

Funcionou por semanas. Até um usuário receber dois e-mails de confirmação da mesma compra — e, pior, o saldo creditado em dobro.

O erro

Dois problemas apareceram juntos, o que atrapalhou o diagnóstico. O primeiro foi uma falha de verificação de assinatura que aparecia de forma esparsa:

vercel logs --prodexit 1
Error: Webhook signature verification failed:
No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?
https://github.com/stripe/stripe-node#webhook-signing
    at Object.constructEvent (/var/task/node_modules/stripe/cjs/Webhooks.js:42:15)
    at POST (/var/task/.next/server/app/api/webhooks/stripe/route.js:1:1204)

O segundo era mais silencioso. Nos logs da aplicação, o mesmo evento aparecia processado duas vezes, com poucos segundos de diferença:

logs da aplicação
12:04:19  evt_3PxKm2A processado  → pedido ord_8812 pago, saldo +50
12:04:23  evt_3PxKm2A processado  → pedido ord_8812 pago, saldo +50

Mesmo evt_ id. Mesma carga. Dois processamentos.

Diagnóstico

Entrega dupla não é bug do provedor

A primeira reação foi procurar o defeito na Stripe. Não existe defeito. Webhooks funcionam com garantia de entrega pelo menos uma vez — o provedor prefere entregar duas vezes a correr o risco de não entregar nenhuma. Isso vale para Stripe, GitHub, Shopify, Mercado Pago e praticamente qualquer plataforma séria.

Uma entrega duplicada acontece quando:

  • sua resposta demorou e o provedor considerou a tentativa falha, mas seu código processou assim mesmo;
  • sua rota devolveu qualquer coisa fora da faixa 2xx (inclusive por um erro em código que rodou depois do trabalho já ter sido feito);
  • houve reprocessamento interno na fila do provedor.

Nos três casos o provedor reenvia. A Stripe, especificamente, insiste com backoff crescente por até três dias.

Por que a assinatura falhava às vezes

Este era um erro separado, e a causa foi o corpo da requisição. A Stripe assina os bytes exatos que enviou. Qualquer coisa que leia e reserialize o JSON — await req.json() seguido de JSON.stringify() — muda espaçamento e ordem de chaves, e a assinatura deixa de bater.

O código estava assim:

src/app/api/webhooks/stripe/route.ts
const body = await req.json();
const event = stripe.webhooks.constructEvent(
  JSON.stringify(body), // reserializado: já não são os bytes originais
  signature,
  secret,
);

Funcionava por acaso na maioria dos eventos, porque o JSON.stringify do Node costuma reproduzir a mesma string. Quebrava nos eventos com números decimais ou com campos em ordem diferente da esperada.

A tentativa de correção que não resolve

A primeira ideia para a duplicidade foi checar antes de processar:

const jaProcessado = await prisma.webhookEvent.findUnique({
  where: { id: event.id },
});
if (jaProcessado) return new Response('ok', { status: 200 });

await prisma.webhookEvent.create({ data: { id: event.id, type: event.type } });
await processar(event);

Isso reduz o problema, mas não elimina. Entre o findUnique e o create existe uma janela. Duas entregas concorrentes — que é exatamente o caso, porque o reenvio chega enquanto a primeira ainda roda — passam as duas pelo findUnique antes de qualquer uma ter gravado. As duas processam.

Verificar-e-depois-inserir é uma condição de corrida com passos extras.

Linha do tempo em que a Stripe envia um evento, não recebe 2xx no prazo e reenvia. As duas entregas passam pelo findUnique antes de qualquer uma gravar, e as duas creditam o saldo.
A janela entre ler e gravar é onde as duas entregas se cruzam — e ela existe mesmo com a verificação prévia.

A solução

  1. Ler o corpo cru, não o JSON.

    No App Router, req.text() devolve exatamente os bytes recebidos. É o que a verificação de assinatura precisa.

    src/app/api/webhooks/stripe/route.ts
    import { headers } from 'next/headers';
    import Stripe from 'stripe';
    
    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
    
    export async function POST(req: Request) {
      // No Next.js 15 headers() é assíncrono — sem o await, o valor vem como
      // Promise e a verificação falha com a mesma mensagem confusa.
      const signature = (await headers()).get('stripe-signature');
      if (!signature) return new Response('sem assinatura', { status: 400 });
    
      const rawBody = await req.text();
    
      let event: Stripe.Event;
      try {
        event = stripe.webhooks.constructEvent(
          rawBody,
          signature,
          process.env.STRIPE_WEBHOOK_SECRET!,
        );
      } catch (err) {
        // Assinatura inválida é o único caso em que 4xx é correto: repetir não
        // vai consertar, e um 4xx faz o provedor parar de insistir.
        console.error('assinatura inválida', err);
        return new Response('assinatura inválida', { status: 400 });
      }
      // ...
    }
  2. Deixar o banco decidir quem processa.

    A trava tem que ser atômica. Uma chave primária no id do evento transforma “já processei isso?” numa pergunta que o Postgres responde sem janela de corrida.

    prisma/schema.prisma
    model WebhookEvent {
      id          String    @id          // o id do provedor, ex: evt_3PxKm2A
      type        String
      receivedAt  DateTime  @default(now())
      processedAt DateTime?
    
      @@index([processedAt])
    }

    O createMany com skipDuplicates vira um INSERT ... ON CONFLICT DO NOTHING e devolve quantas linhas realmente entraram. Zero significa que outra execução chegou primeiro.

    src/app/api/webhooks/stripe/route.ts
    const claim = await prisma.webhookEvent.createMany({
      data: [{ id: event.id, type: event.type }],
      skipDuplicates: true,
    });
    
    if (claim.count === 0) {
      // Alguém já pegou este evento. Responder 200 é essencial: um erro aqui
      // faria o provedor reenviar de novo, para sempre.
      return new Response('duplicado, ignorado', { status: 200 });
    }
  3. Liberar a reserva quando o processamento falha.

    Reservar e depois quebrar deixaria o evento marcado como visto e nunca processado — a repetição legítima do provedor seria descartada pelo passo anterior.

    src/app/api/webhooks/stripe/route.ts
    try {
      await processar(event);
      await prisma.webhookEvent.update({
        where: { id: event.id },
        data: { processedAt: new Date() },
      });
    } catch (err) {
      // Devolve a reserva para que o reenvio do provedor tenha o que fazer.
      await prisma.webhookEvent.delete({ where: { id: event.id } });
      console.error('falha ao processar', event.id, err);
      return new Response('erro ao processar', { status: 500 });
    }
    
    return new Response('ok', { status: 200 });
  4. Responder 200 para o que você não trata.

    Um switch sem default que cai em erro faz o provedor reenviar indefinidamente eventos que você nunca vai querer.

    src/lib/webhooks/processar.ts
    export async function processar(event: Stripe.Event) {
      switch (event.type) {
        case 'checkout.session.completed':
          return concluirPedido(event.data.object);
        case 'charge.refunded':
          return estornarPedido(event.data.object);
        default:
          // Assinado e válido, só não interessa. Aceitar encerra o ciclo de
          // reenvio em vez de acumular tentativas por três dias.
          return;
      }
    }

Como confirmar que resolveu

Reenvie o mesmo evento de propósito. O painel da Stripe tem o botão Resend no detalhe de cada evento. O segundo envio deve responder 200 e não produzir nenhum efeito colateral novo.

Dispare duas entregas simultâneas. É o teste que pega a corrida — o botão de reenvio manual é sequencial demais para reproduzir o problema:

BODY=$(cat evento.json)
for i in 1 2 3 4 5; do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -X POST https://seu-app.vercel.app/api/webhooks/stripe \
    -H "stripe-signature: $ASSINATURA" \
    -H "content-type: application/json" \
    --data-raw "$BODY" &
done
wait

Esperado: um 200 que processou e quatro 200 de duplicado ignorado. Se aparecer mais de um efeito colateral, a trava não está atômica.

Confira a tabela. Uma linha por evento, com processedAt preenchido:

select id, type, received_at, processed_at
from "WebhookEvent"
where processed_at is null
order by received_at desc;

Linhas presas com processed_at nulo indicam processamento que morreu no meio — vale investigar antes que virem pedidos pagos e nunca entregues.

Armadilhas que sobram depois disso

O stripe listen do CLI usa outro segredo. O webhook secret do encaminhamento local é diferente do configurado no painel. Misturar os dois gera exatamente o erro de assinatura deste artigo, só que apenas em desenvolvimento.

Ainda existe uma janela pequena. Se o processo morrer entre concluir o efeito colateral e gravar o processedAt, o catch não roda e a reserva permanece. O evento fica marcado como visto sem estar concluído. Para dinheiro, a proteção final é o efeito colateral em si ser idempotente — um crédito com chave única por pedido, não um saldo += 50.

Middleware que lê o corpo quebra a assinatura. Qualquer coisa no caminho da requisição que consuma o stream antes da rota deixa req.text() vazio. Se você tem middleware global, exclua a rota de webhook do matcher.

Idempotência do lado de fora também importa. Ao chamar APIs externas dentro do processamento, mande uma chave de idempotência própria. Assim, mesmo que a lógica repita, a cobrança do outro lado não repete.

Continue por aqui