Back to BlogTroubleshooting

Stripe Webhook Not Firing, or Returning 405: A Checklist

Rupak Amin

Founder & Lead Engineer, RAITHub

13 min read

If a Stripe webhook is not firing, open the endpoint's Event deliveries tab first: no attempts means a subscription, mode or account problem; attempts with an error code mean your server is refusing them. A 405 usually means the URL reaches code that does not accept POST, such as a Next.js route.ts with no exported POST function.

This checklist is for the moment Stripe and your app disagree about whether an event was delivered. It works from the outside in: is Stripe sending, is your server accepting, is the signature valid, and is the right secret in the right environment. The Stripe facts come from Stripe's documentation, checked on 29 September 2026, and the code targets the Next.js App Router. If you are building billing from scratch rather than debugging it, start with how to add Stripe billing to a SaaS, which covers the objects, events and a full handler. On proof: PropDesk, a property-management SaaS RAITHub built, collects rent through Stripe and runs 1,024 automated tests, so these are the checks RAITHub runs on its own Stripe work.

Is Stripe sending the event at all?

Check before you touch code. In Workbench, open Webhooks, select the endpoint, and open the Event deliveries tab. Stripe's webhooks documentation says it lists each event as Delivered, Pending or Failed, with the HTTP status code of every attempt and the time of the next retry.

If the event you expected is not in that list, Stripe never tried to deliver it to this endpoint. The usual reasons:

  • The event type is not selected. An endpoint only receives the types you chose when you created it. A handler for invoice.paid never runs if the destination only listens for checkout.session.completed.
  • Wrong mode. Sandbox (test) and live mode have separate endpoints. An event from a live payment never reaches an endpoint registered in a sandbox, and the reverse.
  • Wrong account scope. On a Connect platform, events for resources on connected accounts need a destination that listens to "Connected accounts". A destination for "Your account" will not see them.
  • The endpoint is disabled. Stripe will not retry to a disabled or deleted destination.
  • You are relying on the CLI. stripe listen forwards events only while it is running, and only to the URL you gave it. It does not deliver to your deployed server.

If the event is listed with a failed status, Stripe is sending and your server is refusing. Move on to the status code.

What does each webhook status code mean?

Stripe treats anything other than a 2xx as a failure, including redirects. The descriptions below follow the status-code table in Stripe's webhooks documentation; the Next.js causes are the ones RAITHub checks first.

StatusWhat Stripe saysCommon cause on a Next.js appFix
Unable to connectStripe could not reach the serverLocalhost URL registered, or a private networkRegister a public HTTPS URL, or use the CLI locally
3xx, such as 307 or 308"We consider redirect responses to webhook requests as failures"Trailing slash, apex-to-www or HTTP-to-HTTPS redirect, or a proxy.ts auth redirect to a login pageRegister the final URL exactly; exclude the webhook path from auth
401, 403Access restrictions on the URLPassword-protected preview deployment, auth middleware, a firewall rulePoint Stripe at an unprotected production URL; allow the path
404The URL does not existPath typo, route file in the wrong folder, a base path or locale prefixMatch the registered URL to the folder under app/
405Access restriction; the endpoint must accept POSTNo exported POST in route.ts, or a static hostSee the next section
400The server could not or would not process itUsually your own handler rejecting a failed signature checkCheck the raw body and the secret
500The server hit an errorMissing environment variable, database error, an unhandled exceptionRead your logs for the event ID
Timed outThe server took too longSlow work done before respondingReturn 2xx quickly; queue heavy work
TLS errorNo secure connection; TLS 1.2 or higher is requiredExpired or incomplete certificate chainRun an SSL server test and fix the chain

Why does my Next.js webhook route return 405?

Because the request reached something that does not handle POST. In the App Router, a route.ts file answers only the HTTP methods it exports by name, and Next.js returns 405 Method Not Allowed for the rest. The Next.js route handler reference lists the supported exports: GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS.

The patterns that cause it:

  • Only GET is exported, often left over from a health check or a copied example.
  • A default export, the Pages Router habit of export default function handler. The App Router ignores it.
  • A misspelt name, such as post or Post. Method exports are case-sensitive.
  • A static export. With output: 'export' there is no server to run the handler, and static file hosts commonly answer a POST with 405.
  • Something in front of the app, such as a CDN or firewall rule that only allows GET on that path.

A minimal handler that accepts POST and verifies the signature:

// app/api/stripe/webhook/route.ts
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string)

export async function POST(req: Request) {
  // The raw body, exactly as Stripe sent it. Never req.json() here.
  const body = await req.text()
  const signature = req.headers.get('stripe-signature')
  if (!signature) return new Response('Missing signature', { status: 400 })

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET as string)
  } catch {
    return new Response('Invalid signature', { status: 400 })
  }

  // Hand the verified event to an idempotent processor (see below).
  return new Response('ok', { status: 200 })
}

Then test the URL from outside, exactly as registered in Stripe:

curl -i -X POST https://example.com/api/stripe/webhook -d '{}'

You want 400 Missing signature. That proves the request reached your handler over POST. A 405, 404, 401 or 3xx means the problem is routing, protection or redirects, not Stripe. This matches Stripe's own stripe-node App Router example, which reads the body with req.text().

Why does Stripe signature verification fail with a 400?

One of three inputs is wrong: the body, the signature header or the secret. Stripe's signature troubleshooting guide says the failure reads "No signatures found matching the expected signature for payload", and that the most common cause is the wrong endpoint secret.

  • The body was changed. Stripe requires the raw body, and any change, such as parsing to JSON and serialising again, reordering keys or changing whitespace, breaks verification. In a route handler, await req.text() gives you the raw string. In the Pages Router you must disable the body parser instead.
  • The wrong secret. Every endpoint has its own whsec_ secret. Stripe says the CLI secret and a Dashboard endpoint's secret both start with whsec_ but are different, and the same endpoint has a different secret for test and live keys.
  • The secret is in the wrong environment. A production deployment still holding the CLI or sandbox secret fails every live event. On hosts that bake environment variables into a deployment, changing the variable needs a redeploy before it takes effect.
  • A stray character. A trailing newline or quote copied into the environment variable is enough. Log the secret's length and last four characters, never the whole value.
  • Clock drift or a stale replay. The libraries reject timestamps older than a default tolerance of 5 minutes. Stripe advises against a tolerance of 0, which disables the check.
  • A rolled secret. When you roll a secret, Stripe can keep the old one valid for up to 24 hours, then only the new one works.

On the Edge runtime, use stripe.webhooks.constructEventAsync, because Web Crypto is asynchronous. The default Node.js runtime works with constructEvent.

How do test and live mode webhook endpoints differ?

They are separate endpoints with separate secrets, keys and retry behaviour, so a working sandbox proves little about live until you register and test the live endpoint too.

Sandbox (test)Live
Endpoint registrationRegistered in the sandboxRegistered again in live mode
Signing secretIts own whsec_ valueA different whsec_ value
API keysk_test_sk_live_
Automatic retriesThree times over a few hoursUp to three days, with exponential back-off
URL schemeHTTP allowed while developing locallyHTTPS required

The retry figures are from Stripe's webhooks documentation. To stop a sandbox event touching production data, check the livemode field on every event against the environment:

const expectLive = process.env.STRIPE_MODE === 'live'
if (event.livemode !== expectLive) {
  // Acknowledge, but never let a test event change live data.
  return new Response('Ignored: wrong mode', { status: 200 })
}

How do you test Stripe webhooks locally with the CLI?

Run stripe listen with --forward-to pointing at your local route, put the secret it prints into your local environment, and fire events with stripe trigger. The flags below are from Stripe's stripe listen reference.

stripe login

# Forward events to the local route; copy the whsec_ secret it prints.
stripe listen --forward-to localhost:3000/api/stripe/webhook

# Only the events your integration handles, in the latest API version's shape.
stripe listen --events customer.subscription.updated,invoice.paid --latest --forward-to localhost:3000/api/stripe/webhook

# In a second terminal, create a real test event.
stripe trigger payment_intent.succeeded

# Re-send a specific event to a registered endpoint (up to 30 days after creation).
stripe events resend evt_123 --webhook-endpoint=we_123

Three details save time. The CLI secret does not change between restarts of stripe listen, so you can keep it in your local environment file. You do not need a Dashboard endpoint to receive events through the CLI. And without --latest, forwarded events use your account's default API version, which may not match the version your deployed endpoint uses.

Once events arrive, what stops them being processed twice or out of order?

An event-ID table and re-reading the current object. Stripe's documentation says an endpoint "might occasionally receive the same event more than once", and that Stripe "doesn't guarantee the delivery of events in the order that they're generated". Getting delivery working is only half the job.

CREATE TABLE stripe_events (
  id          text PRIMARY KEY,           -- evt_... from Stripe
  type        text NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now()
);

Insert the event ID with ON CONFLICT (id) DO NOTHING in the same transaction as the change it causes, and skip the event if nothing was inserted. For subscription events, retrieve the subscription from the API rather than trusting the payload, so an older event arriving late cannot overwrite newer state. Stripe also says not to use the event's created timestamp to decide order, because distinct events can share one. The full handler is in the Stripe billing guide, and the principle behind the table is in what idempotency means in API design.

What is the full checklist, in order?

  1. Event deliveries tab: is the event listed for this endpoint at all?
  2. Destination settings: right event types, right mode, right account scope, enabled.
  3. Status code of the failed attempt: use the table above.
  4. curl the registered URL with POST: expect your own 400, not 405, 404, 401 or a redirect.
  5. Route file: a named POST export in app/.../route.ts, no static export.
  6. Auth and redirects: the webhook path excluded from proxy.ts auth and preview protection; the registered URL is the final one.
  7. Signature: req.text(), the endpoint's own secret, for this mode, in this deployment.
  8. Timing: respond with a 2xx fast; move slow work to a queue.
  9. Replay: resend the failed event from the Dashboard (up to 15 days) or the CLI (up to 30 days) once fixed.
  10. Idempotency: the event-ID table in place before you replay anything that grants access or sends email.

If events arrive and return 200 but your data still does not change, the problem is inside the handler rather than delivery. For general webhook mechanics, see what webhooks are and how to build them.

Why RAITHub for a Stripe webhook that will not work?

Because RAITHub treats a webhook fix like any other fix: reproduce it, find the root cause, add a regression test, and verify it in the environment Stripe actually calls.

  • Stripe in production. PropDesk collects rent through Stripe, inside a codebase with 1,024 automated tests.
  • Tests that replay the failure. Bad signature, duplicate delivery and out-of-order delivery become tests in CI, so the fix stays fixed.
  • Next.js depth. The route, proxy.ts, runtime and deployment settings are part of the check, not only the Stripe code. See the Next.js development service.
  • Fixed scope. A free 15-minute technical audit, then a written, fixed quote. You own the code, and an NDA is standard.

When you don't need us

  • The checklist above found it. Most 405s and signature failures are a one-line fix once you see the status code.
  • You use Payment Links or hosted Checkout with no custom code. Stripe's hosted tools may not need a webhook at all for simple cases.
  • It is a live outage right now. RAITHub starts with an audit call, so it is not instant. Stripe keeps retrying live events for up to three days, which buys you time.

If you are still stuck, send RAITHub the endpoint URL, the failed status code and your route file, or start from the fix one issue page.

Last reviewed: 29 September 2026. Stripe and Next.js details checked against docs.stripe.com and nextjs.org on 29 September 2026.

Frequently asked questions

Why is my Stripe webhook not firing?

Check the endpoint's Event deliveries tab. If the event is not listed, the endpoint is not subscribed to that event type, is in the other mode, listens to the wrong account scope or is disabled. If it is listed with an error code, your server is refusing the request.

Why does my Stripe webhook return 405 in Next.js?

The route handler does not export a function named POST. Next.js App Router route files answer only the methods they export by name, so a file with only GET, a default export or a lowercase post returns 405. A static export has the same effect.

Why does Stripe say "No signatures found matching the expected signature for payload"?

The body, the signature header or the secret is wrong. Most often it is the wrong secret: the CLI, each Dashboard endpoint, and test and live mode all have different whsec_ secrets. Read the raw body with req.text() and never re-serialise JSON before verifying.

Does Stripe follow redirects on webhook URLs?

No. Stripe treats a 3xx response to a webhook as a failure. Register the final URL, including the right host and trailing-slash form, and exclude the webhook path from any auth redirect.

Can I use the same webhook secret for test and live mode?

No. Stripe generates a unique secret per endpoint, and an endpoint used with both test and live keys has a different secret for each. Store them as separate environment variables per deployment.

How long does Stripe retry a failed webhook?

In live mode, for up to three days with exponential back-off. In a sandbox, three times over a few hours. You can resend an event manually from the Dashboard for up to 15 days, or with the Stripe CLI for up to 30 days.

Why does stripe listen work locally but production gets nothing?

The CLI forwards events only to your local URL while it runs. Production needs its own registered endpoint in the right mode, with its own signing secret set in the production environment.

Stripe webhook not firingStripe webhook 405Stripe signature verificationNext.js route handlerStripe CLIWebhookserror fix

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.