Horizon

Test Heroku app webhooks locally

Receive Heroku app webhook notifications on localhost with a Horizon tunnel, and verify the Heroku-Webhook-Hmac-SHA256 header.

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

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 Heroku app and the Heroku CLI, signed in
  • A Next.js app that uses the App Router and runs on port 3000

Start your app

Heroku signs each notification with the secret you set when you create the subscription. It sends the HMAC-SHA256 digest of the raw request body in the Heroku-Webhook-Hmac-SHA256 header, base64 encoded. Read the raw body with request.text() and compare in constant time.

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

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

  const body = await request.text();
  const received = request.headers.get("heroku-webhook-hmac-sha256") ?? "";
  const expected = createHmac("sha256", secret).update(body).digest("base64");

  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 payload = JSON.parse(body);
  console.log(`Received Heroku event: ${payload.resource} ${payload.action}`);

  return new Response(null, { status: 204 });
}

Heroku wants a 2xx response. It names 204 No Content as the ideal answer.

Pick a secret and store it in an environment variable. Use a random, high-entropy string.

.env.local
HEROKU_WEBHOOK_SECRET=replace-with-a-long-random-string

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 Heroku subscription would point at a dead URL after a restart. Reserve it first on the Subdomains page. Reserved subdomains are a paid feature, see Pricing.

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

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

Add the webhook in Heroku

Create the subscription with the Heroku CLI. The flags -i, -l and -u are required.

heroku webhooks:add -a your-heroku-app -i api:release -l notify -s "$HEROKU_WEBHOOK_SECRET" -u https://my-heroku-app.hrzn.run/api/webhooks/heroku
  • -i is the list of events. api:release covers new releases and release status changes.
  • -l notify sends each notification once. -l sync retries failed notifications for up to 72 hours.
  • -s sets the signing secret. If you leave it out, Heroku generates one and prints it as Webhooks Signing Secret. Copy it into .env.local and restart npm run dev.
  • -u is your tunnel URL plus the route path.

You can also open your app in the Heroku Dashboard, open the dropdown below More, and select View Webhooks. That page creates and manages subscriptions.

To check the subscription, list your webhooks:

heroku webhooks -a your-heroku-app

Trigger an event

Heroku has no test or resend button for app webhooks. Trigger a real event instead. With api:release, deploy your app to create a new release.

To see what Heroku sent and how your endpoint answered, list the deliveries and look one up:

heroku webhooks:deliveries -a your-heroku-app
heroku webhooks:deliveries:info DELIVERY_ID -a your-heroku-app

A delivery has the status pending, success, failure or skipped.

Check it works

After the release, your Horizon terminal prints one line:

Output
  POST    204  /api/webhooks/heroku

Your app terminal prints:

Output
Received Heroku event: release create

heroku webhooks:deliveries shows the delivery as success. If the line shows [401], see Troubleshooting.

Troubleshooting

The signature doesn't match

The Horizon line shows [401]. Check these in order:

  • HEROKU_WEBHOOK_SECRET is the secret you passed to -s, or the one Heroku printed. They must match exactly.
  • The handler encodes the digest as base64. Hex encoding fails.
  • The handler hashes the raw body. Don't run JSON.parse and JSON.stringify first, because that can change the bytes.
  • You restarted npm run dev after you edited .env.local.

Heroku retries and delays other notifications

With -l sync, Heroku retries failed deliveries for up to 72 hours. Heroku delivers notifications in order, so a failing endpoint delays the next ones. Fix the endpoint, or switch to -l notify while you develop.

The URL changed after a restart

You started the tunnel without -s, so Horizon gave you a new random subdomain. Heroku still sends events to the old URL. Restart with -s my-heroku-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 the -u URL ends with /api/webhooks/heroku.
  • Check that the event you trigger matches an entity in -i.
  • Run heroku webhooks:deliveries to see whether Heroku tried.

Next steps

  • Read Heroku's app webhooks article for the full list of events.
  • Use the -t flag to add a custom Authorization header, if you want a second check.

On this page