Test Stripe Webhooks Locally in Next.js with Secure Tunnels
Learn how to test Stripe webhooks in a local Next.js app using taupi secure tunnels, signature verification, and realistic test events.
Stripe webhooks are one of those features that look simple in production and become surprisingly awkward in local development. Your Next.js app runs on localhost:3000, but Stripe needs to send an HTTPS request to a publicly reachable URL. That mismatch is where most webhook debugging pain starts.
This guide walks through a practical local workflow for testing Stripe webhooks in a Next.js application using a secure public tunnel from taupi.dev. You will build a real webhook route, verify Stripe signatures correctly, expose your local app to Stripe, send test events, and troubleshoot the common problems that make webhook testing frustrating.
The examples focus on the Next.js App Router, with a Pages Router version included as well.
What you will build
By the end, you will have:
- A local Next.js app running on
localhost:3000 - A Stripe webhook endpoint at
/api/stripe/webhook - Correct Stripe signature verification using the raw request body
- A secure public HTTPS tunnel pointing to your local server
- A Stripe Dashboard webhook endpoint targeting your local machine
- A repeatable workflow for testing events such as
checkout.session.completed
The key local development idea is this:
Stripe webhook event
↓
https://your-tunnel-url.taupi.dev/api/stripe/webhook
↓
taupi secure tunnel
↓
http://localhost:3000/api/stripe/webhook
↓
Your local Next.js route handler
Stripe sees a normal HTTPS URL. Your code still runs locally, with your debugger, logs, database, and .env.local file.
Prerequisites
You will need:
- Node.js 18 or newer
- A Next.js application
- A Stripe account in test mode
- The Stripe secret key for test mode
- taupi installed locally
If you do not already have a Next.js app, create one:
npx create-next-app@latest stripe-webhook-demo
cd stripe-webhook-demo
Install the Stripe Node SDK:
npm install stripe
Create a local environment file:
touch .env.local
Add your Stripe secret key:
STRIPE_SECRET_KEY=sk_test_your_key_here
STRIPE_WEBHOOK_SECRET=whsec_replace_after_creating_endpoint
You will replace STRIPE_WEBHOOK_SECRET later after you create the webhook endpoint in Stripe.
Why local Stripe webhook testing is different
A normal API route can be tested with curl, Postman, or your browser. Stripe webhooks are different for two reasons.
First, Stripe must initiate the HTTP request. That means your endpoint needs to be reachable from Stripe's servers. localhost only exists on your machine, so Stripe cannot call it directly.
Second, Stripe signs webhook requests. In production and local development, you should verify the Stripe-Signature header using your webhook signing secret. That verification depends on the exact raw request body. If your framework parses or modifies the body before verification, signature checks fail.
This is the most common bug in Stripe webhook implementations:
Webhook signature verification failed
No signatures found matching the expected signature for payload
The fix is not to disable signature verification. The fix is to read the raw body correctly.
Create a Stripe webhook route with the Next.js App Router
If your app uses the App Router, create this file:
mkdir -p app/api/stripe/webhook
touch app/api/stripe/webhook/route.ts
Add the following route handler:
import Stripe from 'stripe';
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
});
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const body = await req.text();
const headerList = await headers();
const signature = headerList.get('stripe-signature');
if (!signature) {
return NextResponse.json(
{ error: 'Missing Stripe signature' },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
webhookSecret
);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
console.error('Stripe webhook signature verification failed:', message);
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
);
}
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
console.log('Checkout completed:', {
id: session.id,
customer: session.customer,
customerEmail: session.customer_details?.email,
amountTotal: session.amount_total,
});
// TODO: Fulfill the order, activate the subscription,
// grant access, or update your database here.
break;
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object as Stripe.Invoice;
console.log('Invoice paid:', invoice.id);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
console.log('Subscription canceled:', subscription.id);
break;
}
default:
console.log('Unhandled Stripe event:', event.type);
}
return NextResponse.json({ received: true });
} catch (err) {
console.error('Error handling Stripe webhook:', err);
return NextResponse.json(
{ error: 'Webhook handler failed' },
{ status: 500 }
);
}
}
There are a few important details here.
The route calls await req.text() instead of await req.json(). Stripe signature verification requires the raw body string. If you parse JSON first, the payload can change just enough for the signature to fail.
The route returns quickly. Stripe expects a 2xx response when your handler succeeds. If you perform slow work inside the webhook, Stripe may retry the event. In production, it is often better to store the event and process it asynchronously.
The route handles unknown events safely. Stripe may send events you are not using yet. Logging and returning success is usually better than failing on an unrecognized event type.
Pages Router version
If your project still uses the Pages Router, create this file instead:
mkdir -p pages/api/stripe
touch pages/api/stripe/webhook.ts
Use this implementation:
import type { NextApiRequest, NextApiResponse } from 'next';
import Stripe from 'stripe';
import { buffer } from 'micro';
export const config = {
api: {
bodyParser: false,
},
};
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
});
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return res.status(405).end('Method Not Allowed');
}
const signature = req.headers['stripe-signature'];
if (!signature || Array.isArray(signature)) {
return res.status(400).json({ error: 'Missing Stripe signature' });
}
let event: Stripe.Event;
try {
const rawBody = await buffer(req);
event = stripe.webhooks.constructEvent(
rawBody,
signature,
webhookSecret
);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
console.error('Stripe webhook signature verification failed:', message);
return res.status(400).json({ error: 'Invalid signature' });
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
console.log('Checkout completed:', session.id);
break;
}
default:
console.log('Unhandled Stripe event:', event.type);
}
return res.status(200).json({ received: true });
}
Install micro if you use this Pages Router example:
npm install micro
npm install --save-dev @types/micro
The critical setting is bodyParser: false. Without it, Next.js parses the request before Stripe can verify the signature.
Start your Next.js app locally
Run the development server:
npm run dev
Confirm that the app is listening on port 3000:
Local: http://localhost:3000
Do not worry that /api/stripe/webhook returns an error if you visit it in the browser. The route only accepts signed POST requests from Stripe.
Expose localhost with a secure taupi tunnel
Stripe needs a public HTTPS URL. taupi creates a secure tunnel from the internet to your local port.
Install taupi:
curl -fsSL https://taupi.dev/install.sh | bash
Start a tunnel to your Next.js dev server:
taupi tunnel 3000
You should see a public HTTPS URL in the terminal. It will look similar to this:
Forwarding https://example-name.taupi.dev -> http://localhost:3000
Your local webhook URL is now:
https://example-name.taupi.dev/api/stripe/webhook
Keep this terminal running. If you stop the tunnel, Stripe can no longer reach your local app.
The free taupi tier is useful for quick testing because it allows one tunnel with five-minute sessions and does not require signup. For longer webhook debugging sessions, persistent sessions, or a stable custom subdomain, the Pro tier supports five tunnels, persistent sessions, and custom subdomains.
For example, with Pro you can authenticate and start a stable subdomain:
taupi auth your-api-key
taupi tunnel 3000 --subdomain myapp
Then your local webhook URL can stay consistent:
https://myapp.taupi.dev/api/stripe/webhook
That stability is helpful because Stripe webhook endpoint URLs are stored in the Dashboard. If your tunnel URL changes frequently, you need to update the Stripe endpoint each time.
Create the webhook endpoint in Stripe
Open the Stripe Dashboard in test mode.
Go to:
Developers → Webhooks → Add endpoint
For the endpoint URL, paste your taupi URL plus the webhook path:
https://example-name.taupi.dev/api/stripe/webhook
Select the events you want to test. For a checkout flow, start with:
checkout.session.completed
checkout.session.expired
payment_intent.succeeded
payment_intent.payment_failed
For subscriptions, also consider:
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
invoice.payment_succeeded
invoice.payment_failed
After creating the endpoint, Stripe shows a signing secret. It starts with whsec_.
Copy it into .env.local:
STRIPE_SECRET_KEY=sk_test_your_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_endpoint_signing_secret
Restart your Next.js dev server after changing environment variables:
npm run dev
This restart matters. Next.js does not always reload environment variables into server code the way you expect during development.
Send a test webhook from Stripe
In the Stripe Dashboard, open your webhook endpoint and click the option to send a test event.
Choose:
checkout.session.completed
If everything is configured correctly, you should see a successful response in the Stripe Dashboard and logs in your Next.js terminal:
Checkout completed: {
id: 'cs_test_...',
customer: 'cus_...',
customerEmail: '[email protected]',
amountTotal: 2000
}
You should also see traffic in the taupi tunnel terminal, which confirms the request reached your local machine.
If Stripe reports a 400 response, your route likely rejected the signature. If Stripe reports a connection error, your tunnel may be stopped or the endpoint URL may be wrong.
Test with a real local Checkout flow
Dashboard test events are useful, but they do not always contain the exact metadata and object relationships your app depends on. A better test is to create a real Checkout Session from your local app, complete it with a test card, and let Stripe send the resulting webhook back through the tunnel.
Create a checkout route:
mkdir -p app/api/checkout
touch app/api/checkout/route.ts
Add this code:
import Stripe from 'stripe';
import { NextResponse } from 'next/server';
export const runtime = 'nodejs';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
});
export async function POST() {
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [
{
price_data: {
currency: 'usd',
product_data: {
name: 'Local Test Product',
},
unit_amount: 2000,
},
quantity: 1,
},
],
metadata: {
orderId: 'local-order-123',
},
success_url: 'http://localhost:3000/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'http://localhost:3000/cancel',
});
return NextResponse.json({ url: session.url });
}
Now create a simple test page:
export default function HomePage() {
async function startCheckout() {
'use server';
}
return (
<main style={{ padding: 40 }}>
<h1>Stripe Webhook Local Test</h1>
<form action={async () => {
'use server';
}}>
</form>
<button id="checkout-button">Checkout test requires client code</button>
</main>
);
}
For a cleaner client-side test, create a client component:
mkdir -p components
touch components/CheckoutButton.tsx
'use client';
export function CheckoutButton() {
async function handleClick() {
const response = await fetch('/api/checkout', {
method: 'POST',
});
const data = await response.json();
if (!response.ok) {
console.error(data);
return;
}
window.location.href = data.url;
}
return <button onClick={handleClick}>Start Checkout</button>;
}
Use it on your home page:
import { CheckoutButton } from '@/components/CheckoutButton';
export default function HomePage() {
return (
<main style={{ padding: 40 }}>
<h1>Stripe Webhook Local Test</h1>
<CheckoutButton />
</main>
);
}
Start checkout locally at:
http://localhost:3000
Use Stripe's standard test card:
4242 4242 4242 4242
Any future expiration date
Any three-digit CVC
Any postal code
After completing payment, Stripe should send a checkout.session.completed event to your taupi URL, which forwards to your local webhook route.
This is the most realistic local test because it exercises your actual Checkout Session configuration, metadata, payment method behavior, and webhook handler together.
Make webhook handlers idempotent
Stripe can deliver the same webhook event more than once. This is expected behavior, not an error. Your handler should be idempotent, meaning processing the same event repeatedly does not create duplicate orders, grant access twice, or send duplicate emails.
A common pattern is to store processed Stripe event IDs in your database:
async function handleStripeEvent(event: Stripe.Event) {
const existing = await db.stripeEvent.findUnique({
where: { id: event.id },
});
if (existing) {
console.log('Skipping duplicate Stripe event:', event.id);
return;
}
await db.stripeEvent.create({
data: {
id: event.id,
type: event.type,
createdAt: new Date(event.created * 1000),
},
});
// Continue with fulfillment logic.
}
If you use Prisma, the model might look like this:
model StripeEvent {
id String @id
type String
createdAt DateTime
handledAt DateTime @default(now())
}
For local testing, duplicate handling is still worth implementing. You can resend events from the Stripe Dashboard to verify your code behaves correctly.
Debugging common issues
Stripe returns 400 invalid signature
Check these items first:
- You copied the signing secret from the exact webhook endpoint you are using.
- The secret starts with
whsec_, notsk_test_. - You restarted Next.js after editing
.env.local. - Your App Router handler uses
await req.text(). - Your Pages Router handler disables
bodyParser. - You did not manually parse and re-stringify the JSON before verification.
Also remember that every Stripe webhook endpoint has its own signing secret. If you delete and recreate the endpoint, update .env.local.
Stripe cannot connect to the endpoint
Check that your tunnel is still running:
taupi tunnel 3000
Verify the public URL in Stripe exactly matches the active taupi URL and includes the full path:
/api/stripe/webhook
If you are using the free tier and the session expires, start a new tunnel and update the endpoint URL in Stripe. If you want to avoid changing the URL during longer debugging sessions, use an authenticated Pro tunnel with a custom subdomain.
Your handler works with test events but not real Checkout
Dashboard test events are sample payloads. They may not include your specific metadata or object relationships. Complete a real test-mode Checkout Session and inspect the actual event payload.
For checkout.session.completed, metadata added to the Checkout Session appears on the session object:
const session = event.data.object as Stripe.Checkout.Session;
const orderId = session.metadata?.orderId;
If you need line item details, retrieve them from Stripe:
const lineItems = await stripe.checkout.sessions.listLineItems(session.id);
Do this carefully in production. Keep webhook handlers fast and resilient.
The route works locally but fails after deployment
Local tunnel testing proves your handler logic, but production has different environment variables and URLs. Before deploying, confirm:
- Production has its own
STRIPE_WEBHOOK_SECRET. - The production webhook endpoint in Stripe points to your deployed domain.
- You are using test keys for test mode and live keys for live mode.
- The route runtime supports the Stripe SDK. Use Node.js runtime for these examples.
Do not reuse your local webhook signing secret in production.
Practical security tips
A tunnel makes your local server reachable from the internet, so treat it as a real public endpoint while it is running.
Keep these practices in place:
- Verify Stripe signatures for every webhook request.
- Do not expose admin-only development routes while testing.
- Avoid logging full customer objects or payment details.
- Keep
.env.localout of Git. - Stop the tunnel when you are done.
- In Stripe, subscribe only to the event types you need.
Webhook signature verification is your main protection. Anyone can discover or guess a URL, but they cannot produce a valid Stripe signature without the endpoint signing secret.
A repeatable local workflow
Here is the short version you can reuse every time you need to work on Stripe webhooks locally.
Start Next.js:
npm run dev
Start a tunnel:
taupi tunnel 3000
Use the public endpoint in Stripe:
https://your-tunnel-url.taupi.dev/api/stripe/webhook
Copy the endpoint signing secret into .env.local:
STRIPE_WEBHOOK_SECRET=whsec_your_secret
Restart Next.js:
npm run dev
Send a test event or complete a test Checkout payment.
Watch three places while debugging:
- The Stripe webhook attempt log
- The taupi tunnel terminal
- The Next.js server terminal
Those three views tell you where the failure is happening: Stripe delivery, tunnel forwarding, or application code.
Conclusion
Testing Stripe webhooks locally is mostly about making your development machine reachable without compromising the correctness of your webhook implementation. A secure tunnel solves the networking problem, while raw-body signature verification solves the Stripe security problem.
With Next.js, the most important implementation detail is to verify the webhook before parsing the body. In the App Router, use await req.text(). In the Pages Router, disable the body parser and read the raw buffer.
taupi fits naturally into this workflow by giving Stripe a public HTTPS URL that forwards to your local Next.js app. For quick tests, taupi tunnel 3000 is enough. For longer sessions or a stable Stripe endpoint URL, an authenticated tunnel with a custom subdomain avoids repeated Dashboard updates.
Once this workflow is in place, you can test payments, subscriptions, retries, duplicate delivery, and fulfillment logic locally with confidence before shipping webhook code to production.