Test Sentry webhooks locally
Receive Sentry integration platform webhooks on localhost with a Horizon tunnel, and verify the Sentry-Hook-Signature header.
Receive Sentry events on your laptop while you build, with a URL Sentry can reach.
Horizon has no Sentry integration. Sentry 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 Sentry organization where you can create integrations
Start your app
Sentry signs every webhook from an integration with your integration's Client Secret. It sends an HMAC (a keyed hash) in the Sentry-Hook-Signature header. The HMAC uses SHA-256 and is a hex digest of JSON.stringify(request.body).
The route handler below parses the body, stringifies it again as Sentry's docs do, and compares the digests with crypto.timingSafeEqual. Sentry also sends the resource type in the Sentry-Hook-Resource header.
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(request: Request) {
const secret = process.env.SENTRY_CLIENT_SECRET;
if (!secret) {
return new Response("Missing SENTRY_CLIENT_SECRET", { status: 500 });
}
const body = await request.json();
const received = request.headers.get("sentry-hook-signature") ?? "";
const expected = createHmac("sha256", secret)
.update(JSON.stringify(body), "utf8")
.digest("hex");
const receivedBuffer = Buffer.from(received);
const expectedBuffer = Buffer.from(expected);
const isValid =
receivedBuffer.length === expectedBuffer.length &&
timingSafeEqual(receivedBuffer, expectedBuffer);
if (!isValid) {
return new Response("Invalid signature", { status: 401 });
}
const resource = request.headers.get("sentry-hook-resource");
console.log(`Received Sentry webhook: ${resource} ${body.action}`);
return new Response("ok", { status: 200 });
}Sentry expects a response within 1 second. Otherwise it counts the request as a timeout. Keep the handler fast.
You get the Client Secret in a later step. Add it to .env.local then.
SENTRY_CLIENT_SECRET=replace-with-your-client-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 the Webhook URL in Sentry would point at a dead URL after a restart. Reserved subdomains are a paid feature, see Pricing.
hrzn tunnel http://localhost:3000 -s my-sentry-appHORIZON: Tunnel connected
URL https://my-sentry-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-sentry-appYour public URL is https://my-sentry-app.hrzn.run. Keep this terminal open.
Create an internal integration in Sentry
An internal integration gives one organization signed webhooks. Creating it installs it on your organization.
- In Sentry, select Settings, then Developer Settings.
- Select Internal Integration, then Next.
- Enter a name.
- Set Webhook URL to
https://my-sentry-app.hrzn.run/api/webhooks/sentry. - Under Permissions, set Issue & Event to Read & Write.
- Under Webhooks, select issue.
- Select Save Changes.
- Under Credentials, copy the Client Secret into
SENTRY_CLIENT_SECRETin.env.local.
Restart npm run dev so Next.js loads the new variable.
To also receive alert notifications, turn on the Alert Rule Action toggle on the integration. The integration then shows up as an action in issue alerts and metric alerts.
Sentry sends no webhooks until you set a Webhook URL. Sentry doesn't document a button that resends a delivery.
Trigger an event
Cause an event that the integration subscribes to. For the issue resource, create or change an issue in a project, for example by resolving one. Sentry sends one request per event.
Check it works
In the terminal that runs hrzn, you see one line for each delivery:
POST 200 /api/webhooks/sentryYour app terminal prints a line such as:
Received Sentry webhook: issue resolvedIf the line shows [401], see Troubleshooting.
Troubleshooting
The signature doesn't match
- Check that
SENTRY_CLIENT_SECRETis the Client Secret of this integration, with no extra spaces or newline. - Restart
npm run devafter you edit.env.local. - Sentry's documented check hashes
JSON.stringify(request.body), not the raw bytes. The handler above does the same. If you switch to the raw body and the digests differ, return to the stringified body.
Sentry reports a timeout
Sentry waits 1 second for a response. Return 200 first and do slow work after.
The URL changed after a restart
You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-sentry-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/sentry. - Check that the integration subscribes to the resource you trigger.
Next steps
- Read Sentry's reference for integration platform webhooks.
- Reserve a subdomain so your URL never changes: see pricing.
Test PagerDuty webhooks locally
Receive PagerDuty V3 webhook events on localhost with a Horizon tunnel, and verify the X-PagerDuty-Signature header.
Test Sonatype Nexus Repository webhooks locally
Receive Sonatype Nexus Repository webhook events on localhost with a Horizon tunnel, and verify the X-Nexus-Webhook-Signature header.