Test Stripe webhooks locally
Receive Stripe webhook events on localhost with a Horizon tunnel, and verify their signatures in a Next.js route handler.
Receive Stripe events on your laptop while you build, with a public HTTPS URL Stripe can reach.
Horizon has no Stripe integration. Stripe sends webhooks to a public URL, and Horizon provides that URL.
Before you begin
- Node.js 18 or later
- A Horizon account and the CLI (see Getting started)
- A Stripe account with a sandbox or test mode to send events from
- A Next.js app that uses the App Router and runs on port 3000
- The Stripe CLI, signed in with
stripe login, to send a test event
Start your app
Install the stripe package:
npm install stripeCreate the route handler. It reads the raw request body with request.text(), then passes the body, the Stripe-Signature header and your endpoint's signing secret to stripe.webhooks.constructEvent. If the signature doesn't match, the handler returns 400.
import Stripe from 'stripe'
import { NextResponse } from 'next/server'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string)
export async function POST(request: Request) {
const signature = request.headers.get('stripe-signature')
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET
if (!signature || !webhookSecret) {
return NextResponse.json({ message: 'Missing signature or secret' }, { status: 400 })
}
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(await request.text(), signature, webhookSecret)
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
console.log(`Webhook signature verification failed: ${message}`)
return NextResponse.json({ message: `Webhook error: ${message}` }, { status: 400 })
}
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data.object as Stripe.PaymentIntent
console.log(`PaymentIntent ${paymentIntent.id} succeeded for ${paymentIntent.amount}`)
}
return NextResponse.json({ message: 'Received' }, { status: 200 })
}Stripe wants a 2xx response quickly, before any slow work. Keep the handler fast.
Add your secret API key to .env.local. Find your keys in the Stripe Dashboard. You add the webhook secret in a later step.
STRIPE_SECRET_KEY=sk_...Start the app:
npm run devStart a tunnel
In a second terminal, open a tunnel to port 3000 and pick a subdomain with -s:
hrzn tunnel http://localhost:3000 -s my-stripe-appYour public URL is https://my-stripe-app.hrzn.run. The terminal prints:
HORIZON: Tunnel connectedUse -s. Without it, the subdomain is random and changes on every run, and you would have to update Stripe each time you restart. Reserving a subdomain keeps it yours across restarts. It's a paid feature, see pricing.
Add the endpoint in Stripe
Your endpoint URL is the tunnel URL plus the route path: https://my-stripe-app.hrzn.run/api/webhooks/stripe.
- Open the Webhooks tab in Workbench.
- Select Create an event destination.
- Select Your account.
- Choose the Snapshot payload format, and select the API version.
constructEventverifies snapshot events. - Select the event type
payment_intent.succeeded. - Select Continue, then select Webhook endpoint as the destination type.
- Select Continue, then enter your URL as the Endpoint URL.
- On the settings page, select Reveal secret and copy the signing secret. It begins with
whsec_.
Verify the signature
Add the signing secret to .env.local:
STRIPE_SECRET_KEY=sk_...
STRIPE_WEBHOOK_SECRET=whsec_...Restart npm run dev so Next.js loads the new variable.
Every endpoint has its own signing secret. The secret from a Dashboard endpoint is different from the one stripe listen prints. Use the secret of the endpoint you registered in the previous step.
Check it works
Send a test event. In a third terminal, run:
stripe trigger payment_intent.succeededStripe CLI prints:
Running fixture for: payment_intent
Trigger succeeded! Check dashboard for event details.The command creates real objects through the Stripe API, so Stripe sends the event to your endpoint like any other. In the terminal that runs hrzn, you see one line for the request:
POST | [200] | /api/webhooks/stripeYour app terminal logs the PaymentIntent ... succeeded line.
To see the delivery on Stripe's side, open your endpoint in Workbench and select the Event deliveries tab. It lists each event as Delivered, Pending or Failed, with the HTTP status code of the attempt.
Troubleshooting
Signature verification fails
The Horizon line shows [400], and your app logs No signatures found matching the expected signature for payload. At least one of the three values you pass to constructEvent is wrong. Check them in this order:
- The secret. Print
STRIPE_WEBHOOK_SECRETand compare it with the secret of your endpoint in Workbench. Did you restart the dev server after you edited.env.local? - The body. Stripe needs the exact raw body. Read it with
await request.text(). Don't callrequest.json()first, and don't re-serialize the body. - The header. The
Stripe-Signaturevalue looks liket=...,v1=...,v0=....
The request returns 404
The Horizon line shows [404]. The path in the Stripe endpoint URL doesn't match your route. The route file app/api/webhooks/stripe/route.ts serves /api/webhooks/stripe. Check the endpoint URL for typos, and make sure the file exports POST. Stripe sends a POST request.
The URL changed after a restart
You started the tunnel without -s, so Horizon gave you a new random subdomain. Stripe still sends events to the old URL. Restart with -s, and register the new URL as your Stripe endpoint if it differs from the one Stripe has.
Nothing arrives
Check that the event type you trigger matches an event type the endpoint listens to. Check that the Horizon tunnel and npm run dev are both running.
Next steps
- Read Stripe's guide to receiving events in your webhook endpoint.
- See the other event types you can send with
stripe trigger. - Reserve a subdomain so your URL never changes: see pricing.