Guide to Heroku Webhooks: Features and Best Practices
Heroku app webhooks tell you what is happening to your apps: a build starts or fails, a release is created and goes live, a dyno crashes, an add-on is provisioned, a collaborator is added or a domain changes. If you run deploy notifications, audit logs, chat alerts or automation around Heroku, webhooks deliver those changes as they happen instead of you polling the Platform API.
This guide covers how Heroku webhook subscriptions work, the entities you can subscribe to and the payload they share, how to verify the Heroku-Webhook-Hmac-SHA256 header, the two delivery levels and their retry rules, and the best practices for running a Heroku receiver in production.
What are Heroku webhooks?
A Heroku webhook subscription belongs to one app. You choose the entities you care about, such as api:build or api:release, a URL and a delivery level. Heroku then sends a POST to the URL for every create, update or destroy event on those entities.
There is no way to pick individual actions. Heroku says "you automatically receive notifications for all applicable event types (create, destroy, and/or update) related to the entities you subscribe to". A release, for example, always produces at least an api:release create and an api:release update, and three events if you use release phase.
Every notification is signed. Heroku computes an HMAC-SHA256 of the request body with the subscription's secret and sends it, base64-encoded, in Heroku-Webhook-Hmac-SHA256.
Heroku webhook features
| Feature | Details |
|---|---|
| Configuration | Dashboard (More > View Webhooks), heroku webhooks:add, or the Platform API's Webhook Create endpoint |
| Subscriptions | Up to 10 per app, add-on or pipeline |
| Subscription model | Entities (--include). All of each entity's actions are delivered |
| Signature header | Heroku-Webhook-Hmac-SHA256 |
| Scheme | HMAC-SHA256, base64 digest |
| Signed content | The raw request body, nothing else |
| Secret | Per subscription. Set with --secret, or generated and shown once by the CLI |
| Optional header | Authorization, set with --authorization and sent verbatim |
| Delivery levels | notify (never retried) or sync (retried for up to 72 hours) |
| Ordering | Deliveries are ordered, so a retry holds back later notifications |
| Success | Any 2xx. Heroku suggests 204 No Content |
| Deletion | Webhooks that fail continuously for a week get an email, then are deleted |
| Delivery log | heroku webhooks:deliveries or the Delivery List endpoint |
Heroku publishes no source IP list for webhooks, so verify the signature.
Common events
These are the entities you can pass to --include, with the actions Heroku sends for each:
| Entity | Actions | Fires when |
|---|---|---|
api:build | create, update | A build starts, or its status changes |
api:release | create, update | A release starts, or its status changes |
api:app | create, update, destroy | The app is created, changed or deleted |
api:dyno | create | A new dyno starts running |
api:formation | update, destroy | The dyno formation for a process type changes |
api:addon | create, update, destroy | An add-on is provisioned, changed or deleted |
api:addon-attachment | create, destroy | An add-on is attached or removed |
api:collaborator | create, update, destroy | A collaborator is added, changed or removed |
api:domain | create, destroy | A custom domain is added or removed |
api:sni-endpoint | create, update, destroy | An SNI endpoint is added, changed or removed |
Heroku's webhook events reference also documents dyno state changes (crashed, down, starting, stopping, up). In those examples resource is dyno, webhook_ is dyno without the api: prefix, and the state is in data.state.
Every notification has the same envelope. Here is Heroku's example for an api:app update:
{
"action": "update",
"actor": {
"email": "user-0436@example.com",
"id": "096370c8-f3ad-4e20-a309-fb4f06ec0a89"
},
"created_at": "2016-10-26T22:50:14Z",
"id": "d472a8bb-1a3c-4f78-aad1-995e6d0022ec",
"data": {
"id": "d4714cc8-aa56-4314-817c-0c6a66ff3d41",
"name": "sample-app-0301",
"maintenance": false,
"region": { "id": "7044c05a-0873-42c2-abbe-6841c5481ba7", "name": "us" },
"web_url": "https://sample-app-0301.herokuapp.com/"
},
"previous_data": {},
"published_at": null,
"resource": "app",
"sequence": null,
"updated_at": "2016-10-26T22:50:14Z",
"version": "application/vnd.heroku+json; version=3",
"webhook_metadata": {
"attempt": { "id": "8a44f820-2354-489d-9a11-a793cbf49979" },
"delivery": { "id": "d244009a-670f-4340-88e9-789a4f9002d5" },
"event": { "id": "d472a8bb-1a3c-4f78-aad1-995e6d0022ec", "include": "api:app" },
"webhook": { "id": "01234567-89ab-cdef-0123-456789abcdef" }
}
}
The data object is trimmed here. It is the Platform API representation of the resource, previous_data holds the fields that changed on an update, and action is the verb. Identify the event by webhook_ (or resource) plus action. For releases and builds, the interesting value is usually data.status: a release starts as pending and ends as succeeded or failed.
Setting up Heroku webhooks
You can subscribe from the Dashboard (open the app, choose More, then View Webhooks), from the CLI, or through the Platform API. With the CLI:
heroku webhooks:add \
-a my-app \
-i api:build,api:release \
-l sync \
-u https://example.com/webhooks/heroku
If you don't pass -s, the CLI prints a generated signing secret. Heroku says "this value can never be retrieved again, so you must persist it immediately". Store it as HEROKU_WEBHOOK_SECRET. If you lose it, heroku webhooks:update refreshes it.
Choose -l sync for anything you can't afford to miss. With notify, "Heroku does not retry unsuccessful webhook notifications".
To use the Platform API instead, call Webhook Create with the header Accept: application/vnd.heroku+json; version=3.webhooks. Its parameters mirror the CLI flags.
For local development, the Hookdeck CLI (hookdeck listen 3000 heroku --path /webhooks/heroku) gives you a public HTTPS URL that forwards to your local server, with no account required. Subscribe that URL, then deploy to trigger build and release events.
Securing Heroku webhooks
Read Heroku-Webhook-Hmac-SHA256, compute an HMAC-SHA256 of the raw request body with the subscription secret as the key, encode the digest as base64, and compare in constant time. Base64 is case-sensitive, so compare it exactly as sent.
const crypto = require('crypto');
const express = require('express');
function verifyHerokuSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('base64');
const received = Buffer.from(signatureHeader.trim(), 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/heroku', express.raw({ type: '*/*' }), (req, res) => {
if (!verifyHerokuSignature(req.body, req.get('heroku-webhook-hmac-sha256'), process.env.HEROKU_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Route on event.webhook_metadata.event.include and event.action, then answer.
res.sendStatus(204);
});
The same check in Python:
import base64
import hashlib
import hmac
import os
def verify_heroku_signature(raw_body: bytes, signature_header, secret: str) -> bool:
if not signature_header or not secret:
return False
digest = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
expected = base64.b64encode(digest)
return hmac.compare_digest(signature_header.strip().encode("utf-8"), expected)
# In FastAPI: raw_body = await request.body()
# verify_heroku_signature(raw_body, request.headers.get("heroku-webhook-hmac-sha256"),
# os.environ["HEROKU_WEBHOOK_SECRET"])
Heroku's own sample, in Ruby, does the same with Base64.encode64(OpenSSL::HMAC.digest(...)).strip and Rack::Utils.secure_. To check your verifier, use the secret heroku-test-secret and this exact body:
{"action":"update","resource":"app","webhook_metadata":{"event":{"include":"api:app"}}}
The signature should be:
tKnhku7hi+r1L/Sq/gkUn0eLh5M5B8R2QNgPtxsyMfk=
If you subscribed with --authorization, Heroku also sends that value unchanged in the Authorization header. You can check it as a second factor, but it is a static value, not a signature over the body.
Make Heroku webhooks production-ready. Hookdeck Event Gateway verifies
Heroku-Webhook-Hmac-SHA256with your subscription secret, acknowledges each notification straight away, and durably queues build, release and dyno events before your service sees them.
Heroku webhook limitations and pain points
One failing delivery holds up the rest
The Problem: A single notification your endpoint keeps rejecting delays every notification after it, and events arrive hours late.
Why It Happens: Heroku says "notification deliveries are intended to be ordered, all other notifications are delayed during retries". With the sync level, a delivery is retried for up to 72 hours. If the backlog then holds events older than 72 hours, those get one attempt only and are marked skipped if they fail.
Workarounds:
- Return a 2xx for anything you have received and stored, even if you can't process it yet. Use your own queue for retries.
- Never reject a notification because of a bug in your processing code.
- Watch
heroku webhooks:deliveriesforpendingentries piling up.
How Hookdeck Can Help: Hookdeck acknowledges every notification as soon as it arrives, so the Heroku queue keeps moving. Delivery to your service is retried on your own schedule, and a failing event doesn't block the ones behind it.
notify means no retries at all
The Problem: A notification sent while your endpoint was restarting is gone.
Why It Happens: With -l notify, Heroku does not retry. The level is required, and it is easy to pick notify from the documentation's example.
Workarounds:
- Use
syncfor every subscription you depend on. - If you have a
notifysubscription, recreate it withsync. - After an outage, look up missed events with
heroku webhooks:deliveries:infoor the Event Info endpoint.
How Hookdeck Can Help: With a Hookdeck URL as the target, your endpoint's restarts don't matter to Heroku. Hookdeck stores each notification and delivers it once your service is back.
A week of failures deletes the webhook
The Problem: A subscription disappears, and you only notice when deploy notifications stop.
Why It Happens: "Heroku deletes webhooks that fail continuously." After a week of failures it sends an email that includes the deletion date, and if the failures continue, the subscription is removed. A high enough failure rate can also disable a subscription.
Workarounds:
- Route Heroku's emails somewhere you will see them.
- Check
heroku webhooksperiodically, or from a scheduled job, and recreate missing subscriptions.
How Hookdeck Can Help: Hookdeck keeps answering Heroku while your service is down, so the subscription doesn't fail, and Hookdeck issues alert you to the problem on your side.
Several events for one deploy
The Problem: One deploy triggers three release notifications, and your chat channel gets three messages.
Why It Happens: Each release produces api:release create and at least one update. With release phase, Heroku sends create, update, update, and only the final update carries succeeded or failed with current set once the release is promoted.
Workarounds:
- Notify only on
updateevents wheredata.statusissucceededorfailed. - Key notifications on
data.idso each release produces one message.
How Hookdeck Can Help: Hookdeck filters can forward only release updates with a final data.status, so your notification service sees one event per release.
Best practices
Verify against the raw body
Mount express.raw() on the route, read await request.body() in FastAPI, or use Buffer.from(await request.arrayBuffer()) in a Next.js route handler. Parse only after verifying.
Answer with 204
Heroku ignores the response body and says "a 204 status with an empty body is ideal". Any 2xx counts as success.
Deduplicate on the event ID
webhook_, which matches the top-level id in Heroku's example, identifies the event. Store it and skip repeats. See our guide to webhook idempotency.
Route on entity and action
Combine webhook_ (or resource) with action, then read data.status where it matters. Keep a default branch for entities you subscribed to but don't handle.
Acknowledge fast and process asynchronously
Because a slow or failing response delays every later notification, keep the handler to verify, store and answer. See why to process webhooks asynchronously.
Conclusion
Heroku signs every app webhook with a base64 HMAC-SHA256 of the raw body in Heroku-Webhook-Hmac-SHA256. Save the secret when you subscribe, use the sync level, verify against the raw bytes, route on entity and action, and answer every stored notification with a 2xx so one bad event never holds up the ordered queue behind it.
Hookdeck Event Gateway verifies each signature, acknowledges Heroku immediately and holds every notification until your service has processed it, so your outages don't delay later events or get the subscription deleted.
Get started with Hookdeck for free and handle Heroku webhooks reliably in minutes.