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.
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.
PAGERDUTY_WEBHOOK_SECRET=replace-with-your-subscription-secretStart the app on port 3000.
npm run devStart 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-appHORIZON: Tunnel connected
URL https://my-pagerduty-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-pagerduty-appYour public URL is https://my-pagerduty-app.hrzn.run. Keep this terminal open.
Add the webhook subscription in PagerDuty
- In PagerDuty, open Integrations, then Generic Webhooks (v3).
- Select New Webhook.
- Set Webhook URL to
https://my-pagerduty-app.hrzn.run/api/webhooks/pagerduty. - Choose the scope: account, team, or service.
- Under Event Subscription, select the event types you want.
- Save the webhook. A dialog shows the secret. Copy it into
PAGERDUTY_WEBHOOK_SECRETin.env.local.
Restart npm run dev so Next.js loads the new variable.
PagerDuty shows the secret when it creates the subscription. Copy it right away.
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:
POST 200 /api/webhooks/pagerdutyYour 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_SECRETis the secret of this subscription, with no extra spaces or newline. - Hash the raw body. Don't run
JSON.parseandJSON.stringifyfirst. - Compare against every comma-separated value in the header, not only the first.
- Restart
npm run devafter 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 forReconnected. - Check that Webhook URL ends with
/api/webhooks/pagerduty.
Next steps
- Read PagerDuty's guide to verifying webhook signatures.
- Read how PagerDuty handles retries and failures.
Test Linear webhooks locally
Receive Linear webhook events on localhost with a Horizon tunnel, and verify the Linear-Signature header with the Linear SDK.
Test Sentry webhooks locally
Receive Sentry integration platform webhooks on localhost with a Horizon tunnel, and verify the Sentry-Hook-Signature header.