When you see Stripe webhook signature verification failed, one of the three inputs to the check is wrong: the request body is not the exact bytes Stripe sent, the signing secret is not the one for this endpoint and mode, or the `Stripe-Signature` header was not passed through. In most production incidents the cause is a body parser that ran before verification. Fix the input, and the error goes away.
This guide is a troubleshooting playbook, organised error by error. For each problem it gives the symptom you see, the cause, the fix and a test that proves the fix. It covers raw-body handling in Express, Next.js, NestJS, Laravel and Django, wrong and mixed-up secrets, clock skew, proxies and redirects, 4xx and 5xx responses, timeouts, Stripe's retry schedule and endpoint disabling, events that never arrive, ordering, thin versus snapshot events, and replaying events with the Stripe CLI. If you are designing a webhook endpoint from scratch, read Stripe webhooks best practices first; this post assumes you have an endpoint and something is going wrong with it. Every Stripe behavior here was checked against Stripe's webhook documentation in October 2026.
Why does Stripe webhook signature verification fail?
Verification fails because the signature Stripe computed over the timestamp and the exact request body does not match the one your code computes. Anything that changes a single byte of the body, or any difference in the secret, produces a mismatch.
Stripe signs every delivery with an HMAC using SHA-256. The `Stripe-Signature` header carries a timestamp prefixed `t=` and one or more signatures prefixed with a scheme, such as `v1=`. Stripe builds the signed string by joining the timestamp, a dot and the raw JSON body, then signs it with your endpoint's secret. Your library repeats that calculation and compares the results in constant time. Stripe's documentation says only `v1` is a valid live scheme; test events also carry a fake `v0` signature, which your code should ignore.
That design has a consequence developers often miss: the signature covers bytes, not meaning. A JSON object that is parsed and serialized again can be semantically identical and still fail, because key order, whitespace, number formatting or Unicode escaping changed. Stripe's guidance is blunt: verification needs the raw body, and any manipulation of it causes verification to fail.
In the stripe-node library, the call is `stripe.webhooks.constructEvent(payload, signature, secret)` for snapshot events, or `parseEventNotification()` for thin events. When one of the inputs is wrong, Stripe's signature troubleshooting guide shows the message most teams search for:
A practical way to localise the fault is to log three things at the top of the webhook route, before any verification: the length of the body your code received, the first 20 characters of the `Stripe-Signature` header, and the last four characters of the secret your process loaded. Never log the full secret. If the body arrives as an object rather than a string or buffer, a parser got there first. If the header is empty, something stripped it. If the secret suffix does not match the endpoint in Workbench, you have found the problem without reading any more code.
How do you fix "No signatures found matching the expected signature for payload"?
Pass the unparsed request body to the verification call, exactly as it arrived, and make sure no middleware reads or rewrites it first. The fix is framework-specific, because each framework parses JSON in a different place.
The test for every framework below is the same: run `stripe listen --forward-to` against your local route, set the secret it prints, trigger an event, and confirm the CLI shows a `200` next to the event. Then deploy and confirm the next delivery in Workbench shows `200` as well.
Next.js App Router
Symptom: verification fails only in the route handler, often after someone refactored to `await request.json()`. Cause: `request.json()` parses the body, and passing `JSON.stringify(body)` to the library produces different bytes. Fix: read the body once with `request.text()` and verify that string, which is the approach in stripe-node's own Next.js example linked from Stripe's troubleshooting table. Route handlers do not pre-parse bodies, so no config flag is needed.
// app/api/webhooks/stripe/route.js (Next.js App Router)
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(request) {
// Read the body as text. Never call request.json() before verifying.
const payload = await request.text();
const signature = request.headers.get("stripe-signature");
let event;
try {
event = stripe.webhooks.constructEvent(
payload,
signature,
process.env.STRIPE_WEBHOOK_SECRET, // this endpoint's whsec_ value
);
} catch (err) {
console.error("Stripe webhook verification failed:", err.message);
return Response.json({ error: "invalid signature" }, { status: 400 });
}
// Record the event ID and hand the work to a queue; reply quickly.
await recordAndEnqueue(event);
return Response.json({ received: true }, { status: 200 });
}Two Next.js details catch teams out. First, a `proxy.js` file (the Next.js 16 name for what used to be middleware) that rewrites, redirects or adds authentication to `/api/*` will also run on your webhook path unless its matcher excludes it. Second, the Pages Router behaves differently: API routes parse JSON by default, so Stripe's guidance is to disable `bodyParser` in the route's `config` export and read the raw buffer yourself.
Express
Symptom: `req.body` is an object, and verification always fails. Cause: `app.use(express.json())` was registered before the webhook route. Express runs middleware in order, so the JSON parser consumed and replaced the body before your handler saw it. Fix: register the webhook route first with `express.raw({ type: "application/json" })`, and register `express.json()` for everything else afterwards, which is the order Stripe's documentation prescribes.
import express from "express";
import Stripe from "stripe";
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
// 1. The webhook route comes first, with a raw parser scoped to it alone.
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
(req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // a Buffer, byte for byte what Stripe sent
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET,
);
} catch (err) {
return res.status(400).send("Webhook Error: " + err.message);
}
queue.add("stripe-event", { id: event.id, type: event.type });
res.sendStatus(200);
},
);
// 2. JSON parsing for every other route is registered after the webhook.
app.use(express.json());Other global middleware can do the same: request loggers that read and re-emit the body, or a shared `body-parser` setup. Exclude the webhook path from them.
NestJS
Symptom: `req.body` in the controller is already an object. Cause: Nest enables its JSON body parser by default. Fix: pass `rawBody: true` to `NestFactory.create()`, type the request as `RawBodyRequest`, and pass `req.rawBody`, a Buffer, to `constructEvent`. Keep the built-in body parser enabled; Nest's raw-body option depends on it. Global interceptors or pipes that transform the request will not affect `rawBody`, but a custom middleware that replaces the stream can.
Laravel
Symptom: a 419 status in Event deliveries, or a signature failure after code that rebuilds the payload from `$request->all()`. Cause: the CSRF middleware rejects Stripe's POST, or the payload passed to the library was reassembled from parsed input. Fix: verify `$request->getContent()`, the raw body, with the header from `$request->header('Stripe-Signature')`, and exempt the webhook path from CSRF verification in your application's middleware configuration (or serve it from the stateless API routes). Stripe's documentation names CSRF protection in frameworks like Rails and Django as something you may need to exempt; Laravel's CSRF middleware behaves the same way. If you use Laravel Cashier, its webhook route verifies signatures once the webhook secret is configured, so check that value first.
Django and Django REST Framework
Symptom: 403 responses from CSRF, or verification failures in a DRF view. Cause: Django's CSRF middleware rejects the POST, or the view passes `request.data`, which DRF has already parsed. Fix: decorate the view with `csrf_exempt`, read `request.body` (bytes), and take the header from `request.META["HTTP_STRIPE_SIGNATURE"]`. In DRF, read `request.body` before anything touches `request.data`, because the stream can only be read once.
Serverless gateways and other runtimes
API gateways can transform a body before your function runs. Stripe documents a mapping template for AWS API Gateway with Lambda that passes an escaped `rawBody` field alongside the parsed one; your function verifies `event.rawBody`. On any platform, check whether the gateway base64-encodes the body and whether your code decodes it back to the original bytes before verifying. A body that is decoded with the wrong character set fails in the same way as a re-serialized one.
Are you using the right Stripe webhook signing secret?
Each webhook endpoint has its own signing secret, and the secret differs between test and live mode even for the same URL. The Stripe CLI uses yet another secret. Using any secret other than the one for this endpoint, in this mode, fails every delivery.
Stripe's documentation is specific here: it generates a unique secret for each endpoint; if one endpoint serves both test and live keys, the secret is different for each; and if you have several endpoints, you need the secret for each one.
- Test secret in production. Symptom: everything worked in staging, every live delivery returns 400 after launch. Fix: create the live endpoint, reveal its secret in Workbench and deploy it to production configuration. Test: resend one live event from the Dashboard and watch for 200.
- CLI secret in a deployed environment. Symptom: local tests pass, the deployed endpoint fails. The secret printed by `stripe listen` belongs to the CLI session, not to any Dashboard endpoint, and Stripe warns not to mix them up. Fix: keep the CLI secret only in local configuration.
- One secret, several endpoints. Symptom: one endpoint works and another, pointing at the same code, fails. Fix: map each endpoint URL or path to its own secret, or consolidate to one endpoint.
- Connect endpoints. A destination that listens to connected accounts is a separate endpoint with its own secret from the one that listens to your own account. Platforms that run both commonly verify Connect deliveries with the platform endpoint's secret.
- A malformed value. Trailing newlines, quotes copied from a `.env` file, or a secret truncated by a deployment tool's variable limits all fail in the same way. Compare the last four characters and the length, not the full value.
Secret rotation without downtime
When you roll a secret in Workbench, you can expire the old one immediately or keep it active for up to 24 hours. During that window Stripe sends one signature per active secret, so the header carries more than one `v1` value. Official libraries handle that. A hand-written verifier that only checks the first `v1` value can fail half the time during a rotation. If you roll and expire immediately, every delivery fails until the new secret is deployed, and those failures enter the retry schedule described below. The best practices guide covers the rotation procedure itself.
Can clock skew, proxies or redirects break Stripe webhooks?
Yes. A server clock that is more than five minutes out fails the default tolerance check, a proxy or CDN that rewrites the body breaks the signature, and any 3xx redirect is recorded by Stripe as a failed delivery.
Clock skew and the timestamp tolerance
Symptom: a signature error that mentions the timestamp being outside the tolerance zone, often on one host or container and not others. Cause: Stripe's libraries reject a signature whose timestamp is more than five minutes from your server's current time, a protection against replay attacks. Stripe generates a fresh timestamp and signature for every delivery attempt, including retries, so an old event that is retried is not the cause; a drifting server clock is. Fix: run NTP on every host, as Stripe recommends, and check containers or virtual machines that resume from suspension with a stale clock. Do not set the tolerance to `0`: Stripe warns that a zero tolerance disables the recency check entirely. Test: compare `date -u` on the host with a reliable time source, then trigger an event.
Proxies, CDNs and web application firewalls
Network layers rarely change bodies, but when they do the failure looks exactly like a body-parser bug. Things to check on the path between the internet and your route:
- Edge functions or workers that read the request, modify it and forward a new one.
- Request-body inspection or sanitising in a WAF, which can rewrite characters it considers unsafe.
- Character-set conversion in an older proxy or application server.
- Proxies that drop or rename unknown headers, leaving `Stripe-Signature` empty.
- Bot protection or challenge pages that answer Stripe with a 403 or an HTML page instead of passing the request through.
Fix: add a bypass rule for the webhook path that disables body inspection and challenges, and, if you also restrict by source, allow Stripe's published webhook IP addresses. Stripe recommends using IP allowlisting together with signature verification, not instead of it. Test: compare the body length logged at your origin with the length logged at the edge for the same event ID.
3xx redirects
Symptom: Event deliveries shows `302` (or `301`, `307`, `308`) and the endpoint never runs. Cause: Stripe treats a redirect response to a webhook request as a failure; it does not follow it. Common sources are an `http://` URL redirected to `https://`, an apex domain redirected to `www`, a trailing slash added or removed by the framework, a locale prefix added by internationalisation routing, and an authentication layer redirecting to a login page. Fix: register the URL the redirect resolves to, exactly, which is what Stripe's status table recommends. Test: `curl -i -X POST` the registered URL and confirm the response is not a 3xx before you look at anything else.
Why is my Stripe webhook returning 400 or another 4xx error?
A 400 almost always comes from your own code rejecting the signature. Other 4xx codes mean something in front of your code refused the request: 401 and 403 for authentication, WAF or CSRF rules, 404 for a path that does not exist, and 405 for a route that does not accept POST.
Start in Workbench: open the endpoint, choose the Event deliveries tab and click a failed event. Stripe shows the HTTP status of each attempt and when the next retry is scheduled. That status code is the fastest way to decide which layer to investigate.
- 400 from something else. Symptom: your application logs show nothing at all. A gateway, a request-size rule or a validation layer rejected the request before it reached your code. Fix: find the layer that logged the rejection and exclude the webhook path.
- 401 and 403. Symptom: staging works for you in a browser, Stripe gets 401. Basic authentication on preview environments, session-based auth middleware, a WAF or bot challenge, or CSRF protection is applied to the route. Fix: exempt the path and rely on signature verification for authenticity.
- 404. Symptom: deliveries fail right after a deploy. The route moved, the deploy did not include it, or the registered URL has a typo. Fix: correct the route or the registered URL. Test: `curl -i -X POST` returns 400 for a missing signature, not 404.
- 405. Symptom: the route exists but only handles GET. Fix: accept POST, which Stripe's documentation lists as a requirement for webhook endpoints.
One rule worth adopting: return 400 only for a failed signature or an unparseable payload. For a valid event that your code does not handle, return 200. Stripe keeps retrying anything that is not 2xx, so a 400 for an unknown event type creates days of pointless retries and noise in your delivery log.
Why does my Stripe webhook time out, and how do you fix it?
A webhook times out when your endpoint does slow work before replying. Stripe's documentation says the endpoint must return a 2xx status quickly, before any complex logic that could cause a timeout, so verify, record the event and reply, then do the work elsewhere.
Symptom: Event deliveries shows `Timed out`, yet your logs show the handler completing successfully a little later. Because Stripe recorded a failure, it retries, and the work runs again. Cause: the handler sends emails, calls other APIs, generates PDFs, writes to an accounting system or waits on a database lock before returning. Stripe's own example is explicit: return 200 before updating an invoice as paid in an accounting system. Cold starts on serverless functions add to the delay, and so do bursts: Stripe notes that spikes, such as the start of the month when subscriptions renew, can overwhelm endpoints that process synchronously.
Fix: reduce the handler to verify, store the event ID, enqueue and return 2xx, and move everything else to a worker. Stripe recommends an asynchronous queue so you process events at a rate your system can sustain. The design of that queue, and of the deduplication table that makes repeated processing safe, is covered in why the endpoint should return 2xx quickly.
Test: log the time from request received to response sent, per event type, and alert when any route takes more than a second or two. Then trigger a burst locally, for example a loop of `stripe trigger` commands, and confirm every delivery shows 200 in the CLI output. Stripe does not publish the exact timeout value on the pages we reviewed, so treat any figure you see quoted elsewhere with care and design for fast replies rather than for a threshold.
How does Stripe retry failed webhooks, and when does it disable an endpoint?
In live mode Stripe retries a failed delivery for up to three days with exponential backoff; in a sandbox it retries three times over a few hours. If an endpoint keeps failing for several days, Stripe can stop delivery attempts and disable it.
A delivery counts as failed when Stripe cannot connect, the TLS handshake fails, the response is a redirect or any non-2xx status, or the endpoint takes too long to respond. The Event deliveries tab shows when the next retry will happen. Stripe's documentation adds two details that matter during an incident: if the endpoint has been disabled or deleted when a retry is due, Stripe stops retrying that event; and if you disable and re-enable the endpoint before the retry, future retries still happen.
Automatic disabling
Stripe's support article on why Stripe keeps trying to reach your endpoints says that when an endpoint has been unresponsive or returning errors for several days and stays that way, Stripe stops delivery attempts and disables it in your webhook configuration. Stripe does not publish an exact threshold, so do not build runbooks around one. Symptom: deliveries stop entirely and the endpoint shows as disabled. Fix: repair the cause first, re-enable the endpoint, then recover the events you missed, because re-enabling only affects future deliveries. Test: resend one recent event and confirm a 200 before declaring the incident closed.
Manual retries and recovering missed events
You can resend a specific event from the Dashboard for up to 15 days after it was created, or with `stripe events resend <event_id> --webhook-endpoint=<endpoint_id>` for up to 30 days. Two behaviors surprise teams. First, a successful manual resend does not cancel Stripe's automatic retries for that event, so the event can arrive again later. Second, Stripe considers events you processed through a recovery script as still undelivered; when the automatic retry arrives, your endpoint should recognise the event ID, skip the work and return 200 so retries stop.
For larger outages, Stripe's recovery procedure uses the List Events API with `delivery_success=false`, an `ending_before` anchor at the last event delivered before the outage, and the event types the endpoint listens for. That API returns events from the last 30 days only. The full reconciliation pattern, including what to do for older gaps, is in how to recover missed Stripe webhook events.
Why is my Stripe webhook not receiving events?
If Event deliveries shows no attempt at all, Stripe never tried to send the event to that endpoint. The usual causes are an event type the endpoint is not subscribed to, the wrong mode or account scope, or a disabled endpoint. If attempts are listed but your logs show nothing, the request is being stopped before your code.
- Event type not enabled. Each endpoint receives only the types in its enabled events list. Stripe notes that updating that list affects future delivery only; earlier events are not sent automatically, though you can retrieve the last 30 days through the Events API.
- Wrong mode. Events created with test keys go to sandbox endpoints; live events go to live endpoints. A live endpoint will never see a `stripe trigger` event.
- Wrong account scope on Connect. Events from direct charges, connected accounts' customers and payout failures go to destinations that listen to connected accounts. Destination charges and separate charges and transfers are platform-owned, so their events go to destinations that listen to your own account. Platforms often need both. See Stripe Connect charge types for which objects live where.
- Disabled or deleted endpoint. Check the endpoint status in Workbench before debugging code.
- Destination limit. Stripe allows up to 16 event destinations per live or sandbox account. Teams that create an endpoint per preview deployment reach that limit quickly.
- Wrong format. A destination receives either thin or snapshot events, chosen at creation. Thin events need their own endpoint, so subscribing to a thin event type on a snapshot destination is not possible.
- Local testing without the listener. Locally, nothing arrives unless `stripe listen` is running with `--forward-to` pointing at the right port and path. The `--forward-to` flag does not work in the Workbench Shell, only in a terminal.
- Events sent, then blocked. If Workbench lists attempts with `Unable to connect`, check DNS, firewall rules and IP allowlists. Stripe requires the host to be publicly reachable over HTTPS in live mode.
There is also a rare case Stripe now documents: on occasion, Stripe can fail to generate an event at all. That event is unrecoverable, and Stripe instead sends a `v2.core.health.event_generation_failure.resolved` event naming the affected object. If your business depends on a particular event type, subscribe to that health event and poll the affected object when it arrives.
Why do I receive duplicate or out-of-order Stripe webhook events?
Stripe can deliver the same event more than once and does not guarantee delivery in the order events were generated. Deduplicate on the event ID, and decide current state by retrieving the object from the API rather than by trusting arrival order.
Duplicates come from retries after timeouts, manual resends that do not cancel automatic retries, and recovery scripts that overlap with Stripe's own retries. Stripe also notes that occasionally two separate Event objects are generated for the same change; to catch those, combine the ID of the object in `data.object` with the event type. Symptom: two confirmation emails, two ledger rows, or a quantity applied twice. Fix: record each event ID under a unique constraint before processing and skip IDs you have seen; make side effects idempotent too. Test: resend the same event twice from the Dashboard and confirm one side effect.
Ordering problems look different. Symptom: a subscription shows `past_due` in your database while Stripe shows `active`, or an invoice is marked paid before the subscription exists. Cause: your handler applied events in arrival order. Stripe's example is that creating a subscription can produce `customer.subscription.created`, `invoice.created`, `invoice.paid` and `charge.created`, which can arrive in any order. Snapshot events record `created` in whole seconds, so several events can share a timestamp; Stripe says not to use `created` to order events or to detect duplicates. Fix: treat each event as a prompt to retrieve the current object and write its absolute state. The layered idempotency design and an ordering timeline are in the best practices guide, and the subscription-specific version is in Stripe subscription webhooks.
Thin events vs snapshot events: which errors depend on the format?
Snapshot events carry the full object as it was when the event was generated and are versioned by API version; thin events carry only the event type and related object ID and are unversioned. Each format has its own verification call, its own endpoint and its own failure modes.
Stripe now recommends thin events for new integrations, while snapshot events remain the right choice when a third-party tool needs the full payload or you need `previous_attributes` without another API call. Format-specific problems we see:
- Wrong parsing call. Snapshot payloads are verified with `constructEvent()`; thin notifications with `parseEventNotification()`. Using the snapshot call on a thin destination, or the reverse, fails in confusing ways even when the signature is valid.
- One endpoint for both. Thin events require a separate destination. Pointing a thin destination at code written for snapshot events produces handlers that look for `data.object` and find nothing.
- Stale snapshots. Stripe describes the snapshot as eventually consistent and possibly outdated by the time you process it. Code that writes `data.object` straight into your database can overwrite newer state, which is the ordering bug above in another form.
- Version mismatch in typed SDKs. A snapshot destination's API version is fixed when it is created. With the statically typed SDKs (.NET, Java, Go), Stripe warns that a destination version that does not match the SDK's version causes deserialization failures. After an API upgrade, update the destination version deliberately.
- Old events after an upgrade. Event objects cannot change after creation. An event created under your old API version keeps that structure even if you retrieve it later with a newer version, so recovery scripts must handle both shapes.
For local testing, the CLI forwards thin events only when you ask for them: `stripe listen --forward-thin-to <url> --thin-events "*"`. Thin events from connected accounts use `--forward-thin-connect-to`.
How do you test and replay Stripe webhook events with the Stripe CLI?
Run `stripe listen` with `--forward-to` to send sandbox events to your local route, use the signing secret it prints, fire events with `stripe trigger`, and resend a specific event with `stripe events resend`. Each fix in this guide should end with one of these tests.
# Terminal 1: forward only the events your handler subscribes to
stripe listen \
--events payment_intent.succeeded,checkout.session.completed \
--forward-to localhost:3000/api/webhooks/stripe
# Print the CLI signing secret for .env.local (stable across restarts)
stripe listen --print-secret
# Terminal 2: fire a test event (creates real sandbox objects as a side effect)
stripe trigger payment_intent.succeeded
# Replay one event to a registered endpoint (up to 30 days after creation)
stripe events resend evt_123 --webhook-endpoint=we_123A few CLI behaviors from the stripe listen reference and the stripe trigger reference that save debugging time:
- The CLI's signing secret does not change between restarts of `stripe listen`, so you can keep it in `.env.local`. `--print-secret` prints it and exits.
- `--events` limits forwarding to the types you list, which matches what a real endpoint with enabled events receives. Without it, the CLI forwards all snapshot events.
- `--load-from-webhooks-api` listens for the events your Dashboard endpoints are configured for, a quick way to reproduce production's subscription list locally.
- `--forward-connect-to` sends Connect events to a different local URL, useful when production verifies Connect and platform events with different secrets.
- `stripe trigger` issues real API requests in your sandbox and creates the objects it needs, so one trigger can produce several events (for example, `payment_intent.succeeded` also produces `payment_intent.created`). Expect that in your logs.
Beyond the happy path, write tests for the failures this guide describes. Send a request with a modified body and confirm 400. Send one with a valid signature and an old timestamp and confirm 400. Resend an event twice and confirm one side effect. Return a 500 from a staging handler on purpose and watch the retry schedule in Event deliveries. The Stripe integration guide includes a wider failure matrix for the rest of a payment integration.
From here, this article is about Cevrynt
How Cevrynt approaches Stripe webhook troubleshooting
Cevrynt, Inc. is a Stripe partner in the Stripe Partner Ecosystem, and our engineering services team repairs Stripe integrations inside clients' own repositories and Stripe accounts. Arindam Pradhan, our founder, is a Stripe Certified Professional (Implementation Architect); Anupam Pradhan and Subhadip Giri are Stripe Certified Professionals (Payments Architect). They work with four senior engineers who each have six or more years of experience.
Every engagement starts with an audit of the current integration, data model and Stripe account setup. On webhook work that means reading the delivery log and tracing a failing request from Stripe's signature to the handler, then adding idempotency, logging and reconciliation for missed or late events, with the failure paths verified in Stripe test mode. It sits within our Stripe payments work, with subscription state handled under Stripe Billing and marketplace event routing under Stripe Connect.
Every project is scoped after a technical review. If your endpoint is failing now, tell us what Event deliveries shows, and we will reply with what we need to see first.
