Skip to content
AIQGM

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:

TypeScriptts
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.

Tell us what you're working on.

A few paragraphs is plenty. We'll read it, reply by email, and if it looks like a fit, set up a 30-minute Google Meet with an engineer.