Webhook idempotente: o bug que só aparece em produção
Todo gateway reenvia webhook. Se o seu handler não for idempotente, você vai descobrir isso no dia em que a mesma venda for contada três vezes.
Você integra o gateway, dispara o webhook de teste, a venda aparece no banco, tudo verde. Duas semanas depois o relatório de faturamento não bate com o extrato, e a diferença é sempre pra mais.
O motivo quase nunca é bug de cálculo. É a mesma venda gravada duas vezes.
Por que o webhook chega repetido
Todo gateway sério (Stripe, Kiwify, Hotmart, Pagar.me) promete entrega at-least-once, nunca exactly-once. Isso não é preguiça deles, é física de rede: se o servidor deles manda o POST e o seu demora 11 segundos pra responder, eles não têm como saber se você processou e a resposta se perdeu, ou se você nunca recebeu nada. A única escolha segura é reenviar.
Na prática você recebe duplicata quando:
- seu handler passou do timeout (normalmente 10s) mas terminou o trabalho
- deu um 500 depois de já ter escrito no banco
- o gateway reprocessou uma fila interna
- alguém clicou em "reenviar" no painel pra debugar
A solução não é if not exists
A primeira tentativa de todo mundo é essa:
const existing = await db.query.sales.findFirst({
where: eq(sales.externalId, event.id),
})
if (!existing) {
await db.insert(sales).values(mapEvent(event))
}Isso reduz o problema, não resolve. Entre o findFirst e o insert existe uma janela de alguns
milissegundos. Duas entregas simultâneas do mesmo evento, que é exatamente o que acontece quando o
gateway faz retry agressivo, passam as duas pelo if, e as duas inserem.
É uma race condition clássica. Ela é rara o suficiente pra nunca aparecer em teste e comum o suficiente pra aparecer em produção.
O banco resolve isso melhor que você
A garantia tem que estar onde a concorrência é resolvida de verdade: numa constraint.
create unique index sales_provider_external_id_idx
on sales (provider, external_id);E o insert vira um upsert:
await db
.insert(sales)
.values(mapEvent(event))
.onConflictDoUpdate({
target: [sales.provider, sales.externalId],
set: { status: event.status, raw: event },
})Agora, se dois requests chegarem no mesmo microssegundo, um ganha o insert e o outro cai no update. O resultado final é o mesmo nos dois casos, que é literalmente a definição de idempotência.
Repare que o índice é composto: (provider, external_id). O id 12345 do Kiwify e o 12345 do
Stripe são eventos diferentes. Sem o provider na chave, adicionar um segundo gateway no futuro
quebra o passado.
Buscar antes de inserir
- Janela de milissegundos entre o SELECT e o INSERT
- Duas entregas simultâneas passam as duas pelo if
- Rara em teste, comum em produção
Unique index e upsert
- O banco decide quem ganha, sem janela
- Um insere, o outro cai no update
- Mesmo resultado final: idempotência
Guarde o evento cru, sempre
Separado da tabela de negócio, tenha uma tabela burra que só registra o que chegou:
await db.insert(webhookEvents).values({
provider: "kiwify",
externalId: event.id,
signatureOk: true,
payload: event,
})Parece redundante. Não é. Quando o faturamento não bater (e uma hora não vai bater), essa tabela é a única fonte que te diz o que o gateway realmente mandou, em vez do que o seu parser entendeu. Já me salvou de discussões com suporte mais de uma vez.
| Tabela | Serve pra | Pode ser reconstruída? |
|---|---|---|
webhook_events | auditoria, replay, debug | não, é a fonte |
sales | relatório, dashboard, consulta | sim, a partir da outra |
Essa assimetria é o ponto. Se você errar o mapeamento de um campo, dá pra reprocessar webhook_events
e regenerar sales. O contrário não existe.
Verifique a assinatura antes de fazer o parse
Detalhe fácil de errar: a ordem importa.
export async function POST(request: Request) {
const raw = await request.text() // texto cru, não .json()
if (!verifySignature(raw, request.headers)) {
return new Response("invalid signature", { status: 401 })
}
const event = eventSchema.parse(JSON.parse(raw))
// ...
}A assinatura é um HMAC do corpo exato que foi enviado. Se você fizer await request.json() e
depois JSON.stringify de volta pra conferir, a ordem das chaves e o espaçamento podem mudar, o
HMAC não bate, e você vai passar uma tarde achando que o segredo está errado.
E request.text() só pode ser chamado uma vez por request. Leia o cru, valide, e só então parseie.
Responda rápido, processe depois
Se o processamento for pesado (mandar e-mail, chamar outra API, gerar PDF), não faça isso dentro do handler. Grave o evento, devolva 200, e deixe o trabalho pra uma fila ou um cron.
O gateway está cronometrando você. Um handler que demora 12 segundos vira um retry, que vira uma duplicata, que vira aquele relatório que não bate.
Cinco linhas de checklist que separam um webhook que funciona na demo de um que funciona em dezembro, na Black Friday, com o gateway fazendo retry em cima de você.
Leia depois disto
- EngenhariaPasso 8Migração de esquema sem downtimePor que renomear coluna quebra produção, e o padrão de quatro passos que transforma migração em tarefa de terça-feira.Ler artigo
- EngenhariaPasso 7Os cinco padrões de resiliência que evitam queda em cascataComo uma dependência secundária lenta derruba o sistema inteiro em noventa segundos, e os cinco padrões que impedem isso.Ler artigo
- EngenhariaPasso 6Saga e outbox: transação sem transação distribuídaVocê precisa debitar numa conta e creditar em outra, e elas estão em bancos diferentes. Os dois padrões que resolvem isso na prática, e o que evitar.Ler artigo