Heroku Integration
Receive and route Heroku app webhooks for releases, builds, dyno state and formation changes.
Setup
1. Create a Source in Hookbase
curl -X POST https://api.hookbase.app/api/sources \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Heroku Production",
"slug": "heroku",
"provider": "heroku",
"signingSecret": "your-heroku-webhook-secret"
}'Save your webhook URL:
https://api.hookbase.app/ingest/{orgSlug}/heroku2. Create the Heroku Webhook
Heroku app webhooks are created per app, with the Heroku CLI (heroku webhooks:add) or the Platform
API.
- Create the webhook against the app you want to watch, with your Hookbase ingest URL as its URL
- List the entity types you want included; a webhook with no matching entities never fires
- Read the signing secret out of the response — it is returned when the webhook is created — and
store it on your Hookbase source as
signingSecret
Warning
The signing secret is shown when the webhook is created, and each webhook has its own. If you lose
it, the fix is to rotate or recreate the webhook, not to look it up. A source holding the secret of a
different webhook produces a well-formed digest that never matches, and every event arrives with
signature_valid: false.
That also means one Hookbase source per Heroku webhook. Pointing two apps' webhooks at the same source leaves the second one's traffic unverifiable.
See Heroku's app webhooks documentation for the entity types and the create call.
3. Create Destinations and Routes
# Create a destination
curl -X POST https://api.hookbase.app/api/destinations \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Deploy Tracker", "slug": "heroku-deploys", "url": "https://api.myapp.com/webhooks/heroku"}'
# Create a route
curl -X POST https://api.hookbase.app/api/routes \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Heroku to Deploy Tracker", "sourceId": "src_...", "destinationId": "dst_..."}'A route carries one destination. To send releases to both a deploy tracker and a chat notifier, create two routes over the same source.
Signature Verification
Heroku signs the raw request body with HMAC-SHA256 and sends the base64 digest in the
Heroku-Webhook-Hmac-SHA256 header. There is no prefix and no timestamp — the header value is the
digest and nothing else:
Heroku-Webhook-Hmac-SHA256: 5FvJ9hOu/uBUWdR/v8EVsqM9TOpvrVo5g7dcyopOMkw=The secret is used exactly as you stored it — its UTF-8 bytes are the HMAC key. Nothing is stripped from it and nothing is base64-decoded first; only the digest is base64, not the key.
Hookbase verifies this automatically once the source has both fields set:
{
"provider": "heroku",
"signingSecret": "your-heroku-webhook-secret"
}Info
SHA256 in the header name describes the hash, not the encoding. The digest is base64 — 44
characters ending in = — not the 64 hex characters the name suggests. If you verify by hand, take
the HMAC as bytes and base64-encode it:
openssl dgst -sha256 -hmac "$SECRET" -binary | base64. Hex-encoding gives you a value that can never
match, and the failure looks identical to a wrong secret.
There is no timestamp in this scheme, so nothing expires and nothing needs a clock tolerance. That also means a captured request stays valid forever if it is replayed: Hookbase applies its own replay protection over the body hash for signed sources, but the signature alone does not distinguish a replay from the original.
Once you have confirmed events are arriving with signature_valid: true, set
rejectInvalidSignatures: true on the source to have unverified requests refused with 401 instead
of stored.
Common Events
Heroku names events by entity, with the specific change in action.
| Event | Description |
|---|---|
api:release | A new release was created |
api:build | A build started or completed |
api:app | An app was updated |
api:dyno | A dyno state changed |
api:formation | Dyno formation was changed (scaled) |
api:addon | An add-on was provisioned or changed |
api:domain | A custom domain was added or removed |
Release Created
{
"id": "evt-abc123",
"action": "create",
"resource": "release",
"data": {
"id": "rel-def456",
"version": 42,
"description": "Deploy abc123de",
"status": "succeeded",
"app": { "id": "app-ghi789", "name": "my-api-prod" },
"user": { "email": "dev@example.com" },
"created_at": "2024-01-15T10:30:00Z"
},
"published_at": "2024-01-15T10:30:01Z"
}Build Updated
{
"id": "evt-def456",
"action": "update",
"resource": "build",
"data": {
"id": "bld-jkl012",
"status": "succeeded",
"app": { "id": "app-ghi789", "name": "my-api-prod" },
"buildpacks": [{ "url": "heroku/nodejs" }],
"created_at": "2024-01-15T10:25:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
}Transform Examples
A javascript transform receives the parsed payload and returns the body Hookbase delivers.
Deploy Record
function transform(payload) {
const d = payload.data || {};
return {
event_id: payload.id,
resource: payload.resource,
action: payload.action,
app_name: d.app ? d.app.name : null,
app_id: d.app ? d.app.id : null,
version: d.version || null,
status: d.status || null,
description: d.description || null,
actor: d.user ? d.user.email : null,
published_at: payload.published_at || d.created_at || null
};
}Slack Alert on a Failed Release or Build
function transform(payload) {
const d = payload.data || {};
const status = d.status || "unknown";
return {
text: `Heroku ${payload.resource} ${status}: ${d.app ? d.app.name : "unknown app"}`,
blocks: [
{
type: "section",
fields: [
{ type: "mrkdwn", text: `*App:*\n${d.app ? d.app.name : "unknown"}` },
{ type: "mrkdwn", text: `*Resource:*\n${payload.resource}` },
{ type: "mrkdwn", text: `*Status:*\n${status}` },
{ type: "mrkdwn", text: `*Version:*\n${d.version || "n/a"}` },
{ type: "mrkdwn", text: `*Actor:*\n${d.user ? d.user.email : "unknown"}` }
]
}
]
};
}Pair this with the failures filter below rather than returning early from the transform. A transform
always produces a body — a return of nothing still delivers — so deciding whether to deliver
belongs to the route's filter, and deciding what to deliver belongs to the transform.
Filter Examples
Filter conditions read dotted paths out of the payload. logic must be AND or OR.
Releases Only
{
"name": "Releases Only",
"logic": "AND",
"conditions": [
{
"field": "resource",
"operator": "equals",
"value": "release"
}
]
}One App
{
"name": "Production App",
"logic": "AND",
"conditions": [
{
"field": "data.app.name",
"operator": "equals",
"value": "my-api-prod"
}
]
}Failures Across Builds and Releases
{
"name": "Failures",
"logic": "AND",
"conditions": [
{
"field": "data.status",
"operator": "equals",
"value": "failed"
}
]
}Headers
| Header | Description |
|---|---|
Heroku-Webhook-Hmac-SHA256 | Base64 HMAC-SHA256 of the raw body, with no prefix |
Hookbase does not forward these headers to your destination. A delivery is a fresh request:
Hookbase sets Content-Type, User-Agent: Hookbase/1.0, X-Delivery-ID and X-Event-ID, then
adds the headers and auth you configured on the destination. Nothing Heroku sent reaches your
handler as a header — read what you need out of the body, or set it on the destination yourself.
Of the incoming headers, only content-type, user-agent, x-github-event, x-gitlab-event and
stripe-signature are stored on the event, so those are the only ones visible later in the
dashboard or the API.
Troubleshooting
Signature Verification Failed
- Confirm you are comparing base64, not hex. See the note above — the header name mentions SHA256 and invites the wrong encoding
- Confirm the source's
providerisheroku. A source left oncustomreadsX-Signature,X-Webhook-SignatureandX-Hub-Signature-256and expects hex, soHeroku-Webhook-Hmac-SHA256is never read at all and nothing verifies - Confirm
signingSecretis the secret returned for this webhook. Recreating a webhook issues a new secret, and the old one keeps producing well-formed mismatches - Confirm you are hashing the raw body. A proxy that reformats JSON on the way through invalidates the digest
No Events Arriving
- Check the webhook's included entities cover what you are doing. A webhook watching
api:releasestays silent during config-var changes - Confirm the webhook's URL matches your ingest URL exactly, including the org and source slugs
- Confirm the webhook is attached to the app you are deploying — app webhooks are per app, not per account
Duplicate Events
Heroku puts an id on every event. Hookbase has no provider event-id extractor for Heroku, so the
default auto dedup strategy falls back to hashing the payload — which is close to equivalent here,
because id is part of the body. Use payload.id in your handler as the idempotency key. Note that
api:build and api:dyno legitimately fire several times for one operation, with different bodies, so
those are not duplicates even though they look repetitive.