# 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_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:

```json
{
  "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:

```bash
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](/docs/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.

```javascript
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:

```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](/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](/docs/retries), 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](/docs/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](/docs/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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Heroku webhooks reliably in minutes.