Stripe webhooks best practices come down to five rules: verify every request's signature against the raw body, record the event ID and return a 2xx response quickly, do the real work asynchronously in a worker, make every handler safe to run twice, and never trust delivery order. Stripe delivers events at least once, retries failed deliveries for up to three days in live mode, and does not guarantee ordering, so your endpoint has to absorb duplicates, late arrivals and gaps without corrupting your data.

This guide explains how each rule works in practice, with a Node.js example, a deduplication table, the difference between idempotency keys and event deduplication, how to recover missed events, and a production checklist. It is written for engineering leads who own a Stripe integration that has started to drift: orders marked unpaid after a successful charge, access granted twice, or support tickets that only make sense once someone reads the event log.

What does a production Stripe webhook endpoint need to do?

A webhook endpoint is an HTTPS URL on your server that Stripe calls with a JSON Event object whenever something happens in your account: a payment succeeds, an invoice is finalized, a subscription changes status, a dispute opens. Stripe pushes these events because much of what matters in payments happens asynchronously. A bank confirms a debit hours later, a renewal charges at 3 a.m., a customer disputes a charge weeks after the fact. If your application only learns about payments from the browser redirect after checkout, it will miss most of them. Stripe's own Checkout fulfillment guide says plainly that you cannot rely on the landing page alone, because the customer may never reach it.

The endpoint itself has a narrow job. It should do four things, in this order, and nothing else on the request path:

  1. Read the raw request body and the Stripe-Signature header, and verify the signature with the endpoint's signing secret. Reject anything that fails with a 400.
  2. Record the event's ID in durable storage under a uniqueness constraint, so a second delivery of the same event is recognized.
  3. Hand the work to a queue or background job, ideally in the same database transaction as the event record.
  4. Return a 2xx status code. Stripe treats anything else, including redirects, as a failed delivery and will retry.

Everything else (updating orders, provisioning access, sending email, writing to your accounting system) belongs in a worker that can take its time, retry its own failures and be run again safely. Stripe's documentation is explicit that the endpoint must return a 2xx before any complex logic that might cause a timeout, and it recommends processing events through an asynchronous queue so a spike in deliveries, such as the start of the month when many subscriptions renew, does not overwhelm your hosts (Stripe webhooks guide).

Two design choices sit upstream of the code. First, subscribe the endpoint only to the event types you actually handle; listening to everything adds load and noise without adding safety. Second, decide whether you consume snapshot events, which carry a copy of the object as it was when the event was created, or thin events, which carry a reference you fetch on demand. Stripe now offers both formats for event destinations. The patterns in this post apply to both; the examples use snapshot events because that is what most existing integrations receive.

Illustrative: the request path does milliseconds of work; everything slow runs in the worker.

How do you verify a Stripe webhook signature?

Stripe signs every event it sends with an HMAC-SHA256 signature computed over a timestamp and the exact bytes of the request body, using a secret unique to each endpoint. The signature arrives in the Stripe-Signature header alongside a t= timestamp. Verifying it proves two things: the request came from Stripe, and nobody altered it in transit. Without that check, anyone who discovers your webhook URL can post a fake invoice.paid event and unlock a paid plan.

The most common verification failure is not a wrong secret but a modified body. Many frameworks parse JSON before your handler runs, and once the payload has been parsed and re-serialized (keys reordered, whitespace stripped, encoding changed) the bytes no longer match what Stripe signed. Stripe's troubleshooting guide calls this out directly: in Express, the webhook route needs the raw body, and express.json() must not run before it. In Next.js route handlers you read request.text() rather than request.json(); in Laravel or Django you read the raw request content and exempt the route from CSRF middleware.

Express: raw body, constructEvent, then store and acknowledge
import express from "express";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; // whsec_...
const app = express();

// Raw body on this route only. Register it before express.json().
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], endpointSecret);
  } catch (err) {
    return res.status(400).send("Invalid signature");
  }
  try {
    await recordAndEnqueue(event); // unique insert + job, one transaction
  } catch (err) {
    return res.sendStatus(500); // not stored: let Stripe retry
  }
  res.sendStatus(200); // includes duplicates and ignored types
});

app.use(express.json()); // JSON parsing for every other route

A few details in that snippet matter more than they look:

  • The secret is per endpoint and per mode. The whsec_ secret for your live endpoint differs from your test endpoint's, and both differ from the one the Stripe CLI prints when it forwards events locally. Mixing them up produces signature errors that look like a code bug.
  • The library enforces a timestamp tolerance. Stripe's official libraries reject events whose signed timestamp is more than five minutes from your server's clock by default, which blunts replay attacks. Keep server clocks synced with NTP, and never set the tolerance to zero; Stripe's docs note that a value of 0 disables the recency check entirely.
  • A failed verification returns 400 and stores nothing. Do not write the payload of an unverified request into tables or logs that other jobs read from.
  • A storage failure returns 500. If you cannot durably record the event, you want Stripe to retry. Returning 200 at that point acknowledges an event you have just lost.

Signature verification can be complemented by restricting the route to Stripe's published webhook IP addresses at your load balancer or firewall. Treat that as defense in depth, not a substitute: IP lists change, and the signature is what actually authenticates the payload.

Why should the endpoint return 2xx quickly and process asynchronously?

Stripe waits only a limited time for your response. If your handler calls three internal services, sends an email and recalculates usage before it answers, a slow dependency turns into a timed-out delivery, and a timed-out delivery is retried while your first attempt may still be finishing. That is how a single slow database query becomes a duplicate fulfillment.

Separating receipt from processing fixes this structurally. The endpoint does a few milliseconds of work: verify, insert, enqueue, respond. The worker picks the job up, loads current state, applies the change and records the result. If the worker fails, it retries on your schedule rather than Stripe's, and the failure shows up in your job dashboard instead of as a red row in the endpoint's delivery log.

There is a Stripe-specific reason to keep invoice.created fast in particular. For subscriptions with automatic collection, Stripe waits for a successful response to invoice.created before it finalizes the invoice; if it does not receive one, finalization of automatically collected invoices can be delayed for up to 72 hours (subscription webhooks guide). A slow or failing endpoint can therefore delay your customers' renewals, not just your internal bookkeeping.

Checkout has a similar interaction. When you set a success URL and listen for checkout.session.completed, Stripe's docs say Checkout waits up to 10 seconds for your server to respond to that delivery before redirecting the customer. A fast endpoint keeps the redirect quick; a slow one leaves the customer looking at a spinner.

What the queue gives you

  • Back-pressure. Renewal spikes are absorbed by the queue, and workers drain it at a rate your database can sustain.
  • Retries you control. Exponential backoff, a dead-letter queue after a fixed number of attempts, and an alert when anything lands there.
  • Ordering where you need it. You can partition jobs by Stripe object ID (subscription, customer or invoice) so changes to the same object are processed one at a time.
  • Replay. Because every event ID is stored, you can re-run processing for a time window after fixing a bug.

The queue does not need to be elaborate. A jobs table polled by a worker, a managed queue service or your framework's job runner all work. What matters is that enqueueing happens in the same transaction as the event record (the transactional outbox pattern), so you never end up with an event marked as received but no job to process it, or a job with no record behind it.

At-least-once delivery and Stripe webhook idempotency

Stripe delivers events at least once, not exactly once. Its documentation states that endpoints might occasionally receive the same event more than once, and recommends logging the event IDs you have processed and skipping ones already logged (best practices). Duplicates come from ordinary causes: your server processed the event but the response was lost, the response arrived after Stripe's timeout, someone clicked Resend in the Dashboard, or a recovery script replayed a window that the automatic retries also covered.

Stripe webhook idempotency means that processing the same event twice leaves your system in the same state as processing it once. There are two layers to it, and a production integration needs both.

Layer 1: deduplicate on the event ID

Every Event has a unique ID beginning with evt_. Store it in a table with a primary key or unique constraint and let the database reject the second insert. Do not use the event's created timestamp for this. Stripe records it in seconds, and its docs warn that distinct events can share a timestamp.

Deduplicate on event ID and enqueue in one transaction (PostgreSQL)
// stripe_events(id text PRIMARY KEY, type text, object_id text,
//   received_at timestamptz DEFAULT now(), processed_at timestamptz)
export async function recordAndEnqueue(event) {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    const { rowCount } = await client.query(
      "INSERT INTO stripe_events (id, type, object_id) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING",
      [event.id, event.type, event.data.object.id]
    );
    if (rowCount === 1) {
      // First time we have seen this event: queue it in the same transaction.
      await client.query("INSERT INTO jobs (kind, ref) VALUES ('stripe_event', $1)", [event.id]);
    }
    await client.query("COMMIT");
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}

The insert-or-ignore pattern is race-safe: if two deliveries of the same event reach two servers at the same moment, the unique constraint lets exactly one of them through. A check-then-insert in application code (SELECT, then INSERT if missing) is not race-safe and will occasionally let both through under load.

Layer 2: make the side effect itself idempotent

Deduplicating at the endpoint guarantees each event is enqueued once. It does not guarantee the job runs once: a worker can crash after granting access but before marking the job done, and the job will run again. The business logic needs its own guard. Practical options:

  • Write state, not deltas. Set the subscription status and period end to absolute values instead of incrementing a counter or appending a credit.
  • Key side effects on the Stripe object. A fulfillment row keyed on the invoice ID or Checkout Session ID can only exist once. Stripe's fulfillment guide asks you to make the fulfillment function safe to call multiple times, even concurrently, for the same session.
  • Commit the marker with the change. Record processed_at in the same transaction as the state change, so a crash either commits both or neither.
  • Guard external effects. For email and third-party calls, store a sent marker keyed on the event or object before sending, and accept that at-least-once email is usually better than none.

Stripe also documents a subtler case: occasionally two separate Event objects are generated for the same underlying change. Event-ID deduplication will not catch these because the IDs differ. Stripe's suggestion is to combine the ID of the object in data.object with the event type. In practice, state-based handlers from layer 2 make this harmless, because applying the same subscription state twice changes nothing.

Idempotency keys vs event deduplication: what is the difference?

Teams often hear “make it idempotent” and reach for Stripe's Idempotency-Key header on the webhook path, where it does nothing. The two mechanisms protect opposite directions of traffic.

Stripe's idempotent requests reference describes the outgoing side. For the v1 API, all POST requests accept an idempotency key; GET and DELETE requests are idempotent by definition, so sending a key there has no effect. Keys can be up to 255 characters, Stripe suggests V4 UUIDs or similar high-entropy strings, and keys may be pruned once they are at least 24 hours old, after which reuse is treated as a new request. Stripe saves the status code and body of the first request once execution begins and returns that saved response to retries, including 500 errors, and it returns an error if the same key is reused with different parameters.

The two meet inside webhook workers. Suppose your handler for invoice.paid creates a credit note or a transfer in Stripe. If the job is retried, that outgoing call could run twice. Derive the idempotency key from the triggering event, for example the event ID plus the action name, so a re-run of the same job sends the same key and Stripe returns the original result. Do not generate a fresh random UUID inside the retry loop; that defeats the point. And remember the 24-hour pruning window: a job replayed days later needs your own database guard, not just the key.

How should you handle out-of-order Stripe webhook events?

Stripe does not guarantee that events arrive in the order they were generated. Its docs give the example of creating a subscription, which can produce customer.subscription.created, invoice.created, invoice.paid and charge.created in any arrival order. Retries make it worse: an event that failed its first delivery may arrive minutes or hours after a later event that succeeded on the first attempt.

Illustrative: two updates to the same subscription, delivered late and twice.

The failure mode is easy to reproduce. A subscription goes past_due when a renewal fails, then active when the customer updates their card and the retry succeeds. Two customer.subscription.updated events are generated: A (past_due) and B (active). B is delivered first; A's first delivery times out and its retry arrives later. A handler that copies data.object.status into your database ends with past_due, and a paying customer is locked out.

There are three reliable ways to handle this, from simplest to most involved:

  1. Re-fetch the object. Treat the event as a notification that something changed, retrieve the current subscription, invoice or PaymentIntent from the API, and write that. Stripe's docs suggest using the API to retrieve missing objects, and thin events are built around this model. It costs one API call per event and makes ordering irrelevant for that object.
  2. Compare against stored state. Store a version marker with each record, such as the time of the last event applied or the invoice's status transition times, and ignore events that would move the record backwards. This avoids the API call but requires you to define what “newer” means for each object type, and second-level timestamps can tie.
  3. Serialize per object. Route all jobs for the same subscription or customer to one partition so they are processed one at a time, and combine that with one of the approaches above. Serialization alone does not fix ordering, because the queue receives events in the wrong order to begin with.

For most subscription and payment state, re-fetching is the right default: it is simple, correct and easy to reason about in code review. Use stored-state comparison where API call volume is a real constraint, and document the comparison rule next to the handler. Whichever you choose, make status transitions explicit in your own model, listing which states can move to which, so an unexpected backwards move is logged rather than silently applied.

Also expect events about related objects to arrive before the object your code assumes exists. If invoice.paid arrives before you have recorded the customer from checkout.session.completed, the handler should fetch what it needs (the invoice, its subscription, the customer) instead of failing because a local row is missing. Failing and retrying also works, but fetching is faster and does not depend on the other event turning up.

What happens when your webhook endpoint fails?

When a delivery does not get a 2xx (connection refused, TLS error, timeout, 4xx, 5xx or a redirect), Stripe marks it failed and retries. According to Stripe's webhooks documentation, in live mode Stripe attempts delivery for up to three days with exponential backoff; for events created in a sandbox it retries three times over a few hours. Each attempt, its status code and the next scheduled retry appear in the endpoint's Event deliveries tab in Workbench.

Several details of that behavior shape how you design around it:

  • Every retry is freshly signed. Stripe generates a new timestamp and signature for each attempt, so a retry arriving hours later still passes the five-minute tolerance check.
  • Manual resends do not cancel automatic retries. Clicking Resend in the Dashboard (available for up to 15 days after the event was created) or running stripe events resend with the CLI (up to 30 days) delivers the event again, but Stripe's scheduled retries continue even if the manual attempt succeeds. Your deduplication absorbs the overlap.
  • Disabled or deleted endpoints stop retries. If an endpoint is disabled or deleted when a retry is due, Stripe stops retrying that event to it.
  • Failures can hold up billing. For invoice.created on subscriptions with automatic collection, Stripe's subscription guide says it will not attempt to charge the customer while it waits for a successful response, and that it emails you when the webhook is failing.

The practical rule: return 500 when you could not durably record the event, return 400 only when the request is invalid (bad signature, unparseable body), and return 200 for everything you have recorded, including event types you deliberately ignore and duplicates you have already processed. Returning an error for an event you do not care about buys you three days of retries and a noisy delivery log.

A three-day retry window is generous, but it is not a delivery guarantee. An outage longer than that, a misconfigured endpoint, a deploy that broke the route, or a bug that returned 200 while dropping the job will all lose events. That is why a production integration needs a reconciliation path that does not depend on webhooks at all. And if the failures you are chasing are customers' renewal payments rather than your endpoint, that is a different problem, covered in our guide to failed subscription payments, retries and dunning.

How do you recover missed Stripe webhook events?

A reconciliation job is a scheduled process that asks Stripe what changed, compares it with what your database believes, and repairs the difference using the same handlers your webhook worker uses. It turns “we hope we received every event” into “we will notice within the hour if we did not.”

Illustrative: reconciliation reuses the webhook handlers, so it cannot double-apply anything.

Stripe gives you two inputs for this. The first is the List Events API, which returns events created in the last 30 days. Stripe's guidance for recovering after an outage is to list events with ending_before set to the last event you know was delivered, filtered to the types your endpoint handles, with delivery_success=false so only events that failed delivery to at least one endpoint come back; auto-paginating with ending_before returns them in chronological order. The second input is the objects themselves: listing subscriptions, invoices or charges and comparing them field by field with your records. Object listing is the only option for gaps older than 30 days.

A reconciliation design that holds up in production usually has these parts:

  • A cursor. Store the last event ID or time the job has checked, so each run covers new ground and a missed run is caught up by the next.
  • Shared handlers. Feed recovered events through the same enqueue-and-process path as live webhooks. The deduplication table makes it safe for the reconciler and Stripe's own retries to overlap, which Stripe's recovery guide specifically warns about.
  • A drift report. For objects where state matters (active subscriptions, paid invoices, fulfilled orders), compare counts and statuses between Stripe and your database, and record every repair with what changed and why.
  • Alert thresholds. One repaired record a week is noise; fifty in an hour means the webhook path is broken and someone should look now.
  • A run after every incident. After an outage, a bad deploy or a secret rotation that went wrong, trigger reconciliation over the affected window rather than waiting for the schedule.

Reconciliation is also the right tool for event-generation failures. Stripe documents a rare case in which it fails to create an Event at all and later sends a v2.core.health.event_generation_failure.resolved notification naming the affected object; the documented recovery is to fetch that object's current state from the API. A reconciler that already knows how to fetch and compare objects absorbs this without special code.

How should you rotate a Stripe webhook signing secret?

Stripe recommends rolling endpoint signing secrets periodically, and immediately if you suspect one has leaked. When you roll a secret in the Webhooks tab in Workbench, you can expire the old secret immediately or keep it valid for up to 24 hours. During that overlap, Stripe generates one signature per active secret, so the Stripe-Signature header carries more than one v1 signature and an event verifies if any of them matches your configured secret.

A zero-downtime rotation looks like this:

  1. Roll the secret with a delayed expiry, long enough to cover a full deploy.
  2. Put the new secret in your secret store and roll your servers. Because the old secret is still active, events verify on both old and new instances throughout the deploy.
  3. Watch your 400 responses and the endpoint's delivery log for signature failures, and confirm new deliveries succeed.
  4. Let the old secret expire, then confirm it is removed from every environment and CI configuration that held it.
  5. If anything failed during the window, run reconciliation over it rather than relying on retries alone.

If your servers load the secret only at boot, step 2 is a deploy; if they read it from a secret manager at runtime, it can be a configuration change. Either way, never commit whsec_ values to the repository, and keep live and test secrets in separate variables so a test secret can never verify live traffic.

Rotation is a good moment to review the rest of the endpoint's posture: HTTPS with a valid certificate (Stripe requires HTTPS for live-mode endpoints and supports TLS 1.2 and 1.3), a CSRF exemption scoped to this one route rather than a whole path prefix, and request size limits generous enough not to reject legitimate payloads.

How do you test Stripe webhook events with the Stripe CLI?

Webhook bugs hide in the paths nobody exercises by clicking through checkout once. A useful test plan covers delivery mechanics as well as business logic.

Local development. Run stripe listen --forward-to localhost:4242/webhooks/stripe to forward events from your test environment to a local server; the CLI prints a whsec_ secret for that session, which your local configuration should use. Then run stripe trigger with an event type, such as stripe trigger payment_intent.succeeded, to create the objects and events a real flow would. Stripe's webhooks guide covers both commands.

Delivery mechanics. Test what happens when things go wrong, not only when they go right:

  • Send the same event twice with stripe events resend and assert the second run changes nothing.
  • Deliver two updates for the same object in reverse order and assert the final state matches the API.
  • Make the worker throw on its first attempt and confirm the job retries and the event is applied once.
  • Alter the body or use the wrong secret and confirm a 400 with nothing stored.
  • Take the endpoint down, generate events, bring it back and run the reconciler over the gap.

Automated tests. Most of this can run in CI without the network. Stripe's Node library, for example, exposes a helper to generate a valid test signature header for a given payload and secret, so you can exercise the verification path end to end. Save real event payloads from your test environment as fixtures, and write handler tests that apply them twice and out of order.

Time-based flows. Renewals, trial endings and retry schedules unfold over weeks. Stripe's test clocks, shown as Simulations in the Dashboard, let you advance time for a test customer and watch the resulting events. Our Stripe subscription webhook events guide walks through a full transition matrix.

One environmental note: retry behavior in a sandbox differs from live mode (three retries over a few hours rather than up to three days). Do not tune alert thresholds or recovery runbooks on sandbox retry patterns.

Monitoring, alerting and a production checklist

A webhook integration fails quietly. Stripe retries for days and the Dashboard shows red rows, but nobody looks at the Dashboard until a customer complains. Put the signals where your team already looks.

  • Endpoint health: rate of non-2xx responses by status code, signature-failure count, and p95 response time. A jump in 400s after a deploy usually means the raw body broke; a jump in 500s means storage or the queue is failing.
  • Pipeline health: queue depth, age of the oldest unprocessed job, dead-letter count, and the gap between received_at and processed_at in your events table.
  • Business health: reconciler repairs per run, the number of subscriptions whose status in your database differs from Stripe's, and paid invoices without a matching fulfillment record.
  • Stripe-side signals: the endpoint's Event deliveries tab shows failed and pending deliveries, and Stripe can email you when a webhook is failing. Make sure those emails reach a monitored address, not a former employee's inbox.

Log every event with its ID, type, related object ID, outcome and processing time, and make those logs searchable by object ID. When support asks why a customer lost access, the answer should be one query away.

From here, this article is about Cevrynt

How Cevrynt approaches Stripe webhook work

Cevrynt is a Stripe partner, a member of the Stripe Partner Ecosystem, and our engineering services team builds and repairs Stripe integrations inside clients' own repositories and Stripe accounts. The work is led by Arindam Pradhan, a Stripe Certified Professional (Implementation Architect), with senior engineers who each have six or more years of production experience.

On webhook work we start with the event log, not the code: which events the account emits, which ones the endpoint receives, where deliveries fail, and where your database disagrees with Stripe. The deliverable is usually the pattern in this post (verified, idempotent handlers, a queue, a reconciler and alerts) plus test-mode evidence for duplicate, late and failed events. For payment flows that sits within our Stripe payments work; for subscription state it is part of Stripe Billing. Every project is scoped and priced in writing after a technical review.

If your integration shows the symptoms described above, tell us what you are seeing or book a call, and we will say whether it needs a targeted fix or a broader rebuild.