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.
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.
HEROKU_WEBHOOK_SECRET=replace-with-a-long-random-stringStart 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 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-appHORIZON: Tunnel connected
URL https://my-heroku-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-heroku-appYour 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-iis the list of events.api:releasecovers new releases and release status changes.-l notifysends each notification once.-l syncretries failed notifications for up to 72 hours.-ssets the signing secret. If you leave it out, Heroku generates one and prints it as Webhooks Signing Secret. Copy it into.env.localand restartnpm run dev.-uis 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-appTrigger 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-appA delivery has the status pending, success, failure or skipped.
Check it works
After the release, your Horizon terminal prints one line:
POST 204 /api/webhooks/herokuYour app terminal prints:
Received Heroku event: release createheroku 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_SECRETis 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.parseandJSON.stringifyfirst, because that can change the bytes. - You restarted
npm run devafter 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 forReconnected. - Check that the
-uURL ends with/api/webhooks/heroku. - Check that the event you trigger matches an entity in
-i. - Run
heroku webhooks:deliveriesto see whether Heroku tried.
Next steps
- Read Heroku's app webhooks article for the full list of events.
- Use the
-tflag to add a customAuthorizationheader, if you want a second check.
Test GitLab webhooks locally
Receive GitLab webhook events on localhost with a Horizon tunnel, and verify the webhook-signature header in a Next.js route handler.
Test Jira Cloud webhooks locally
Receive Jira Cloud webhook events on localhost with a Horizon tunnel, and verify the X-Hub-Signature header.