Horizon

Test PagerDuty webhooks locally

Receive PagerDuty V3 webhook events on localhost with a Horizon tunnel, and verify the X-PagerDuty-Signature header.

Receive PagerDuty events on your laptop while you build, with a URL PagerDuty can reach.

Horizon has no PagerDuty integration. PagerDuty 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 reserved subdomain for -s. Reserve one on the Subdomains page.
  • A Next.js app that uses the App Router and runs on port 3000
  • A PagerDuty account where you can add webhook subscriptions

Start your app

PagerDuty signs each V3 webhook with a secret it generates when you create the subscription. It sends the signature in the X-PagerDuty-Signature header. The header holds one or more comma-separated values like v1=<hex digest>. PagerDuty sends several values while you rotate a secret, so accept the request when any value matches. Each value is an HMAC (a keyed hash) of the raw request body, using SHA-256, as a hex digest.

PagerDuty has no Node SDK helper for this. Its docs use the crypto module.

app/api/webhooks/pagerduty/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST(request: Request) {
  const secret = process.env.PAGERDUTY_WEBHOOK_SECRET;
  if (!secret) {
    return new Response("Missing PAGERDUTY_WEBHOOK_SECRET", { status: 500 });
  }

  const body = await request.text();
  const header = request.headers.get("x-pagerduty-signature") ?? "";
  const expected = Buffer.from(
    `v1=${createHmac("sha256", secret).update(body).digest("hex")}`,
  );

  const isValid = header.split(",").some((signature) => {
    const received = Buffer.from(signature.trim());
    return received.length === expected.length && timingSafeEqual(received, expected);
  });

  if (!isValid) {
    return new Response("Invalid signature", { status: 401 });
  }

  const payload = JSON.parse(body);
  console.log(`Received PagerDuty event: ${payload.event?.event_type}`);

  return new Response("ok", { status: 200 });
}

PagerDuty expects a 2xx response within 5 seconds. Keep the handler fast.

You get the secret in a later step. Add it to .env.local then.

.env.local
PAGERDUTY_WEBHOOK_SECRET=replace-with-your-subscription-secret

Start the app on port 3000.

npm run dev

Start a tunnel

Use -s with a subdomain you reserved. Without it, the subdomain is random and changes every run, so your PagerDuty subscription would point at a dead URL after a restart. Reserved subdomains are a paid feature, see Pricing.

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

Your public URL is https://my-pagerduty-app.hrzn.run. Keep this terminal open.

Add the webhook subscription in PagerDuty

  1. In PagerDuty, open Integrations, then Generic Webhooks (v3).
  2. Select New Webhook.
  3. Set Webhook URL to https://my-pagerduty-app.hrzn.run/api/webhooks/pagerduty.
  4. Choose the scope: account, team, or service.
  5. Under Event Subscription, select the event types you want.
  6. Save the webhook. A dialog shows the secret. Copy it into PAGERDUTY_WEBHOOK_SECRET in .env.local.

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

Send a test event

Open your new webhook, scroll to Test, and select Send Test Event. Confirm when PagerDuty asks.

Check it works

In the terminal that runs hrzn, you see one line for the delivery:

Output
  POST    200  /api/webhooks/pagerduty

Your app terminal prints Received PagerDuty event: followed by the event type. Every V3 payload carries one event object with an event_type, for example incident.priority_updated.

If the line shows [401], see Troubleshooting.

Troubleshooting

The signature doesn't match

  • Check that PAGERDUTY_WEBHOOK_SECRET is the secret of this subscription, with no extra spaces or newline.
  • Hash the raw body. Don't run JSON.parse and JSON.stringify first.
  • Compare against every comma-separated value in the header, not only the first.
  • Restart npm run dev after you edit .env.local.

PagerDuty stops sending events

PagerDuty retries timeouts, 5xx and 429 responses for up to 48 hours. It drops other 4xx responses without a retry. After 3 consecutive delivery failures, it disables the subscription for 24 hours. Fix the handler, then send a new test event.

The URL changed after a restart

You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-pagerduty-app and the URL stays the same. -s needs a subdomain you reserved, see Pricing.

Nothing reaches your app

  • Check that the Horizon terminal is still running. If its last line is Connection lost. Reconnecting…, wait for Reconnected.
  • Check that Webhook URL ends with /api/webhooks/pagerduty.

Next steps

On this page