The idempotent webhook: the bug that only shows up in production
Every gateway redelivers webhooks. If your handler is not idempotent, you will find out on the day the same sale gets counted three times.
You integrate the gateway, fire the test webhook, the sale shows up in the database, everything green. Two weeks later the revenue report does not match the bank statement, and the difference is always on the high side.
The reason is almost never a calculation bug. It is the same sale written twice.
Why the webhook arrives more than once
Every serious gateway (Stripe, Kiwify, Hotmart, Pagar.me) promises at-least-once delivery, never exactly-once. That is not laziness on their part, it is network physics: if their server sends the POST and yours takes 11 seconds to answer, they have no way of knowing whether you processed it and the response was lost, or whether you never got anything. The only safe choice is to resend.
In practice you get duplicates when:
- your handler blew past the timeout (usually 10s) but finished the work anyway
- it returned a 500 after it had already written to the database
- the gateway reprocessed an internal queue
- somebody clicked "resend" in the dashboard while debugging
The solution is not if not exists
Everybody's first attempt is this one:
const existing = await db.query.sales.findFirst({
where: eq(sales.externalId, event.id),
})
if (!existing) {
await db.insert(sales).values(mapEvent(event))
}That reduces the problem, it does not solve it. Between the findFirst and the insert there is a
window of a few milliseconds. Two simultaneous deliveries of the same event, which is exactly what
happens when the gateway retries aggressively, both pass the if, and both insert.
It is a classic race condition. Rare enough never to show up in testing and common enough to show up in production.
The database solves this better than you do
The guarantee has to live where concurrency is genuinely resolved: in a constraint.
create unique index sales_provider_external_id_idx
on sales (provider, external_id);And the insert becomes an upsert:
await db
.insert(sales)
.values(mapEvent(event))
.onConflictDoUpdate({
target: [sales.provider, sales.externalId],
set: { status: event.status, raw: event },
})Now, if two requests land in the same microsecond, one wins the insert and the other falls into the update. The end result is the same in both cases, which is literally the definition of idempotency.
Notice the index is composite: (provider, external_id). Kiwify's id 12345 and Stripe's 12345 are
different events. Without provider in the key, adding a second gateway in the future breaks the
past.
Store the raw event, always
Separate from the business table, keep a dumb table that only records what arrived:
await db.insert(webhookEvents).values({
provider: "kiwify",
externalId: event.id,
signatureOk: true,
payload: event,
})It looks redundant. It is not. When revenue does not add up (and one day it will not), that table is the only source that tells you what the gateway actually sent, rather than what your parser understood. It has saved me from arguments with support more than once.
| Table | Good for | Can it be rebuilt? |
|---|---|---|
webhook_events | auditing, replay, debugging | no, it is the source |
sales | reports, dashboard, queries | yes, from the other one |
That asymmetry is the point. If you get a field mapping wrong, you can reprocess webhook_events and
regenerate sales. The reverse does not exist.
Verify the signature before you parse
An easy detail to get wrong: order matters.
export async function POST(request: Request) {
const raw = await request.text() // raw text, not .json()
if (!verifySignature(raw, request.headers)) {
return new Response("invalid signature", { status: 401 })
}
const event = eventSchema.parse(JSON.parse(raw))
// ...
}The signature is an HMAC of the exact body that was sent. If you do await request.json() and
then JSON.stringify it back to check, key order and spacing can change, the HMAC does not match, and
you will spend an afternoon convinced the secret is wrong.
And request.text() can only be called once per request. Read the raw body, validate, and only then
parse.
Answer fast, process later
If the processing is heavy (sending an email, calling another API, generating a PDF), do not do it inside the handler. Store the event, return 200, and leave the work to a queue or a cron.
The gateway is timing you. A handler that takes 12 seconds becomes a retry, which becomes a duplicate, which becomes that report that does not add up.
Five lines of checklist that separate a webhook that works in the demo from one that works in December, on Black Friday, with the gateway retrying on top of you.