Gareth Wilson Gareth Wilson

Guide to Heroku Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationDashboard (More > View Webhooks), heroku webhooks:add, or the Platform API's Webhook Create endpoint
SubscriptionsUp to 10 per app, add-on or pipeline
Subscription modelEntities (--include). All of each entity's actions are delivered
Signature headerHeroku-Webhook-Hmac-SHA256
SchemeHMAC-SHA256, base64 digest
Signed contentThe raw request body, nothing else
SecretPer subscription. Set with --secret, or generated and shown once by the CLI
Optional headerAuthorization, set with --authorization and sent verbatim
Delivery levelsnotify (never retried) or sync (retried for up to 72 hours)
OrderingDeliveries are ordered, so a retry holds back later notifications
SuccessAny 2xx. Heroku suggests 204 No Content
DeletionWebhooks that fail continuously for a week get an email, then are deleted
Delivery logheroku 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:

EntityActionsFires when
api:buildcreate, updateA build starts, or its status changes
api:releasecreate, updateA release starts, or its status changes
api:appcreate, update, destroyThe app is created, changed or deleted
api:dynocreateA new dyno starts running
api:formationupdate, destroyThe dyno formation for a process type changes
api:addoncreate, update, destroyAn add-on is provisioned, changed or deleted
api:addon-attachmentcreate, destroyAn add-on is attached or removed
api:collaboratorcreate, update, destroyA collaborator is added, changed or removed
api:domaincreate, destroyA custom domain is added or removed
api:sni-endpointcreate, update, destroyAn 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_metadata.event.include 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_metadata.event.include (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_compare. 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-SHA256 with 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:deliveries for pending entries 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 sync for every subscription you depend on.
  • If you have a notify subscription, recreate it with sync.
  • After an outage, look up missed events with heroku webhooks:deliveries:info or 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 webhooks periodically, 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 update events where data.status is succeeded or failed.
  • Key notifications on data.id so 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_metadata.event.id, 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_metadata.event.include (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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.