← Blog

Stripe Webhook Secrets Across Local, Preview, and Production: Stripe CLI vs Test vs Live Endpoints on Vercel (2026)

By Jake Mcdonald, founder of ShipEmber · November 12, 2026 · About a 9-minute read · Checked against the Stripe and Vercel docs

I'm Jake Mcdonald. The most common Stripe bug I've hit (and helped other people with) isn't in the billing logic at all. It's this line in the logs: No signatures found matching the expected signature for payload. The code is fine. The handler is fine. The app is just holding the wrong whsec_ secret for the place it's running.

That happens because a normal Next.js + Stripe + Vercel setup has at least three different webhook signing secrets, and they all look the same: one from the Stripe CLI on your laptop, one for a test (sandbox) endpoint, and one for your live endpoint. Add Vercel's per-environment variables, Preview URLs that change, and Deployment Protection, and there are a lot of ways to plug the right secret into the wrong place.

This post is the map I use. Everything here was checked against Stripe's webhook docs, Stripe's signature troubleshooting guide, the Stripe CLI listen reference, Vercel's environment variable docs, and Vercel's Deployment Protection docs on November 12, 2026.

The short version: one variable name, three different values

Use one variable name everywhere, STRIPE_WEBHOOK_SECRET, and give it a different value in each place:

Where the code runs Who sends the events Where the whsec_ comes from Stripe API key
Your laptop (next dev) Stripe CLI (stripe listen) Printed by stripe listen in your terminal sk_test_...
Vercel Preview (optional) A sandbox/test endpoint in the Dashboard That endpoint's settings, Reveal secret sk_test_...
Vercel Production A live-mode endpoint in the Dashboard That live endpoint's settings, Reveal secret sk_live_...

Stripe's own troubleshooting guide says the most common cause of signature failures is using the wrong endpoint secret, and that "the secret for a CLI-forwarded endpoint is different from the secret for a Dashboard-managed endpoint." All of them start with whsec_, so you can't tell them apart by looking.

Secret #1: the Stripe CLI secret (local development)

On your laptop, Stripe can't reach localhost, so the Stripe CLI connects to Stripe and forwards events to your dev server:

stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe

When it starts, it prints Ready! Your webhook signing secret is whsec_.... That value goes in .env.local as STRIPE_WEBHOOK_SECRET, and nowhere else. A few things worth knowing, all from the CLI reference:

  • You don't need a Dashboard endpoint for this. The CLI secret is not the same as any endpoint secret you see in the Dashboard.
  • It doesn't change between restarts of stripe listen, so you set it once. If you want it in a script, stripe listen --print-secret prints only the secret and exits.
  • It runs in a sandbox by default. There's a --live flag, but I'd keep local development on test keys and test events.
  • stripe trigger checkout.session.completed sends a test event through the listener. It works by making real API calls in your sandbox, so it creates the objects it needs (customers, payments) as a side effect.

One Vercel-specific trap: if you also store a STRIPE_WEBHOOK_SECRET in Vercel's Development environment, vercel env pull will write that value into .env.local. Per Vercel's CLI docs, vercel env pull merges into an existing file and keeps variables that exist only locally, so the clean setup is to not set STRIPE_WEBHOOK_SECRET in Vercel's Development scope at all and let the CLI secret live only in .env.local.

Secret #2 and #3: test-mode and live-mode endpoints each have their own

When you register an endpoint in the Dashboard (Workbench → Webhooks → Create an event destination), Stripe shows a signing secret that starts with whsec_ on the endpoint's settings page. Stripe's docs are explicit that these are per endpoint and per mode: "Stripe generates a unique secret key for each endpoint. If you use the same endpoint for both test and live API keys, the secret is different for each one."

So even if your test and live endpoints point at the exact same URL, they have different secrets. And it's not just the secret: Stripe's API keys doc says each mode has its own keys and that "objects in one mode aren't accessible to the other." Your sandbox Price IDs won't exist in live mode, so STRIPE_PRICE_PRO (or whatever you call it) needs a live value too.

Rolling a secret is also per endpoint. In the endpoint's menu, Roll secret lets you expire the old secret right away or keep it active for up to 7 days while you update your app. During that window Stripe signs each event once per active secret, so the official libraries keep working with either one.

Vercel: scope each value to the right environment, then redeploy

Vercel environment variables are scoped to Production, Preview, and Development (plus custom environments if you use them). For this setup:

  • Production: live STRIPE_WEBHOOK_SECRET, sk_live_ secret key, live Price IDs.
  • Preview: test values, only if you actually want webhooks on Preview (see the next section). Vercel also lets you scope a Preview variable to one specific Git branch, and a branch-specific value overrides the general Preview value.
  • Development: leave the webhook secret out, as above.

You can set these in the dashboard (Project → Settings → Environment Variables) or with the CLI. On recent CLI versions (64.7.0 or later):

vercel env add STRIPE_WEBHOOK_SECRET --environment production
vercel env add STRIPE_WEBHOOK_SECRET --environment preview

Run it without piping the value in, so the CLI prompts you. Vercel's docs warn that piping with echo saves the secret in your shell history. With the CLI, new Production variables default to the Secret type, which is write-only after saving.

The step everyone forgets: redeploy. Vercel's docs put it plainly: "Any change you make to environment variables are not applied to previous deployments, they only apply to new deployments." If you paste the live whsec_ into Production and immediately run a test purchase, the running deployment still has the old value and every event fails signature verification. Change the variable, redeploy, then test.

Preview deployments: changing URLs and Deployment Protection

Preview is where webhooks get awkward, for two reasons.

1. The URL changes. Vercel gives every deployment a unique URL like <project>-<unique-hash>-<scope>.vercel.app, so a Stripe endpoint pointed at one commit's URL is stale after the next push. Vercel also gives each Git branch a URL, <project>-git-<branch>-<scope>.vercel.app, which "won't change if you push new commits to the branch." If you want a test endpoint for Preview, point it at a branch URL (for example a long-lived staging branch), not a commit URL.

2. Deployment Protection can block Stripe. Vercel's Standard Protection is available on every plan and protects everything except your production domains, which includes Preview URLs. With Vercel Authentication, an unauthenticated request gets redirected to a Vercel login. Stripe can't log in, and Stripe's docs say it treats redirects (3xx) and access-restricted responses (401, 403) as failed deliveries.

You have two honest options:

  • Skip webhooks on Preview. This is what I do for most projects. Local development uses the CLI, Production uses the live endpoint, and Preview is for looking at UI. Simple, and no extra secrets to manage.
  • Use Protection Bypass for Automation. Vercel supports a bypass secret sent as a header or as a query parameter named x-vercel-protection-bypass, and its docs specifically mention the query parameter for services like Stripe that can't send custom headers. Your test endpoint URL becomes something like https://<project>-git-staging-<scope>.vercel.app/api/webhooks/stripe?x-vercel-protection-bypass=<secret>. Two caveats: that bypass secret works for the whole project until you revoke it, so treat that endpoint URL as sensitive; and if you regenerate the bypass secret, Vercel says you need to redeploy, and you'll need to update the Stripe endpoint URL too.

Production domains aren't covered by Standard Protection, so your live endpoint on your own domain is reachable by default. If you've switched the project to All Deployments protection, production is protected too, and Stripe will be blocked there as well.

Two redirects that silently break production

Since Stripe counts any 3xx as a failure (Stripe's fix is to "set the webhook endpoint destination to the URL resolved by the redirect"), check these before going live:

  • Domain redirects. If example.com redirects to www.example.com (or the other way), register the final URL in Stripe.
  • Auth middleware. If your middleware.ts (renamed proxy.ts in Next.js 16) redirects signed-out requests to /login, make sure it skips the webhook route. Stripe never has a session cookie. One way is a matcher that excludes it:
// middleware.ts (or proxy.ts on Next.js 16)
export const config = {
  matcher: ["/((?!api/webhooks|_next/static|_next/image|favicon.ico).*)"],
}

A quick test: curl -i -X POST https://your-domain.com/api/webhooks/stripe. You want a 400 from your own handler (no signature), not a 301, 307, 401, or a Vercel login page.

Raw body: the other half of signature verification

The right secret isn't enough if the body has changed. Stripe signs the exact bytes it sent, and its docs say "any manipulation to the raw body of the request causes the verification to fail." In an App Router route handler, read the body with await req.text() and pass that string straight to constructEvent, which is the same approach as Stripe's official Next.js App Router example in the stripe-node repo. Don't call req.json() first.

// app/api/webhooks/stripe/route.ts
import Stripe from "stripe"
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
  const secret = process.env.STRIPE_WEBHOOK_SECRET
  if (!secret) return new Response("Webhook secret not set", { status: 500 })
  const body = await req.text() // raw string, not req.json()
  const signature = req.headers.get("stripe-signature") ?? ""
  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(body, signature, secret)
  } catch {
    return new Response("Invalid signature", { status: 400 })
  }
  // Hand off to your idempotent sync code, then return 2xx quickly.
  // await handleStripeEvent(event)
  return new Response("ok", { status: 200 })
}

Two debugging tips from Stripe's signature guide. Some deliveries include optional secret=whsec_****... and destination=we_... entries in the Stripe-Signature header, a redacted hint of which endpoint secret Stripe signed with. Comparing the last few characters to yours is a quick way to spot a mix-up. Those entries aren't signed, so never use them to verify anything. And check your environment variable for stray whitespace from copy-pasting.

What to do after the signature passes (dedupe events, handle them out of order, return 2xx at the right time) is its own topic. I covered it in Stripe Webhooks in Next.js: Idempotency, Out-of-Order Events, and Retries.

A first live test checklist

Here's the order I go through the first time real money moves:

  1. Create the live endpoint. Switch the Dashboard to live mode, create a webhook endpoint for your final production URL (no redirects), and select only the events your handler uses.
  2. Copy the live secret. Reveal the endpoint's whsec_ and put it in Vercel's Production scope as STRIPE_WEBHOOK_SECRET, along with your sk_live_ key and live Price IDs.
  3. Redeploy Production. New env values only reach new deployments.
  4. Curl the endpoint. curl -i -X POST https://your-domain.com/api/webhooks/stripe should return your handler's 400, not a redirect or a login page.
  5. Buy your own product. Use a real card on your live Checkout.
  6. Check Event deliveries. In Workbench, open the live endpoint's Event deliveries tab and confirm 200s. If something fails, the status code tells you a lot: 3xx is a redirect, 401/403 is an access restriction, 400 from your handler usually means the wrong secret or a changed body.
  7. Check your database. The user should be on Pro because the webhook said so, not because they landed on the success page.
  8. Undo it. Refund yourself in the Dashboard and cancel the subscription (or cancel through the customer portal) and confirm the downgrade lands too.

One timing note: Stripe retries failed live-mode deliveries for up to three days with exponential backoff, but sandbox deliveries are only retried three times over a few hours. If you fix a test setup the next morning, those old test events may be gone. Resend or re-trigger them.

Why choose ShipEmber

None of this needs a product. It's environment hygiene, and if you'd rather wire it up yourself, the table and checklist above should get you there. I built ShipEmber because I kept redoing this exact setup, plus the auth, billing, and gating around it, for every new project.

ShipEmber is a Next.js App Router starter with that loop already working: Auth.js v5 sign-in (Google, GitHub, and email magic links), Stripe Checkout, a customer portal route, a raw-body, signature-verified, idempotent webhook route that keeps Free/Pro in the database, Prisma with Postgres, and a dashboard that checks the plan on the server. You still create your own Stripe endpoints and paste in your own secrets (no kit can do that part for you), but the code on the other end is done.

To be honest about where it doesn't fit: if you need teams, organizations, per-seat billing, or role-based permissions from day one, a bigger kit will serve you better. In my October 30 comparison, those kits were listed at $199 to $349 and up (ShipFast from $199, Makerkit Pro $349, supastarter Solo $349). ShipEmber is deliberately small, built for one product with a Free plan and a Pro plan.

It's a one-time payment, $49 right now on the site (shown there as the launch price, with $79 listed as the regular price), and you can use it in unlimited personal and client projects. You get 14 days to ask for a refund if it doesn't work as described.

Prefer watching over reading? There's a short walkthrough:

Watch the ShipEmber walkthrough on YouTube

See pricing

Get ShipEmber, $49

Related: Stripe Webhooks in Next.js: Idempotency, Out-of-Order Events, and Retries · Next.js + Stripe + Auth.js setup checklist · Free vs Pro Feature Gating in Next.js App Router

Doc note: CLI vs endpoint secrets, per-endpoint and per-mode secrets, raw-body guidance, the Stripe-Signature debug entries, redirect and 4xx delivery failures, and retry windows checked on docs.stripe.com/webhooks and docs.stripe.com/webhooks/signature; stripe listen behavior on docs.stripe.com/cli/listen; env var scoping, redeploy behavior, vercel env usage, generated URLs, Deployment Protection, and Protection Bypass for Automation checked on vercel.com/docs. All on November 12, 2026.

— Jake Mcdonald, Springfield, IL