Notifications & webhook payload reference
Add a notification channel from Notification Channels in the dashboard — a webhook, a Slack message, or an email. This page is for whoever's writing the code on the receiving end: the exact payload shape, how to verify it's really from us, and what happens on a failed delivery.
When we send something
incident.created— a new incident was opened, automatically or by a team memberincident.updated— an incident's status changed, including resolutiondegradation.detected— a service moved from Operational into a non-Operational statecertificate.expiring_soon— a monitored TLS certificate is nearing expiry (Pro/Business)certificate.renewed— a previously-expiring certificate was renewed (Pro/Business)
Choose which of these a channel fires on when you create or edit it. Slack and email support customer- authored message templates per event type — see templates & variables below. A webhook always gets the fixed JSON body described here — it's a machine-consumed integration payload, not something a template renders.
Payload shape
Every webhook delivery is a POST with this envelope:
{
"event": "incident.created",
"org_id": "your-org-uuid",
"occurred_at": "2026-08-23T14:02:11Z",
"data": { ... shape depends on "event", see below ... }
}
data by event type:
| event | data fields |
|---|---|
incident.created |
id, title, severity (nullable), service_ids (array) |
incident.updated |
id, status, severity (nullable),
updated_by (nullable — absent/null on an automatic resolution)
|
degradation.detected |
service_id, service_name |
certificate.expiring_soon,certificate.renewed |
monitored_certificate_id, expires_at, days_remaining (signed
integer, can be negative) — both absent from the object entirely (not present as
null) if the certificate probe itself failed to get a reading. Guard with
if data.expires_at is not None-style checks, not an equality check against null.
|
Example — incident.created:
{
"event": "incident.created",
"org_id": "5d6b1e2a-...",
"occurred_at": "2026-08-23T14:02:11Z",
"data": {
"id": "9c1f0a4e-...",
"title": "API returning 502s",
"severity": "major",
"service_ids": ["a1b2c3d4-..."]
}
}
Verifying the signature
Every delivery carries two headers, computed from the signing secret shown once when you create the channel:
X-HeimPulse-Signature: sha256=<hex-encoded HMAC-SHA256>X-HeimPulse-Timestamp: <unix seconds>
The signed content is "{timestamp}." concatenated with the raw request body (not the
body alone) — tying the timestamp into what's signed is what lets you reject an old, replayed delivery by
checking X-HeimPulse-Timestamp against the current time, in addition to verifying the
signature itself. Always verify against the raw bytes of the request body, before any
JSON parsing — a re-serialized copy isn't guaranteed to produce the same bytes.
Node.js:
const crypto = require("crypto");
function verify(secret, rawBody, timestampHeader, signatureHeader) {
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestampHeader}.`)
.update(rawBody)
.digest("hex");
const provided = signatureHeader.replace(/^sha256=/, "");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
}
Python:
import hashlib
import hmac
def verify(secret: str, raw_body: bytes, timestamp_header: str, signature_header: str) -> bool:
expected = hmac.new(
secret.encode(),
f"{timestamp_header}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
provided = signature_header.removeprefix("sha256=")
return hmac.compare_digest(expected, provided)
Both use a constant-time comparison (timingSafeEqual / compare_digest) — a plain
===/== string comparison leaks timing information an attacker can use to guess
the correct signature byte by byte. Don't swap it for a simple equality check.
Retries and timeouts
Any 2xx response counts as a successful delivery. Anything else — including a timeout
(10 seconds) — is retried up to 5 attempts total, with increasing delay between
attempts: 1 minute, 5 minutes, 30 minutes, then 2 hours before the final attempt. After the 5th failure,
the delivery is dropped — nothing further is retried for that event.
Respond quickly (a bare 200 is enough) and do any slow processing asynchronously on your
side — a receiving endpoint that's slow to respond looks identical to one that's down, from our side.
Templates & variables (Slack, email)
Slack and email channels can customize the message sent per event type (webhook always gets the fixed
JSON payload above, unaffected by templates). Every template has access to the same fields as the
webhook payload — the event envelope plus that event's own data fields:
event,org_id,occurred_at— always presentdata.*— the same fields as the payload table above, e.g.data.title,data.severity
For a field that can be missing (marked nullable/absent in the table above), use a conditional
rather than assuming it's always there — e.g. {{#if data.updated_by}}by {{data.updated_by}}{{else}}automatically{{/if}}.
The template editor in the dashboard shows the exact same catalog, generated from this same source, so it
never drifts from what's actually sent.
Testing a channel
Click Send test next to any channel in Notification Channels to fire a
one-off delivery at it right away — a webhook gets a real signed POST (verify it the same way
described above), Slack gets a message, email gets a message to the confirmed address. It's sent
regardless of whether the channel is currently enabled or which event types it's subscribed to, so you can
confirm a receiving endpoint works before turning the channel on for real. The wire shape is the same
envelope as above with "event": "test" — a value that's never a real trigger and can't be
added to a channel's subscribed event types, so it can only ever arrive this way. Unlike a real event, a
failed test isn't retried — you get a pass/fail immediately rather than the usual backoff schedule.
An unconfirmed email channel can't be tested — confirm it first (see the confirmation link in your inbox) so "Send test" can't be used to reach an address that hasn't actually proven it's yours.
HeimPulse