Test Stripe webhooks locally
Test Stripe webhooks locally with a real public endpoint on a Horizon tunnel, and verify signatures in a Next.js route. Know when stripe listen is enough.
Receive Stripe events on your laptop through a real, registered webhook endpoint with a public HTTPS URL.
Horizon has no Stripe integration. Stripe sends webhooks to a public URL, and Horizon provides that URL.
Stripe CLI or a Horizon tunnel?
For quick, solo testing, use the Stripe CLI. stripe listen --forward-to connects to Stripe's API and forwards events to your local server. You don't register an endpoint in the Dashboard, and you don't need a public URL. The signing secret it prints doesn't change between restarts of listen.
stripe listen --forward-to localhost:3000/api/webhooks/stripeThe terminal prints your signing secret:
> Ready! Your webhook signing secret is whsec_abcdefg1234567Use that secret as STRIPE_WEBHOOK_SECRET and skip the rest of this guide. Stripe documents the same flow for Connect events with --forward-connect-to, so a Connect platform doesn't need a tunnel for that alone.
Use a Horizon tunnel when you want the endpoint itself to be real:
- You test the endpoint registration. Stripe requires registered webhook endpoints to be publicly accessible HTTPS URLs, and gives each endpoint its own signing secret. A tunnel lets you register an endpoint in Workbench and check that its event types and API version are the ones your code expects.
- You want to see delivery on Stripe's side. A registered endpoint has an Event deliveries tab with the status and HTTP status code of each attempt, and Stripe can resend events to it.
- The URL has to stay the same. A reserved subdomain gives you one URL across restarts, so the endpoint you register keeps working.
- Other people or services need the URL. A teammate, a staging-like setup or another service's webhooks can use the same tunnel URL.
If none of these apply, stripe listen is the shorter path.
Before you begin
- Node.js 18 or later
- A Horizon account and the CLI (see Getting started)
- A reserved subdomain for
-s. Reserve one on the Subdomains page. - 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 withstripe trigger
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 on a subdomain you reserved, 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 connected
URL https://my-stripe-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-stripe-appUse -s. Without it, the subdomain is random and changes on every run, and you would have to update Stripe each time you restart. Reserve it first on the Subdomains page. Reserved subdomains are 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.
Stripe generates a unique signing secret for each endpoint. Use the secret of the endpoint you registered in the previous step, not the one stripe listen prints.
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 every flag of
stripe listen, including--eventsand--latest. - See the other event types you can send with
stripe trigger. - Reserve a subdomain so your URL never changes: see pricing.
Test Square webhooks locally
Receive Square webhook events on localhost with a Horizon tunnel, and verify the x-square-hmacsha256-signature header.
Test Worldline webhooks locally
Receive Worldline Direct webhook events on localhost with a Horizon tunnel, and verify the X-GCS-Signature header in a Next.js route handler.