PaymentsJune 9, 2026 · 7 min

Payment webhooks the hard parts: idempotency, retries and reliability

Payment webhooks fail in production for predictable reasons: they're delivered at-least-once, can arrive out of order, and retry on any non-2xx. Make every handler verify, deduplicate, and stay idempotent — then do the slow work in a queue. Here's the pattern.

The mental model: at-least-once, not exactly-once

Every serious payment provider — Stripe, Trust Payments, Norbr, HiPay — delivers webhooks at-least-once. The same event can arrive twice; events can arrive out of order; and if your endpoint returns anything but a quick 2xx, the provider retries. If you treat a webhook as a one-time, in-order, exactly-once message, you will eventually double-charge, double-fulfil, or double-email someone.

Step 1 — Verify the signature

Always verify the provider's signature against the raw request body before doing anything. This is also why webhook routes need the unparsed body — verifying against a re-serialized JSON object will fail.

Step 2 — Deduplicate by event id

Record every processed event id. If you've seen it, acknowledge with 2xx and stop. This single table turns at-least-once delivery into effectively once-handled.

CREATE TABLE processed_events (
  id text PRIMARY KEY,            -- provider event id
  processed_at timestamptz DEFAULT now()
);

Step 3 — Make the handler idempotent

Even with dedup, design the effect so that running it twice is harmless: upsert instead of insert, check current state before transitioning, key external actions by the event id. Idempotency is a property of the handler, not just the dedup table.

async function onPaymentSucceeded(evt) {
  if (await seen(evt.id)) return ack();           // dedup
  await db.tx(async (t) => {
    const inv = await t.invoice(evt.data.invoiceId);
    if (inv.status === 'paid') return;            // idempotent guard
    await t.markPaid(inv.id, evt.id);
    await t.recordEvent(evt.id);
  });
  await queue.add('send-receipt', { invoiceId: evt.data.invoiceId });
}

Step 4 — Acknowledge fast, work async

Return 2xx as soon as you've durably recorded the event; push slow work (emails, PDFs, downstream API calls) to a queue (I use BullMQ on Redis). A slow webhook handler causes provider timeouts and a storm of retries — exactly what you don't want during a payment spike.

Reconciliation beats trust: webhooks can be missed entirely (downtime, misconfig). Periodically reconcile against the provider's API as a safety net rather than assuming every event arrived.

Key takeaways

  • Treat webhooks as at-least-once and possibly out of order.
  • Verify signature → dedupe by event id → idempotent handler → ack fast → queue the slow work.
  • Add periodic reconciliation; never assume every webhook was delivered.

FAQ

Why do I need both dedup and idempotency?

Dedup handles duplicate deliveries of the same event; idempotency protects against everything else — races, partial failures, replays — by making the effect safe to repeat. Defense in depth.

Where should slow webhook work go?

A queue. Acknowledge the webhook fast with a 2xx after durably recording it, then process emails, PDFs and downstream calls in a background worker with retries.

AA
Ali Asghar

Senior software engineer & technical lead — 6+ years shipping production multi-tenant SaaS, payments and AI integration in Next.js, Node & TypeScript.

Keep reading
Multi-tenant SaaS: RLS vs schema-per-tenant vs database-per-tenantHow I built a real-time, cookieless analytics console in Next.js