Skip to content
Tech Stack15 June 2026 · 8 min readUpdated 30 September 2026

Webhook Reliability: Idempotency Keys and Dead-Letter Queues in 2026

Three layers every production SaaS webhook handler needs: signature verification, event-ID idempotency and a dead-letter queue with a working replay path.

Webhook Reliability: Idempotency Keys and Dead-Letter Queues in 2026

What you'll learn

  • What breaks without webhook idempotency?
  • Does signature verification actually matter?
  • How do you build webhook idempotency that doesn't race?
Article contents

Your webhook endpoint works in testing. Then a provider retries one delivery, your handler runs twice, and a customer is billed or provisioned twice. The first action is a two-minute check: open your handler and find the line that records the event ID. If it runs after the business write, or doesn't exist, that's the fix this post walks through. It's part of the SaaS Backend Infrastructure guide, and every webhook handler that touches money needs the same three layers: signature verification, idempotency keyed to the event ID, and a dead-letter queue with a replay path.

What breaks without webhook idempotency?

Two identical cyan cubic event packets approaching the same amber intake port in an isometric server junction, representing the risk of duplicate webhook delivery

Without idempotency, the first retry produces a second charge, a second subscription or a second user account, and providers do retry.

Stripe's webhook documentation (checked 30 September 2026) says it attempts delivery for up to three days with exponential backoff in live mode, and retries sandbox events three times over a few hours. That gap is why the bug hides in development and shows up in production. The same page says webhook endpoints might occasionally receive the same event more than once, and that Stripe doesn't guarantee delivery in generation order: creating a subscription can produce customer.subscription.created, invoice.created, invoice.paid and charge.created in any sequence. Stripe's advice is to log the event IDs you've processed and skip repeats. It adds that in some cases two separate Event objects are generated, and to spot those you compare the data.object ID together with the event.type.

Why does check-then-act fail?

Consider a handler that SELECTs for an existing event_id, finds nothing, provisions the subscription, then INSERTs the event as processed. Two retries arriving within milliseconds of each other can both SELECT before either INSERTs. Both proceed, and two subscriptions get created. That's a hypothetical, but it follows directly from the ordering of the steps.

The fix is to make the claim atomic. A processed_webhooks table with a unique constraint on event_id lets the database arbitrate:

INSERT INTO processed_webhooks (event_id, received_at)
VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING
RETURNING id;

The PostgreSQL INSERT documentation (version 18, checked 30 September 2026) says ON CONFLICT DO NOTHING avoids inserting the row instead of raising an error, and that only rows actually inserted are returned. So if RETURNING yields no rows, another delivery already claimed the event and you stop. This is the webhook idempotency key pattern in its simplest form: the event ID is the key and the database constraint is the lock.

Does signature verification actually matter?

An amber verification gate panel with a single glowing cyan circular sensor in an isometric data corridor, with a cream pipeline approaching from the left and an open path beyond

Yes, because without it anyone who knows your webhook URL can trigger your billing logic with fabricated payloads.

Stripe's documentation states the risk directly: without verification, an attacker could send fake events to fulfil orders, grant access or modify records. It recommends two protections together, IP allowlisting against its published address list and signature verification. The Stripe-Signature header carries a timestamp (t=) and one or more signatures, and the only valid live scheme is v1, an HMAC with SHA-256 over the timestamp, a dot and the raw JSON body, keyed with your endpoint secret. Stripe's libraries apply a default tolerance of five minutes between the timestamp and the current time, which blocks replayed payloads, and the docs warn that a tolerance of 0 disables the recency check entirely. Use the library helper (construct_event in Stripe's Ruby example) rather than hand-rolling it.

One subtlety costs people a day. Stripe requires the raw body for verification, and any manipulation of it makes verification fail. If your framework parses JSON globally before your handler runs, scope raw-body handling to the webhook route only. You can test locally with stripe listen --forward-to localhost:4242/webhook, and a body-parsing mismatch shows up on the first event.

How do you build webhook idempotency that doesn't race?

An isometric single-slot turnstile gate with amber pipelines converging from multiple directions toward a single glowing cyan opening, representing atomic mutual exclusion in event processing

Claim the event ID atomically before touching business state, then run the business write and the claim in one database transaction so they commit together or not at all.

Ordering matters more than almost any other detail. Hookdeck's idempotency guide puts it plainly: mark events as processed before executing side effects, because if you send an email first and mark second, a crash between the two steps means the retry sends the email again. The sequence I'd use:

  1. Verify the signature on the raw body. Return 400 on failure.
  2. Enqueue the raw event to a durable queue and return 200 immediately.
  3. In the worker, claim the event with INSERT ... ON CONFLICT DO NOTHING RETURNING.
  4. If no row comes back, the event is already handled. Acknowledge the message and stop.
  5. In the same transaction, run the business operation.
  6. Commit.

Step 2 follows Stripe's guidance to return a 2xx quickly before complex logic and to process from an asynchronous queue, since a spike such as all subscriptions renewing at the start of a month can overwhelm synchronous handlers. Steps 5 and 6 avoid the second bug: committing the business change and the idempotency record separately. A crash between two commits leaves the business operation done and the event unrecorded, so the next retry repeats it.

How long should you keep processed event IDs?

Longer than any path by which an old event can come back. Stripe retries automatically for up to three days, and its docs say a manual resend from the Dashboard works for up to 15 days after the event was created, and from the CLI for up to 30 days. My recommendation is to keep IDs for at least 30 days plus a margin, and to delete older rows with a scheduled cleanup job. On Postgres, including the Supabase and Stripe setup, a created_at index on the table keeps that cleanup cheap and avoids adding Redis.

What about events that arrive out of order?

Don't derive state from the expected sequence. Stripe says not to depend on receiving events in a specific order, and not to use the event's created value to decide order or whether you've processed an event, because it's recorded in seconds and distinct events can share a timestamp. Instead, use the event as a trigger and fetch the current object from the API. For example, if invoice.paid arrives first, retrieve the invoice, charge and subscription with the IDs it carries. The Stripe integration glossary entry covers the overall billing pattern, and the Stripe and Supabase subscription pattern shows it in a Postgres schema.

What does a dead-letter queue need, and how should you retry?

A dead-letter queue needs four things: the original payload, the error with its stack trace, a retry count, and a replay path your on-call person can actually reach.

A DLQ is where an event goes after the worker exhausts its retry budget. The alternatives are silently dropping it or blocking the worker forever, and silently dropping a Stripe webhook for invoice.payment_succeeded isn't a decision you want made by accident.

PropertyMain queueDead-letter queue
Retention (example)4 days14 days
Auto-retryYes, with backoffNo, manual replay only
Alert triggerRepeated failureFirst entry
Stored contextEvent payloadPayload, error, retry count, timestamps

Those retention numbers aren't arbitrary. The Amazon SQS developer guide (AWS documentation, checked 2026) lists a default message retention of 4 days and a maximum of 1,209,600 seconds, which is 14 days. Its dead-letter queue page adds that a message's expiry is based on its original enqueue timestamp, so a message that spent a day in the source queue has one less day in a 4-day DLQ, and that you should set the DLQ retention longer than the source queue's. Hookdeck's dead-letter queue guide gives the same 14 versus 4 days as an example. Treat them as starting points for your own queue, not a standard.

What's a sensible webhook retry policy?

Backoff with jitter, a hard cap on attempts, then the DLQ. Backoff stops you hammering a recovering dependency, jitter stops every worker retrying on the same tick, and the cap stops a permanently broken event from looping. On SQS the cap is the maxReceiveCount in the redrive policy, and AWS notes that setting it too low, such as 1, moves a message to the DLQ after one failure. The provider side is separate: Stripe already retries deliveries to you, so your own retries apply to the work inside your worker.

How should replay and alerting work?

Replay in batches with pauses between them. Releasing 500 backed-up events at once after an outage creates a thundering herd against your own database, so watch DLQ depth fall instead of queue depth spike. For alerts, I'd page on the first DLQ entry for financial event types such as invoice.payment_succeeded and use a threshold for the rest. That's a recommendation, not a documented rule.

What should you monitor per provider?

Index your processed_webhooks table by event_id and event_type, then track failure rate by event type over 24 hours, DLQ inflow per hour, the age of the oldest unprocessed DLQ event (your real SLA clock) and the share of events processed within 30 seconds of receipt. Set separate thresholds for financial and data events, since a failed invoice.payment_succeeded isn't the same severity as a failed customer.updated. Callidus, the React, Firebase and Stripe Connect clinic SaaS I built, handles twenty Stripe webhook events end to end, including customer.subscription.trial_will_end and invoice.payment_action_required with 3D Secure escalation. With that many event types, knowing which one is failing matters more than knowing something failed. BookBed has its own Stripe webhook handlers and trial lifecycle wired, though subscriptions aren't yet activated for paid traffic. The telemetry side is covered in observability for SaaS.

Open your handler now and answer one question: what does it write first, the event ID or the side effect? If it's the side effect, move the claim in front of it and put both in one transaction.

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

What is webhook idempotency and why does it matter?

Webhook idempotency means processing the same event once, however many times it's delivered. It matters because providers deliver at least once: Stripe's documentation says it retries for up to three days in live mode and that endpoints might occasionally receive the same event more than once. Without idempotency, a retry can create a second charge, subscription or account. The usual approach is to record each event ID in a table with a unique constraint and skip events you've already claimed, inside the same transaction as the business change.

How do I prevent duplicate webhook processing with Postgres?

Claim each event atomically with INSERT INTO processed_webhooks (event_id) VALUES ($1) ON CONFLICT (event_id) DO NOTHING RETURNING id before any business logic runs. The PostgreSQL documentation says ON CONFLICT DO NOTHING skips the insert instead of raising an error and that only inserted rows are returned, so an empty result means another delivery already claimed the event. Run the business change in the same transaction as the claim so both commit together or not at all, and keep processed IDs longer than the longest replay window you expect.

What is a Stripe webhook idempotency key?

For webhooks, the idempotency key is the event ID in the Stripe Event object, which you store and check before acting. Stripe recommends logging the event IDs you've processed and not processing events already logged. In some cases two separate Event objects are generated for the same underlying change, so Stripe suggests comparing the ID of the object in data.object together with the event type. Don't use the created timestamp to detect duplicates or order, because it's recorded in seconds and distinct events can share it.

What does a dead-letter queue do for webhook reliability?

A dead-letter queue holds events that fail after every retry, with the original payload, the final error, the retry count and timestamps, so nothing is silently lost. Once you fix the cause, you replay events in controlled batches instead of all at once. Retention should exceed your main queue's: Amazon SQS defaults to 4 days and allows up to 14, and its guidance is to set the dead-letter queue's retention longer than the source queue's. Alert on the first entry for financial event types.

What should a webhook retry policy include?

A webhook retry policy should include exponential backoff, jitter, a hard cap on attempts and a dead-letter queue for events that exhaust the cap. Backoff avoids hammering a recovering dependency, jitter spreads retries out, and the cap stops a permanently broken event from looping. The provider's own retries are separate: Stripe retries deliveries to your endpoint for up to three days in live mode, so your retry policy covers the processing work inside your own worker after you've returned a 2xx.