Webhooks arrive twice. Build for it.
Payment and messaging providers retry on timeouts, so the same event can show up more than once. Handling it takes one table and one transaction.
Most webhook providers guarantee at-least-once delivery. If your endpoint is slow or returns a 5xx, the provider retries, and sometimes the first attempt had already succeeded. The symptom is a customer charged once and credited twice.
The fix is to record the event ID in the same transaction as the side effect, with a unique constraint doing the work:
export async function POST(req: Request) {
const event = await verifySignature(req); // reject unsigned requests first
await db.transaction(async (tx) => {
const inserted = await tx
.insert(processedEvents)
.values({ id: event.id })
.onConflictDoNothing()
.returning();
if (inserted.length === 0) return; // seen it before; do nothing
await applyEvent(tx, event); // same transaction as the marker
});
return new Response(null, { status: 200 });
}If applyEvent throws, the marker rolls back with it and the retry gets a clean attempt. If the work is slow, the handler should only record the event and enqueue a job, then return 200 well inside the provider's timeout.
This comes from our web applications work.