Webhook idempotente: o bug que só aparece em produção
- backend
- webhooks
- postgres
Publicado em por Gabriel Dias · 4 min de leitura · Engenharia
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.
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ê.
Um e-mail por semana, sem enrolação
O que eu aprendi construindo software e IA na semana — em texto curto, direto, sem newsletter de 3 mil palavras. Cancele quando quiser.