Free tool · Stripe webhooks · Next.js

Paste the Stripe webhook error.
Get the fix.

15 common reasons Stripe webhooks fail on Next.js, from signature errors to redirects and duplicate events. Each one comes with what it means, the fix, a before → after diff of the code AI agents usually get wrong, and a prompt you can hand to Cursor or Claude Code.

stripe-webhook-fixer — runs in your browser, nothing is sent

try:

› 15 errors indexed. Matching runs locally as you type.

  • 15 failures
  • 8 before → after diffs
  • 7 only show up in production
  • checked against stripe-node 23.0.0 · Next.js 16.4.3 App Router
  • updated

01Works locally, breaks in prod

Your laptop is forgiving. Production isn't.

Locally, stripe listen forwards straight to your route with its own secret, and nothing sits in between. After you deploy, redirects, auth, timeouts and a different whsec_ secret all get a say. These are the 7 failures that mostly appear there:

  1. 01Wrong webhook secret (CLI vs Dashboard, test vs live)The stripe listen whsec_ is set in productionFix: use the whsec_ of THIS endpoint + mode →
  2. 02307 / 308 / 301 redirectEndpoint registered as https://example.com but the site redirects to https://www.example.comFix: register the exact final URL →
  3. 03401 / 403 from auth middleware or Vercel Deployment Protectionproxy.ts / middleware.ts protects /api/* and the matcher doesn't exclude the webhookFix: exclude the route from auth / deployment protection →
  4. 04Timed out / not returning 2xx fastSending email or calling third-party APIs inside the handlerFix: return 2xx fast, do slow work later →
  5. 05Edge runtime / crypto errorsexport const runtime = "edge" on the webhook routeFix: runtime = "nodejs" or constructEventAsync →
  6. 06Events not enabled on the endpoint (nothing arrives)checkout.session.completed or customer.subscription.* not selectedFix: add the event types to the endpoint →
  7. 07Static export can't run API routesoutput: "export" in next.configFix: deploy the route to a server/runtime →
  8. pre-deploy check
    • Dashboard whsec_ in STRIPE_WEBHOOK_SECRET
    • Same mode (test/live) as the key
    • Exact final URL (no 3xx)
    • Webhook excluded from proxy.ts auth
    • Events enabled on the endpoint

02Every failure, explained

The full list.

01server log

No signatures found matching the expected signature

StripeSignatureVerificationError: No signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe?

The signature is computed over the exact bytes Stripe sent. If your code parses the body as JSON and stringifies it again, or anything changes whitespace or encoding, the HMAC no longer matches. If the body is untouched, the secret is wrong (see the next entry).

usually because

  • Calling req.json() and then JSON.stringify() before constructEvent
  • A wrapper, logger or middleware that reads and re-serialises the body
  • The right body but the wrong whsec_ secret

works with stripe-node 23.0.0 · Next.js 16.4.3 App Routerlast verified CLI steps: Stripe CLI 1.55.2, Oct 27, 2026

prevent it · stripe-webhooks.mdc (free Cursor Rules Pack): Reads the body once with await req.text() and verifies it before anything else.

fix

  1. 1.In an App Router route handler, read the body once with await req.text()
  2. 2.Pass that string, the stripe-signature header and STRIPE_WEBHOOK_SECRET to constructEvent
  3. 3.Only JSON-parse after verification (event is already parsed for you)
  4. 4.If it still fails, check which whsec_ secret is in use
src/app/api/stripe/webhook/route.ts
−2 +5
// src/app/api/stripe/webhook/route.tsimport Stripe from "stripe"; added: export const runtime = "nodejs";added:  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); export async function POST(req: Request) {  const sig = req.headers.get("stripe-signature");removed:   const payload = await req.json();added:   if (!sig) return new Response("Missing signature", { status: 400 });added:   const body = await req.text(); // raw body: never req.json()  let event: Stripe.Event;  try {removed:     event = stripe.webhooks.constructEvent(JSON.stringify(payload), sig!, process.env.STRIPE_WEBHOOK_SECRET!);added:     event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);  } catch (err) {    return new Response(`Invalid signature: ${(err as Error).message}`, { status: 400 });  }  // ...handle event.type...  return Response.json({ received: true });}
verify the fix
stripe listen --forward-to localhost:3000/api/stripe/webhook
# put the whsec_... it prints in .env.local, then restart npm run dev
stripe trigger checkout.session.completed

pass if stripe listen prints [200] for every forwarded event, not 400 or 500.

prompt for your agent

My Stripe webhook fails with 'No signatures found matching the expected signature'. Open src/app/api/stripe/webhook/route.ts and make sure the body is read once with await req.text() and passed unmodified to stripe.webhooks.constructEvent with the stripe-signature header. Remove any req.json(), JSON.stringify or body-parsing wrapper before verification. Don't change the event handling.

02server logprod only

Wrong webhook secret (CLI vs Dashboard, test vs live)

No signatures found matching the expected signature for payload  (works locally with stripe listen, fails in production)

Every endpoint has its own signing secret. The secret printed by stripe listen only verifies events forwarded by the CLI. A Dashboard endpoint has a different one, and test-mode and live-mode endpoints have different ones too.

usually because

  • The stripe listen whsec_ is set in production
  • A test-mode secret is used with a live-mode endpoint (or the reverse)
  • You rolled the secret in the Dashboard and didn't redeploy
  • Extra quotes or a trailing space in the env var

works with stripe-node 23.0.0 · Next.js 16.4.3 App Routerlast verified CLI steps: Stripe CLI 1.55.2, Oct 27, 2026

prevent it · stripe-webhooks.mdc (free Cursor Rules Pack): One whsec_ secret per endpoint and environment, and test keys never paired with live secrets.

fix

  1. 1.Stripe Dashboard → Developers → Webhooks → your endpoint → reveal the signing secret
  2. 2.Set it as STRIPE_WEBHOOK_SECRET in the production environment (the matching mode: live for live keys)
  3. 3.Redeploy so the running build sees the new value
  4. 4.Keep the stripe listen secret only in .env.local
.env.local
# local only: printed by `stripe listen`
STRIPE_WEBHOOK_SECRET=whsec_from_stripe_listen

# production (set on your host, not in git):
# STRIPE_WEBHOOK_SECRET=whsec_from_dashboard_endpoint
verify the fix
stripe listen --forward-to localhost:3000/api/stripe/webhook
# put the whsec_... it prints in .env.local, then restart npm run dev
stripe trigger checkout.session.completed
stripe events resend evt_... --webhook-endpoint=we_...
# then the deployed endpoint; add --live for a live-mode event

pass if Local events return [200], and the resent event shows a 200 delivery on the deployed endpoint.

prompt for your agent

Stripe webhook signature checks pass locally but fail in production. List every place STRIPE_WEBHOOK_SECRET is read, confirm the code trims nothing weird, and give me a checklist to confirm production uses the Dashboard endpoint's signing secret for the same mode (test/live) as STRIPE_SECRET_KEY. Do not hardcode any secret.

03Stripe Dashboardprod only

307 / 308 / 301 redirect

Webhook delivery failed: HTTP status code 308 (Permanent Redirect)

Stripe does not follow redirects. Any 3xx counts as a failed delivery. Next.js and hosts redirect apex to www (or the reverse), http to https, and add or strip trailing slashes.

usually because

  • Endpoint registered as https://example.com but the site redirects to https://www.example.com
  • trailingSlash: true in next.config adds a slash and redirects
  • http:// instead of https://
  • A locale or auth redirect in proxy.ts / middleware.ts

works with stripe-node 23.0.0 · Next.js 16.4.3 App Routerlast verified CLI steps: Stripe CLI 1.55.2, Oct 27, 2026

prevent it · stripe-webhooks.mdc (free Cursor Rules Pack): Excludes the webhook path from auth, locale and trailing-slash redirects.

fix

  1. 1.Run curl -i -X POST https://your-url/api/stripe/webhook and look at the status and Location header
  2. 2.Update the Dashboard endpoint to the final URL from Location, exactly
  3. 3.Exclude /api/stripe/webhook from locale and auth redirects
terminal
curl -i -X POST https://example.com/api/stripe/webhook
# HTTP/2 308
# location: https://www.example.com/api/stripe/webhook   <- register THIS url

# A healthy endpoint answers 400 (missing signature), not 3xx/401/404.
verify the fix
curl -i -X POST https://<your-domain>/api/stripe/webhook
# expect 400 (missing signature) from your handler, not 3xx, 401, 403, 404 or 405
stripe events resend evt_... --webhook-endpoint=we_...
# we_... is the endpoint id in the Dashboard; add --live for a live-mode event

pass if curl gets 400 from your own handler, and the resent event shows a 200 delivery on the endpoint in the Dashboard.

prompt for your agent

Stripe shows my webhook failing with a 307/308 redirect. Check next.config (trailingSlash, redirects), proxy.ts/middleware.ts and any locale or auth redirects, and tell me the exact final URL Stripe should call. Make sure /api/stripe/webhook is never redirected.

04Stripe Dashboardprod only

401 / 403 from auth middleware or Vercel Deployment Protection

Webhook delivery failed: HTTP status code 401 (Unauthorized)

Something answered before your handler ran. Stripe has no session cookie, so auth middleware rejects it or redirects it to sign-in. On Vercel, Deployment Protection (Vercel Authentication) blocks preview deployments, and sometimes production, with a 401.

usually because

  • proxy.ts / middleware.ts protects /api/* and the matcher doesn't exclude the webhook
  • Vercel Deployment Protection is on for the URL Stripe calls (often a preview URL)
  • Cloudflare / WAF bot protection challenges Stripe's requests
  • Basic auth on a staging site

works with stripe-node 23.0.0 · Next.js 16.4.3 App Routerlast verified CLI steps: Stripe CLI 1.55.2, Oct 27, 2026

prevent it · stripe-webhooks.mdc (free Cursor Rules Pack): Keeps the webhook path out of your auth layer, since a 401 counts as a failed delivery.

fix

  1. 1.Exclude /api/stripe/webhook from the middleware matcher: the Stripe signature is its authentication
  2. 2.Point Stripe at your production domain, not a *.vercel.app preview URL
  3. 3.Or turn off Deployment Protection for that URL, or use Vercel's Protection Bypass for Automation
  4. 4.Allow Stripe's webhook IPs through your WAF if needed
src/proxy.ts
−2 +2
export const config = {removed:   // run everywhere except static filesremoved:   matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],added:   // run everywhere except static files and the Stripe webhookadded:   matcher: ["/((?!api/stripe/webhook|_next/static|_next/image|favicon.ico).*)"],};
verify the fix
curl -i -X POST https://<your-domain>/api/stripe/webhook
# expect 400 (missing signature) from your handler, not 3xx, 401, 403, 404 or 405
stripe events resend evt_... --webhook-endpoint=we_...
# we_... is the endpoint id in the Dashboard; add --live for a live-mode event

pass if curl gets 400 from your own handler, and the resent event shows a 200 delivery on the endpoint in the Dashboard.

prompt for your agent

Stripe gets 401/403 from my webhook. Find every proxy.ts/middleware.ts matcher, auth check or wrapper that runs on /api/stripe/webhook and exclude that route, keeping signature verification as its only auth. Also tell me if the URL in Stripe is a Vercel preview URL behind Deployment Protection.

05Stripe Dashboard

404 Not Found (wrong path)

Webhook delivery failed: HTTP status code 404 (Not Found)

Nothing answers at the URL Stripe calls. In the App Router, the URL comes from the folder path of route.ts, and only exported HTTP method names are handlers.

usually because

  • Endpoint is /api/webhooks/stripe but the file is app/api/stripe/webhook/route.ts
  • The file is named webhook.ts or page.tsx instead of route.ts
  • The handler isn't exported as POST (e.g. export default)
  • A typo, or a missing /api prefix, or a basePath

works with stripe-node 23.0.0 · Next.js 16.4.3 App Routerlast verified CLI steps: Stripe CLI 1.55.2, Oct 27, 2026

fix

  1. 1.Map the URL to the file: /api/stripe/webhook → src/app/api/stripe/webhook/route.ts
  2. 2.Export a named function: export async function POST(req: Request)
  3. 3.Test with curl -i -X POST: you should get 400 (missing signature), not 404
verify the fix
curl -i -X POST https://<your-domain>/api/stripe/webhook
# expect 400 (missing signature) from your handler, not 3xx, 401, 403, 404 or 405
stripe events resend evt_... --webhook-endpoint=we_...
# we_... is the endpoint id in the Dashboard; add --live for a live-mode event

pass if curl gets 400 from your own handler, and the resent event shows a 200 delivery on the endpoint in the Dashboard.

prompt for your agent

Stripe gets a 404 from my webhook URL. Find the actual webhook route file, tell me the URL it serves (including basePath), confirm it exports a named POST handler, and tell me what to put in the Stripe Dashboard.

06Stripe Dashboardprod only

Timed out / not returning 2xx fast

Webhook delivery failed: Timed out connecting to remote host

Stripe expects a 2xx quickly. If your handler sends emails, calls other APIs or waits on a cold database before answering, the request can time out (around 10 seconds or more counts as a failure) and Stripe retries it, which also causes duplicates.

usually because

  • Sending email or calling third-party APIs inside the handler
  • Cold start plus a slow database connection
  • Serverless function timeout on your host
  • Awaiting work that isn't needed to acknowledge the event

works with stripe-node 23.0.0 · Next.js 16.4.3 App Routerlast verified CLI steps: Stripe CLI 1.55.2, Oct 27, 2026

prevent it · stripe-webhooks.mdc (free Cursor Rules Pack): Returns 2xx once the event is recorded and runs slow work afterwards.

fix

  1. 1.Verify the signature, do the minimal DB write, return 200
  2. 2.Move slow work to a queue or background job (or after() in Next.js)
  3. 3.Keep the handler idempotent because retries will come
route.ts (after verifying)
−3 +4
removed: await recordEvent(event);removed: await sendReceiptEmail(event); // Stripe waits for thisremoved: await syncToCrm(event);         // ...and thisadded: import { after } from "next/server";added:  added: await recordEvent(event);            // fast, idempotent DB writeadded: after(() => sendReceiptEmail(event)); // runs after the responsereturn Response.json({ received: true });
verify the fix
stripe listen --forward-to localhost:3000/api/stripe/webhook
# put the whsec_... it prints in .env.local, then restart npm run dev
stripe trigger checkout.session.completed

pass if stripe listen prints [200] right away, before any slow work (emails, provisioning) finishes.

prompt for your agent

My Stripe webhook times out. Profile src/app/api/stripe/webhook/route.ts: keep signature verification and the minimal idempotent DB write, then return 200. Move emails and third-party calls out of the request path (after() or a queue). Don't change what gets written.

07symptom

Duplicate events / missing idempotency

Same checkout.session.completed processed twice: two welcome emails, credits granted twice

Stripe delivers at least once. Retries after a timeout or non-2xx, and occasionally plain duplicates, mean the same event.id can arrive more than once. Handlers that increment, insert or send emails without checking will repeat the side effect.

usually because

  • No record of which event.id values were already processed
  • Incrementing credits or inserting rows instead of writing current state
  • A slow handler that Stripe retried

works with stripe-node 23.0.0 · Next.js 16.4.3 App Routerlast verified CLI steps: Stripe CLI 1.55.2, Oct 27, 2026

prevent it · stripe-webhooks.mdc (free Cursor Rules Pack): Records each event.id under a unique constraint and returns 200 for repeats without doing the work again.

fix

  1. 1.Store event.id with a unique constraint before doing side effects; skip if it already exists
  2. 2.Prefer writing the current state (status = X) over increments or toggles
  3. 3.Return 200 for duplicates so Stripe stops retrying
prisma/schema.prisma + route.ts
−4 +5
model StripeEvent {  id        String   @id // evt_...  createdAt DateTime @default(now())} // in the handler, after constructEvent:removed: await db.user.update({removed:   where: { id: userId },removed:   data: { credits: { increment: 100 } }, // runs again on every retryremoved: });added: try {added:   await db.stripeEvent.create({ data: { id: event.id } });added: } catch {added:   return Response.json({ received: true, duplicate: true });added: }
verify the fix
stripe listen --forward-to localhost:3000/api/stripe/webhook
# put the whsec_... it prints in .env.local, then restart npm run dev
stripe trigger checkout.session.completed
stripe events resend evt_...   # the evt_ id stripe listen printed
stripe events resend evt_...   # same id again

pass if All three deliveries return [200], the database changed once, and there's one stored row for that event id.

prompt for your agent

Make my Stripe webhook idempotent. Add a StripeEvent table keyed by event.id (unique), insert it right after signature verification and return 200 early if it already exists. Change any increments or one-off side effects so a repeated event is harmless. Add a test that sends the same event twice.

08symptom

Out-of-order events

customer.subscription.updated (active) arrived after customer.subscription.deleted, user got Pro back

Stripe doesn't guarantee delivery order. A retried older event can land after a newer one, so writing event.data.object blindly can overwrite fresh state with stale state.

usually because

  • Writing the event payload snapshot straight to the database
  • Assuming created always arrives before updated
  • Retries of an old event

works with stripe-node 23.0.0 · Next.js 16.4.3 App Routerlast verified CLI steps: Stripe CLI 1.55.2, Oct 27, 2026

prevent it · stripe-webhooks.mdc (free Cursor Rules Pack): Re-fetches the object from Stripe instead of trusting event order or event.data.object.

fix

  1. 1.Take only the object id from the event
  2. 2.Re-fetch the subscription with stripe.subscriptions.retrieve(id) and write its current state
  3. 3.Or compare event.created / a stored timestamp and ignore older events
src/lib/billing.ts
−1 +1
case "customer.subscription.updated":case "customer.subscription.deleted": {removed:   const sub = event.data.object; // may be an old, late-retried snapshotadded:   const sub = await stripe.subscriptions.retrieve(event.data.object.id);  await db.user.updateMany({    where: { stripeCustomerId: sub.customer as string },    data: { subscriptionStatus: sub.status },  });  break;}
verify the fix
stripe listen --forward-to localhost:3000/api/stripe/webhook
# put the whsec_... it prints in .env.local, then restart npm run dev
stripe trigger customer.subscription.updated
stripe events resend evt_...   # the customer.subscription.created event from that run

pass if After the older event is replayed, the stored subscription still matches what the Dashboard shows.

prompt for your agent

My Stripe webhook can apply stale subscription state when events arrive out of order. Change the handler to take only the subscription id from the event, re-fetch it with stripe.subscriptions.retrieve, and write its current state. Add a test where updated(active) arrives after deleted.

09server logprod only

Edge runtime / crypto errors

SubtleCryptoProvider cannot be used in a synchronous context. Use `await constructEventAsync(...)` instead of `constructEvent(...)`

constructEvent is synchronous and uses Node's crypto. On the Edge runtime or Cloudflare Workers only Web Crypto exists, which is async.

usually because

  • export const runtime = "edge" on the webhook route
  • Deploying to Cloudflare Workers / Pages
  • A bundler polyfill that breaks node:crypto

works with stripe-node 23.0.0 · Next.js 16.4.3 App Routerlast verified CLI steps: Stripe CLI 1.55.2, Oct 27, 2026

prevent it · stripe-webhooks.mdc (free Cursor Rules Pack): Keeps the webhook handler on the Node.js runtime.

fix

  1. 1.Simplest: export const runtime = "nodejs" on the webhook route
  2. 2.On Edge/Workers: await stripe.webhooks.constructEventAsync(body, sig, secret, undefined, Stripe.createSubtleCryptoProvider())
  3. 3.Still read the body with await req.text()
route.ts (Edge / Workers)
−3 +3
removed: export const runtime = "edge";removed:  removed: const event = stripe.webhooks.constructEvent(added: const event = await stripe.webhooks.constructEventAsync(  await req.text(),  sig,  process.env.STRIPE_WEBHOOK_SECRET!,added:   undefined,added:   Stripe.createSubtleCryptoProvider(),);
verify the fix
stripe listen --forward-to localhost:3000/api/stripe/webhook
# put the whsec_... it prints in .env.local, then restart npm run dev
stripe trigger checkout.session.completed

pass if stripe listen prints [200] for every forwarded event, not 400 or 500.

prompt for your agent

My Stripe webhook throws a crypto/SubtleCryptoProvider error. Check the runtime of src/app/api/stripe/webhook/route.ts. If I'm not deploying to an edge-only platform, set runtime = "nodejs". Otherwise switch to await constructEventAsync with Stripe.createSubtleCryptoProvider().

10server log

Missing raw body / body already used

TypeError: Body is unusable: Body has already been read  ·  Webhook payload must be provided as a string or a Buffer

The request body stream can be read once. If something read it already, or you pass an object instead of the raw string, verification can't run. The Pages Router bodyParser: false config does nothing in App Router route handlers.

usually because

  • Reading req.json() for logging, then req.text() for verification
  • Copying a Pages Router example (export const config = { api: { bodyParser: false } }, micro's buffer())
  • Passing a parsed object to constructEvent

works with stripe-node 23.0.0 · Next.js 16.4.3 App Routerlast verified CLI steps: Stripe CLI 1.55.2, Oct 27, 2026

prevent it · stripe-webhooks.mdc (free Cursor Rules Pack): Never calls req.json() before verifying the signature.

fix

  1. 1.Read once: const body = await req.text()
  2. 2.Delete Pages Router body config and the micro dependency from App Router routes
  3. 3.If you need to log, log after verification using the event object
src/app/api/stripe/webhook/route.ts
−5 +4
// src/app/api/stripe/webhook/route.tsimport Stripe from "stripe"; removed: // Pages Router config: ignored by App Router route handlersremoved: export const config = { api: { bodyParser: false } };added: export const runtime = "nodejs"; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); export async function POST(req: Request) {  const sig = req.headers.get("stripe-signature");removed:   console.log("webhook", await req.json()); // body is now used upremoved:   const body = await req.text(); // throws: Body is unusableadded:   if (!sig) return new Response("Missing signature", { status: 400 });added:   const body = await req.text(); // raw body: never req.json()  let event: Stripe.Event;  try {removed:     event = stripe.webhooks.constructEvent(body, sig!, process.env.STRIPE_WEBHOOK_SECRET!);added:     event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);  } catch (err) {    return new Response(`Invalid signature: ${(err as Error).message}`, { status: 400 });  }  // ...handle event.type...  return Response.json({ received: true });}
verify the fix
stripe listen --forward-to localhost:3000/api/stripe/webhook
# put the whsec_... it prints in .env.local, then restart npm run dev
stripe trigger checkout.session.completed

pass if stripe listen prints [200] for every forwarded event, not 400 or 500.

prompt for your agent

My Stripe webhook can't get the raw body. Rewrite src/app/api/stripe/webhook/route.ts for the App Router: read the body once with await req.text(), verify with constructEvent, remove any Pages Router bodyParser config or micro buffer() code, and don't read the body anywhere else.

11symptomprod only

Events not enabled on the endpoint (nothing arrives)

Payment succeeded in Stripe but my webhook never fired / no deliveries listed for the endpoint

A Dashboard endpoint only receives the event types you selected, in the mode (test or live) where you created it. Stripe CLI forwarding doesn't reach production.

usually because

  • checkout.session.completed or customer.subscription.* not selected
  • The endpoint was created in test mode but the payment was live (or the reverse)
  • Endpoint disabled after repeated failures
  • Listening on a connected account instead of your account

works with stripe-node 23.0.0 · Next.js 16.4.3 App Routerlast verified CLI steps: Stripe CLI 1.55.2, Oct 27, 2026

prevent it · stripe-webhooks.mdc (free Cursor Rules Pack): Checks the production endpoint is subscribed to every event type the handler switches on.

fix

  1. 1.Dashboard → Developers → Webhooks → endpoint → check the event list and mode
  2. 2.For subscriptions add checkout.session.completed and customer.subscription.created/updated/deleted
  3. 3.Use 'Send test event' or stripe trigger checkout.session.completed to confirm delivery
verify the fix
stripe listen --forward-to localhost:3000/api/stripe/webhook
# put the whsec_... it prints in .env.local, then restart npm run dev
stripe trigger invoice.payment_failed
# test-mode Dashboard endpoints receive triggered events too

pass if The event reaches your local handler with [200] and shows up as a delivery on the test-mode endpoint in the Dashboard.

prompt for your agent

My Stripe webhook never fires. List every event.type my handler handles so I can enable exactly those on the Dashboard endpoint, and give me the stripe trigger commands to test each one locally.

12Stripe CLI

stripe listen forwarding problems (local)

[ERROR] Failed to POST: Post "http://localhost:3000/api/webhook": dial tcp [::1]:3000: connect: connection refused

The CLI receives events and forwards them to a local URL. It fails if the dev server isn't running, the port or path is wrong, or your app uses a different secret than the one stripe listen printed.

usually because

  • next dev isn't running, or runs on another port
  • Wrong path in --forward-to
  • localhost resolves to IPv6 ::1 while the server listens on IPv4
  • STRIPE_WEBHOOK_SECRET is the Dashboard secret, not the CLI one

works with stripe-node 23.0.0 · Next.js 16.4.3 App Routerlast verified CLI steps: Stripe CLI 1.55.2, Oct 27, 2026

prevent it · stripe-webhooks.mdc (free Cursor Rules Pack): Uses the whsec_ that stripe listen prints, only for local forwarding.

fix

  1. 1.Start next dev first, then stripe listen --forward-to localhost:3000/api/stripe/webhook
  2. 2.Copy the whsec_ it prints into .env.local and restart next dev
  3. 3.Try 127.0.0.1 instead of localhost if you see [::1] connection refused
  4. 4.Fire a test with stripe trigger checkout.session.completed
terminal
npm run dev
stripe listen --forward-to localhost:3000/api/stripe/webhook
# copy the printed whsec_... into .env.local, restart dev
stripe trigger checkout.session.completed
verify the fix
stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe listen --print-secret   # the whsec_ to put in .env.local
stripe trigger checkout.session.completed

pass if stripe listen prints [200] for every forwarded event, not 400 or 500.

prompt for your agent

Set up local Stripe webhook testing for this app. Tell me the exact stripe listen --forward-to command for my webhook route and port, which env var gets the printed whsec_, and which stripe trigger commands cover the events my handler uses.

13server log

Timestamp outside the tolerance zone

StripeSignatureVerificationError: Timestamp outside the tolerance zone

The stripe-signature header carries a timestamp, and the library rejects events older than the tolerance (300 seconds by default) to stop replay attacks. Either your server clock is off, or you're replaying an old saved payload.

usually because

  • Server or container clock drift (no NTP)
  • Replaying a saved request from a fixture or log
  • Queueing the raw request and verifying it minutes later

works with stripe-node 23.0.0 · Next.js 16.4.3 App Routerlast verified CLI steps: Stripe CLI 1.55.2, Oct 27, 2026

fix

  1. 1.Sync the server clock (NTP) and check date -u against real UTC
  2. 2.Verify the signature as soon as the request arrives, then queue the parsed event
  3. 3.For tests, generate fresh headers with stripe.webhooks.generateTestHeaderString instead of old ones
verify the fix
date -u   # should match real UTC
stripe listen --forward-to localhost:3000/api/stripe/webhook
# put the whsec_... it prints in .env.local, then restart npm run dev
stripe trigger checkout.session.completed

pass if Fresh events verify and return [200]. Don't raise the tolerance to make old payloads pass.

prompt for your agent

My Stripe webhook fails with 'Timestamp outside the tolerance zone'. Check whether verification happens immediately on receipt or later from a queue, and whether tests replay stale signed payloads. Make verification run on receipt and use stripe.webhooks.generateTestHeaderString in tests. Don't raise the tolerance.

14symptom

Can't map the event to a user (missing metadata / customer id)

Webhook returns 200 but the user is still on Free / updateMany count: 0 / Cannot read properties of undefined (reading 'userId')

The webhook ran but couldn't tell which of your users the event belongs to. Subscription events carry a customer id, not your user id, so you need a stored stripeCustomerId or metadata set when the Checkout Session was created.

usually because

  • Checkout Session created without client_reference_id or metadata.userId
  • stripeCustomerId never saved on the user
  • A new Customer created per checkout (customer_email) instead of reusing one
  • Reading metadata from the subscription when you only set it on the session

works with stripe-node 23.0.0 · Next.js 16.4.3 App Routerlast verified CLI steps: Stripe CLI 1.55.2, Oct 27, 2026

prevent it · stripe.mdc (free Cursor Rules Pack): Sets client_reference_id (or metadata.userId) on every Checkout Session.

fix

  1. 1.Create Checkout with client_reference_id: user.id (and subscription_data.metadata.userId if you need it on the subscription)
  2. 2.On checkout.session.completed save session.customer as the user's stripeCustomerId
  3. 3.Look up later subscription events by stripeCustomerId; pass customer for returning users
checkout route
−0 +3
await stripe.checkout.sessions.create({  mode: "subscription",  line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],added:   client_reference_id: user.id,added:   subscription_data: { metadata: { userId: user.id } },added:   ...(user.stripeCustomerId ? { customer: user.stripeCustomerId } : { customer_email: user.email }),  success_url: `${origin}/billing?success=1`,  cancel_url: `${origin}/billing`,});
verify the fix
stripe listen --forward-to localhost:3000/api/stripe/webhook
# put the whsec_... it prints in .env.local, then restart npm run dev
stripe trigger checkout.session.completed --add checkout_session:client_reference_id=<test-user-id>
# the fixture is a one-time payment; also run one subscription checkout in test mode

pass if That test user's row gets the Stripe customer id, and a later subscription event finds the same user.

prompt for your agent

My Stripe webhook returns 200 but never updates the right user. Trace how a Checkout Session links to my user: add client_reference_id (and subscription_data.metadata.userId), save session.customer as stripeCustomerId on checkout.session.completed, and look subscription events up by stripeCustomerId. Log a warning when no user matches.

15build / deployprod only

Static export can't run API routes

Error: export const dynamic = "force-static"/ output: export ... route /api/stripe/webhook cannot be exported  ·  405 Method Not Allowed

With output: "export" Next.js produces only static files. A POST route handler that verifies signatures needs a server or a function runtime, so on a static host the webhook either fails the build or returns 404/405.

usually because

  • output: "export" in next.config
  • Hosting on a static-only host (GitHub Pages, plain S3, a Pages project with no functions)

works with stripe-node 23.0.0 · Next.js 16.4.3 App Routerlast verified CLI steps: Stripe CLI 1.55.2, Oct 27, 2026

fix

  1. 1.Remove output: "export" and deploy to a host that runs Next.js (Vercel, Node, Docker, OpenNext on Cloudflare)
  2. 2.Or keep the site static and run the webhook as a separate function / Worker
  3. 3.Then register that function's URL in Stripe
verify the fix
curl -i -X POST https://<your-domain>/api/stripe/webhook
# expect 400 (missing signature) from your handler, not 3xx, 401, 403, 404 or 405
stripe events resend evt_... --webhook-endpoint=we_...
# we_... is the endpoint id in the Dashboard; add --live for a live-mode event

pass if curl gets 400 from your webhook function instead of 404 or 405, and the resent event shows a 200 delivery.

prompt for your agent

My Next.js app uses a static export but needs a Stripe webhook. Explain the options for this repo: drop output: "export" and deploy to a Next.js-capable host, or move the webhook into a separate serverless function / Worker. Recommend one and list the exact changes.

03Check it yourself

A 3-minute endpoint checklist.

This page is static, so it can't call your endpoint. Run these yourself: a healthy webhook answers an unsigned curl -i -X POST with 400 (missing signature). Anything else points at a fix above.

Redirects (3xx)
  • curl -i -X POST the exact URL in Stripe
  • No 301/307/308 and no location header
  • Same host as the final URL (www vs apex)
  • No trailing-slash redirect from next.config
Full fix →
Auth (401 / 403)
  • Webhook excluded from proxy.ts / middleware.ts matcher
  • Not a protected *.vercel.app preview URL
  • No WAF or bot challenge on /api/stripe/webhook
  • Signature check is the only auth
Full fix →
Idempotency
  • event.id stored with a unique constraint
  • Duplicates return 200 early
  • Handlers write current state, no increments
  • Slow work runs after the 2xx
Full fix →
terminal
# expect 400 "Missing signature" (route reached), not 3xx / 401 / 403 / 404 / 405
curl -i -X POST https://your-domain.com/api/stripe/webhook

# local: forward test events and fire one
stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe trigger checkout.session.completed

04Or skip the debugging

Start from a Stripe setup with webhooks already wired.

Skip the setup. The VibeBoot SaaS Starter ships Stripe Checkout, the billing portal and a signature-verified webhook already wired, plus auth and a database on Next.js 16.4.3. $49, one-time.

14-day money-back guarantee · lifetime updates

See everything that's inside →
  • Signed webhook · src/app/api/stripe/webhook/route.ts reads req.text() and verifies with constructEvent
  • Idempotent · duplicate deliveries are harmless: handlers write current state, never increment
  • Order-safe · only the subscription id is taken from the event; state is re-fetched from Stripe
  • Tested · Vitest tests for bad signatures, duplicate events and deleted-then-updated
  • Mapped to users · subscription events update the user by stripeCustomerId
  • Deploy notes · README covers the Dashboard endpoint, its events and the signing secret

FAQ

Why does my Stripe webhook work locally but fail in production?

The secret printed by stripe listen only verifies events forwarded by the CLI. Your Dashboard endpoint has its own signing secret, different again for test and live mode. Production also adds redirects (www vs apex, trailing slash), auth middleware and host protection that localhost doesn't have.

How do I get the raw body for Stripe in the Next.js App Router?

Call await req.text() once in the route handler and pass that string to stripe.webhooks.constructEvent. The Pages Router bodyParser: false config does nothing in App Router route handlers, and req.json() followed by JSON.stringify breaks the signature.

Does Stripe follow redirects for webhooks?

No. Any 3xx response counts as a failed delivery, so register the exact final URL. curl -i -X POST your endpoint: a healthy webhook answers 400 for a missing signature, not 3xx, 401 or 404.

Can this tool test my endpoint?

No. This page is static and never contacts your server or Stripe. Use the checklist on this page, curl from your terminal, Stripe Dashboard → Webhooks → your endpoint for delivery logs, and stripe trigger for test events.

Is anything I paste sent to a server?

No. The matcher is plain JavaScript in this page. Nothing you paste is stored or sent anywhere, but strip whsec_ and sk_ secrets from logs before sharing them anyway.

Sources: Stripe's webhooks guide, signature verification docs, Stripe CLI docs and the stripe-node README. Checked October 26, 2026. Spotted a mistake? Email [email protected].

Related: Stripe subscriptions in Next.js · duplicate & out-of-order events · "No signatures found" in the App Router. More free tools: Auth.js error fixer · Cursor Rules Pack · AGENTS.md generator · newsletter

$ fixed it? get the next one first

New fixes for what AI agents break, by email.

When we add a fix or a Cursor rule for a new Next.js, Auth.js, Prisma or Stripe break, it goes out in the VibeBoot newsletter. Free, occasional, unsubscribe in one click.

Subscribe free

> More free tools

All tools →