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.
O erro tratado aqui
Webhook signature verification failed: No signatures found matching the expected signature for payloadAmbiente testado
- Next.js 15.3
- Stripe SDK 18
- Prisma 6.5
- Postgres 15
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:
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:
12:04:19 evt_3PxKm2A processado → pedido ord_8812 pago, saldo +50
12:04:23 evt_3PxKm2A processado → pedido ord_8812 pago, saldo +50Mesmo 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:
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.

A solução
-
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.tsimport { 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 }); } // ... } -
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.prismamodel WebhookEvent { id String @id // o id do provedor, ex: evt_3PxKm2A type String receivedAt DateTime @default(now()) processedAt DateTime? @@index([processedAt]) }O
createManycomskipDuplicatesvira umINSERT ... ON CONFLICT DO NOTHINGe devolve quantas linhas realmente entraram. Zero significa que outra execução chegou primeiro.src/app/api/webhooks/stripe/route.tsconst 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 }); } -
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.tstry { 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 }); -
Responder 200 para o que você não trata.
Um
switchsemdefaultque cai em erro faz o provedor reenviar indefinidamente eventos que você nunca vai querer.src/lib/webhooks/processar.tsexport 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
APIs & Resiliência
Fila no Postgres com SKIP LOCKED, ou serviço de fila: qual escolher
Dois workers pegando a mesma tarefa é o defeito clássico de fila caseira. A cláusula que resolve, e quando vale trocar o banco por um serviço.
APIs & Resiliência
FUNCTION_INVOCATION_TIMEOUT: quebrando trabalho longo em lotes
Aumentar o maxDuration só empurra a falha para um arquivo maior. A correção é a requisição iniciar o trabalho em vez de executá-lo até o fim.
APIs & Resiliência
Cortando o custo de API de LLM: medir, rotear e cachear
A fatura triplicou sem o uso triplicar. Como atribuir custo por funcionalidade e quais alavancas realmente rendem, na ordem de retorno.