Stripe webhooks are the heartbeat of any subscription business. They tell your app when a payment succeeds, a subscription is canceled, or a trial expires. But there's a problem: Stripe can't reach localhost. Your development machine isn't publicly accessible from the internet, so Stripe has no way to POST events to it — unless you use the Stripe CLI to create a tunnel.
Why localhost doesn't work directly
When you register a webhook endpoint in the Stripe dashboard, you provide a URL like https://yourapp.com/api/stripe/webhook. Stripe makes real HTTPS requests to that URL. During development, your server is only reachable on your local network — Stripe's servers in the cloud have no idea how to find localhost:3000.
The Stripe CLI solves this by opening a persistent connection from your machine to Stripe's infrastructure. Stripe forwards events over that connection to your local server. No public URL, no ngrok account, no dashboard configuration required.
Installing the Stripe CLI
The fastest install method depends on your OS. On macOS with Homebrew:
brew install stripe/stripe-cli/stripe
On Windows with Scoop:
scoop bucket add stripe https://github.com/stripe/scoop-stripe-cli.git
scoop install stripe
Or download the binary directly from github.com/stripe/stripe-cli/releases.
Once installed, authenticate with your Stripe account:
stripe login
This opens a browser window and links the CLI to your Stripe account. You only need to do this once. The CLI stores a token in your home directory.
Forwarding events to your local server
With your Next.js dev server running on port 3000, start the Stripe listener in a second terminal:
stripe listen --forward-to localhost:3000/api/stripe/webhook
You'll see output like this:
> Ready! You are using Stripe API Version [2024-06-20]. Your webhook signing secret is whsec_abc123... (^C to quit)
Copy that whsec_... value. It is your local webhook signing secret — different from the one in the Stripe dashboard. Add it to .env.local:
STRIPE_WEBHOOK_SECRET=whsec_abc123...
Your webhook handler uses this secret to verify that incoming requests actually came from Stripe (or the CLI, locally). Without it, stripe.webhooks.constructEvent() will throw a signature verification error.
Triggering test events
You can trigger any Stripe event type from the CLI without touching the Stripe dashboard or making a real payment. The most useful one for subscription apps is:
stripe trigger checkout.session.completed
The CLI sends a synthetic event to your local server. Your Next.js route handler receives it, verifies the signature, and processes it as if a real payment had occurred. Other useful triggers:
stripe trigger customer.subscription.created
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_succeeded
stripe trigger invoice.payment_failed
Run stripe trigger --help to see the full list of supported event types.
Reading the forwarded event payload
When the CLI forwards an event, it prints the full JSON payload to your terminal — event type, object data, and metadata. This is useful for inspecting exactly what Stripe sends so you can make sure your handler is reading the right fields.
On the Next.js side, your route handler reads the raw body and verifies the signature:
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Signature verification failed:", err);
return new Response("Invalid signature", { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
// update your database here
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
// handle cancellation here
break;
}
}
return new Response(null, { status: 200 });
}
Verifying the webhook signature still works locally
The CLI signs every forwarded event with the local whsec_ secret it printed at startup. As long as your STRIPE_WEBHOOK_SECRET environment variable matches, constructEvent() will succeed. If you restart the stripe listen process, Stripe generates a new signing secret — update .env.local to match.
One important detail: you must read the request body as raw text before passing it to constructEvent(). If Next.js parses the body as JSON first (or if a middleware does), the signature check fails because the raw bytes no longer match what Stripe signed. The req.text() call above is correct — do not use req.json().
Common gotchas
Wrong endpoint path. The --forward-to URL must exactly match your route handler path. If your handler lives at app/api/stripe/webhook/route.ts, the URL is localhost:3000/api/stripe/webhook. A typo here means the CLI gets a 404 and your handler never fires.
Wrong signing secret. The Stripe dashboard shows one webhook secret; the CLI generates a different one at runtime. They are not interchangeable. Use the CLI secret (whsec_... printed at startup) for local development, and the dashboard secret for production.
Body already consumed. If any middleware reads req.body before your handler, the raw bytes are gone and signature verification fails. Make sure no global body-parsing middleware runs before the webhook route.
Dev server not running.The CLI will retry failed requests a few times, but if your Next.js server isn't running, events are dropped. Always start npm run dev before stripe listen.
Stripe webhook handling — signature verification, database upserts, and Resend email triggers — is pre-wired in GetLaunchpad, a Next.js 16 SaaS boilerplate. Get private repo access and start building your product today.