Horizon

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.

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

.env.local
SENTRY_CLIENT_SECRET=replace-with-your-client-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 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-app
Output
HORIZON: Tunnel connected
  URL          https://my-sentry-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-sentry-app

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

  1. In Sentry, select Settings, then Developer Settings.
  2. Select Internal Integration, then Next.
  3. Enter a name.
  4. Set Webhook URL to https://my-sentry-app.hrzn.run/api/webhooks/sentry.
  5. Under Permissions, set Issue & Event to Read & Write.
  6. Under Webhooks, select issue.
  7. Select Save Changes.
  8. Under Credentials, copy the Client Secret into SENTRY_CLIENT_SECRET in .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.

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:

Output
  POST    200  /api/webhooks/sentry

Your app terminal prints a line such as:

Output
Received Sentry webhook: issue resolved

If the line shows [401], see Troubleshooting.

Troubleshooting

The signature doesn't match

  • Check that SENTRY_CLIENT_SECRET is the Client Secret of this integration, with no extra spaces or newline.
  • Restart npm run dev after 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 for Reconnected.
  • Check that Webhook URL ends with /api/webhooks/sentry.
  • Check that the integration subscribes to the resource you trigger.

Next steps

On this page