Horizon
GuidesWebhooks

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 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 and pick a subdomain with -s:

hrzn tunnel http://localhost:3000 -s my-stripe-app

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

HORIZON: Tunnel connected

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

  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:

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