Horizon

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/stripe

The terminal prints your signing secret:

Output
> Ready! Your webhook signing secret is whsec_abcdefg1234567

Use 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 with stripe trigger

Start your app

Install the stripe package:

npm install stripe

Create 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.

app/api/webhooks/stripe/route.ts
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.

.env.local
STRIPE_SECRET_KEY=sk_...

Start the app:

npm run dev

Start 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-app

Your public URL is https://my-stripe-app.hrzn.run. The terminal prints:

Output
HORIZON: Tunnel connected
  URL          https://my-stripe-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-stripe-app

Use -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.

  1. Open the Webhooks tab in Workbench.
  2. Select Create an event destination.
  3. Select Your account.
  4. Choose the Snapshot payload format, and select the API version. constructEvent verifies snapshot events.
  5. Select the event type payment_intent.succeeded.
  6. Select Continue, then select Webhook endpoint as the destination type.
  7. Select Continue, then enter your URL as the Endpoint URL.
  8. 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:

.env.local
STRIPE_SECRET_KEY=sk_...
STRIPE_WEBHOOK_SECRET=whsec_...

Restart npm run dev so Next.js loads the new variable.

Check it works

Send a test event. In a third terminal, run:

stripe trigger payment_intent.succeeded

Stripe CLI prints:

Output
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:

Output
  POST    200  /api/webhooks/stripe

Your 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:

  1. The secret. Print STRIPE_WEBHOOK_SECRET and compare it with the secret of your endpoint in Workbench. Did you restart the dev server after you edited .env.local?
  2. The body. Stripe needs the exact raw body. Read it with await request.text(). Don't call request.json() first, and don't re-serialize the body.
  3. The header. The Stripe-Signature value looks like t=...,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

On this page