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.
› 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:
- 01Wrong webhook secret (CLI vs Dashboard, test vs live)The stripe listen whsec_ is set in productionFix: use the whsec_ of THIS endpoint + mode →
- 02307 / 308 / 301 redirectEndpoint registered as https://example.com but the site redirects to https://www.example.comFix: register the exact final URL →
- 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 →
- 04Timed out / not returning 2xx fastSending email or calling third-party APIs inside the handlerFix: return 2xx fast, do slow work later →
- 05Edge runtime / crypto errorsexport const runtime = "edge" on the webhook routeFix: runtime = "nodejs" or constructEventAsync →
- 06Events not enabled on the endpoint (nothing arrives)checkout.session.completed or customer.subscription.* not selectedFix: add the event types to the endpoint →
- 07Static export can't run API routesoutput: "export" in next.configFix: deploy the route to a server/runtime →
- pre-deploy check
- Dashboard
whsec_inSTRIPE_WEBHOOK_SECRET - Same mode (test/live) as the key
- Exact final URL (no 3xx)
- Webhook excluded from
proxy.tsauth - Events enabled on the endpoint
- Dashboard
02Every failure, explained
The full list.
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 thenJSON.stringify()beforeconstructEvent - A wrapper, logger or middleware that reads and re-serialises the body
- The right body but the wrong
whsec_secret
fix
- 1.In an App Router route handler, read the body once with await
req.text() - 2.Pass that string, the stripe-signature header and
STRIPE_WEBHOOK_SECRETtoconstructEvent - 3.Only JSON-parse after verification (event is already parsed for you)
- 4.If it still fails, check which
whsec_secret is in use
// 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 });}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.
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.
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 listenwhsec_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
fix
- 1.Stripe Dashboard → Developers → Webhooks → your endpoint → reveal the signing secret
- 2.Set it as
STRIPE_WEBHOOK_SECRETin the production environment (the matching mode: live for live keys) - 3.Redeploy so the running build sees the new value
- 4.Keep the
stripe listensecret only in.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_endpointstripe 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.
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.
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.configadds a slash and redirects - http:// instead of https://
- A locale or auth redirect in
proxy.ts/middleware.ts
fix
- 1.Run
curl -i -X POSThttps://your-url/api/stripe/webhook and look at the status and Location header - 2.Update the Dashboard endpoint to the final URL from Location, exactly
- 3.Exclude /api/stripe/webhook from locale and auth redirects
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.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.
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.
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
fix
- 1.Map the URL to the file: /api/stripe/webhook → src/app/api/stripe/webhook/
route.ts - 2.Export a named function: export async function POST(req: Request)
- 3.Test with
curl -i -X POST: you should get 400 (missing signature), not 404
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.
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.
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
fix
- 1.Verify the signature, do the minimal DB write, return 200
- 2.Move slow work to a queue or background job (or after() in Next.js)
- 3.Keep the handler idempotent because retries will come
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 });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.
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.
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.idvalues were already processed - Incrementing credits or inserting rows instead of writing current state
- A slow handler that Stripe retried
fix
- 1.Store
event.idwith a unique constraint before doing side effects; skip if it already exists - 2.Prefer writing the current state (status = X) over increments or toggles
- 3.Return 200 for duplicates so Stripe stops retrying
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: }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.
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.
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
fix
- 1.Take only the object id from the event
- 2.Re-fetch the subscription with stripe.subscriptions.retrieve(id) and write its current state
- 3.Or compare
event.created/ a stored timestamp and ignore older events
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;}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.
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.
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
fix
- 1.Simplest: export const
runtime = "nodejs"on the webhook route - 2.On Edge/Workers: await stripe.webhooks.constructEventAsync(body, sig, secret, undefined, Stripe.createSubtleCryptoProvider())
- 3.Still read the body with await
req.text()
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(),);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.
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().
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, thenreq.text()for verification - Copying a Pages Router example (export const config = { api: { bodyParser: false } }, micro's buffer())
- Passing a parsed object to
constructEvent
fix
- 1.Read once: const body = await
req.text() - 2.Delete Pages Router body config and the micro dependency from App Router routes
- 3.If you need to log, log after verification using the event object
// 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 });}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.
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.
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
fix
- 1.Dashboard → Developers → Webhooks → endpoint → check the event list and mode
- 2.For subscriptions add checkout.session.completed and customer.subscription.created/updated/deleted
- 3.Use 'Send test event' or
stripe triggercheckout.session.completed to confirm delivery
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.
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.
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_SECRETis the Dashboard secret, not the CLI one
fix
- 1.Start next dev first, then
stripe listen--forward-to localhost:3000/api/stripe/webhook - 2.Copy the
whsec_it prints into.env.localand restart next dev - 3.Try 127.0.0.1 instead of localhost if you see [::1] connection refused
- 4.Fire a test with
stripe triggercheckout.session.completed
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.completedstripe 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.
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.
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
fix
- 1.Sync the server clock (NTP) and check date -u against real UTC
- 2.Verify the signature as soon as the request arrives, then queue the parsed event
- 3.For tests, generate fresh headers with stripe.webhooks.generateTestHeaderString instead of old ones
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.
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.
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_idor metadata.userId stripeCustomerIdnever 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
fix
- 1.Create Checkout with
client_reference_id: user.id (and subscription_data.metadata.userId if you need it on the subscription) - 2.On checkout.session.completed save session.customer as the user's
stripeCustomerId - 3.Look up later subscription events by
stripeCustomerId; pass customer for returning users
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`,});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.
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.
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"innext.config- Hosting on a static-only host (GitHub Pages, plain S3, a Pages project with no functions)
fix
- 1.Remove
output: "export"and deploy to a host that runs Next.js (Vercel, Node, Docker, OpenNext on Cloudflare) - 2.Or keep the site static and run the webhook as a separate function / Worker
- 3.Then register that function's URL in Stripe
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.
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.
curl -i -X POSTthe 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
- Webhook excluded from
proxy.ts/middleware.tsmatcher - Not a protected *.vercel.app preview URL
- No WAF or bot challenge on /api/stripe/webhook
- Signature check is the only auth
event.idstored with a unique constraint- Duplicates return 200 early
- Handlers write current state, no increments
- Slow work runs after the 2xx
# 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.completed04Or 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.tsreadsreq.text()and verifies withconstructEvent - 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.
> More free tools
All tools →- VibeBoot DoctorNewPaste any error or your package.json: diagnosis, fix and the rule that prevents it.
- middleware.ts → proxy.tsNewConvert Next.js middleware to Next.js 16 proxy.ts, Auth.js v5 included.
- AGENTS.md checkerNewCheck AGENTS.md for the Next.js 16 managed block, commands and guardrails.
- Access control checkerPaste a route or server action, see what anyone can call.
- Prisma error fixerP2002, P1001, P2021, did not initialize yet: cause and fix.
- Next.js error fixerServer Component, Suspense and hydration errors: cause and fix.
- Env leak checkerPaste your .env, see which secrets would ship to the browser.
- Auth.js error fixerMissingSecret, UntrustedHost, redirect_uri: cause and fix.
- AGENTS.md generatorA ready AGENTS.md and CLAUDE.md for your Next.js stack.
- Cursor Rules PackFree downloadScoped .mdc rules for Next.js, Stripe webhooks, access control and more.
- Cursor rules generatorBuild one project.mdc rule for your exact stack.
- AI chatbot cost calculatorCost per message, Pro margin and a safe free-tier cap.