HeimPulse

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 member
  • incident.updated — an incident's status changed, including resolution
  • degradation.detected — a service moved from Operational into a non-Operational state
  • certificate.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 present
  • data.* — 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.