Stripe webhook not firing in production (Vercel / Next.js)

By BuildUnstuck · · checked against Stripe, Next.js and Vercel docs

"Not firing" almost always means one of three things: Stripe can't reach your URL (wrong URL, a redirect, a protected deployment), your server rejects the request (wrong signing secret, or the body was modified before verification), or your handler fails or is too slow. Stripe's Event deliveries tab shows the HTTP status of each attempt, which usually tells you which one in a minute.

What this guide covers

Payments work in test mode and with the Stripe CLI. In production, customers pay and nothing unlocks: no new row in your database, no email, no upgraded account. That's a webhook that either isn't arriving or is arriving and being rejected. The fastest way to tell which is to look at Stripe's side first, then your own.

Step 1: Look at Event deliveries in Stripe

In the Stripe Dashboard, open Workbench → Webhooks, pick your endpoint, and open the Event deliveries tab. Each event is listed as Delivered, Pending or Failed, and clicking one shows the HTTP status code of the delivery attempt (Stripe: webhooks). What that code means, from Stripe's docs:

  • Unable to connect: Stripe can't reach your host. Make sure the domain is publicly accessible.
  • 3xx (like 302): your server tried to redirect. Stripe counts redirects as failures, so register the URL the redirect resolves to.
  • 4xx (400, 401, 403, 404, 405): your server won't or can't process it. That includes access restrictions (401, 403, 405) and a URL that doesn't exist (404). Make sure the endpoint is public and accepts POST.
  • 5xx: your code threw. Read your server logs.
  • Timed out: do the slow work after you've answered with a 2xx.
  • TLS error: certificate problem. Stripe supports TLS 1.2 and 1.3 only.

If there are no events at all for your endpoint, check that it's registered in the same mode as the payment (test-mode and live-mode are separate), that it subscribes to the event types you handle, and that the URL is the right one (Step 2).

Step 2: Register the right, final, public HTTPS URL

The endpoint must be a publicly accessible HTTPS URL, like https://yourdomain.com/api/webhooks/stripe. In Next.js App Router, that URL matches a route file at app/api/webhooks/stripe/route.ts. Common reasons Stripe gets a redirect or an error instead of your handler:

  • Apex vs www: if yourdomain.com redirects to www.yourdomain.com (or the other way), register the final URL.
  • Trailing slash: Next.js redirects one form to the other. By default it redirects /about/ to /about, and trailingSlash: true reverses that (Next.js: trailingSlash). Register the form that doesn't redirect.
  • Auth middleware: if a middleware or proxy protects every route behind a login, Stripe's request can get redirected to your sign-in page. Exclude your webhook path from it. Stripe isn't signed in.
  • The URL still points to localhost, a tunnel URL, or an old preview URL from when you tested.

Step 3 (Vercel): Check Deployment Protection

Vercel's Deployment Protection puts a login in front of protected deployments: with Vercel Authentication, visitors hit a Vercel login redirect. Standard Protection protects all domains except your production domains (Vercel: Deployment Protection). So a webhook aimed at a preview URL or a generated *.vercel.app deployment URL can fail even though the code is fine, and a redirect counts as a failed delivery. Point Stripe at your production domain (unless you chose All Deployments protection, which also covers production domains). If you need to test webhooks against a protected deployment, read Vercel's docs on protection bypass for automation rather than turning protection off globally.

Step 4: Use the right signing secret, in the right environment

Every webhook endpoint has its own signing secret, starting with whsec_. Stripe says the most common cause of signature errors is using the wrong one: the secret printed by stripe listen is different from the secret for an endpoint you created in the Dashboard, and an endpoint used with both test and live keys has a different secret for each (Stripe: manage webhook endpoints).

  • Copy the secret from the production endpoint in Workbench (Reveal secret) into your host's Production environment variables, for example as STRIPE_WEBHOOK_SECRET.
  • Use live-mode API keys and a live-mode endpoint together, test with test, and don't mix them.
  • Redeploy afterwards. On Vercel, environment variable changes apply only to new deployments (Vercel: environment variables).

Step 5: Verify the signature against the raw body

Stripe verifies webhooks using the Stripe-Signature header, the endpoint secret and the raw request body. Its docs say any manipulation of the raw body, such as parsing and re-serializing the JSON, makes verification fail. In a Next.js App Router route handler, read the body with request.text(); Next.js's own webhook example does this (Next.js: route handlers). This follows the shape of Stripe's official Next.js example:

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

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(request: Request) {
  const body = await request.text() // raw body: do not use request.json() here
  const signature = request.headers.get('stripe-signature')
  const secret = process.env.STRIPE_WEBHOOK_SECRET

  if (!signature || !secret) {
    return new Response('Missing signature or secret', { status: 400 })
  }

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(body, signature, secret)
  } catch (err) {
    return new Response('Invalid signature', { status: 400 })
  }

  // Record event.id, then handle event.type (for example checkout.session.completed)
  return new Response('ok', { status: 200 })
}

Other frameworks (from Stripe's troubleshooting table):

  • Next.js Pages Router: disable bodyParser for the route and read the raw buffer. Stripe links an example.
  • Express: your webhook route must be registered before app.use(express.json()), because middleware runs in order and express.json() would parse the body first.

Don't unlock access from your success page. Anyone can open /success in a browser. Grant access from a verified webhook event, and treat the redirect page as a thank-you screen only.

Step 6: Return a 2xx quickly, and expect duplicates

  • Stripe wants a successful status code (2xx) before any complex logic that could time out. Acknowledge first, then do slow work, ideally through a queue.
  • Events can arrive more than once, and Stripe doesn't guarantee order. Log the event IDs you've processed and skip repeats, and don't use the created timestamp to decide ordering.
  • Only subscribe to the event types you actually handle.
  • In live mode, Stripe retries failed deliveries for up to three days with exponential backoff. In a sandbox it retries three times over a few hours. You can also resend an event yourself: from the Dashboard (up to 15 days after the event) or with the CLI, stripe events resend <event_id> --webhook-endpoint=<endpoint_id> (up to 30 days). A manual resend doesn't cancel Stripe's automatic retries, which is another reason to skip events you've already processed.

Step 7: Test the whole path before you flip to live

  1. Locally: run stripe listen --forward-to localhost:3000/api/webhooks/stripe, put the whsec_ value it prints in your local env, then run stripe trigger payment_intent.succeeded.
  2. On your deployed test environment: create a test-mode endpoint pointing at it, with its own secret, and send a test event from the Dashboard. Check Event deliveries for a 200.
  3. Negative test: send a request with a wrong signature and confirm you get a 400 and no side effects.

Do this on a staging copy with test keys, and never paste live secret keys or signing secrets into a chat, a screenshot or a ticket.

Symptom to likely cause

Event deliveries showsCheck first
Nothing for your endpointEndpoint mode (test vs live), subscribed event types, URL (Steps 1 and 2)
3xxwww/apex, trailing slash, auth middleware redirect (Step 2), Vercel protection (Step 3)
401, 403, 404, 405Protection or middleware, wrong path, route doesn't export POST (Steps 2 and 3)
400 and a signature error in your logsWrong whsec_ secret, modified body (Steps 4 and 5)
500Your handler threw: read runtime logs, check env vars
Timed outAcknowledge first, queue the slow work (Step 6)
200 but nothing unlocksYour handler ignores the event type or fails after responding; check what you do with event.type

Quick answers

Why does my Stripe webhook work with the CLI but not in production?

The CLI forwards events to localhost with its own signing secret. A production endpoint has a different secret, needs a public HTTPS URL that doesn't redirect, and its secret must be set in your host's production environment (then redeployed).

Why do I get "No signatures found matching the expected signature for payload"?

At least one of three things passed to signature verification is wrong: the raw body, the Stripe-Signature header, or the endpoint secret. Stripe says the wrong endpoint secret is the most common cause; the next suspect is a framework that parsed the body before you read it.

Does Stripe retry failed webhooks?

Yes. In live mode Stripe retries for up to three days with exponential backoff; in a sandbox it retries three times over a few hours. You can also resend an event from the Dashboard or the CLI.

Sources

The vendor-specific claims above come from these docs, read on Oct 23, 2026. Docs change, so check the current wording before you rely on a specific setting.