Ionhour Docs
Integrations

Webhooks

Receive Ionhour incidents and alerts into your IMS as signed HTTP callbacks, with HMAC signature verification.

Webhooks are the outbound direction of the integration: Ionhour calls your endpoint when something happens. This is how you push Ionhour's own incidents and alerts into an external incident-management system. (For the inbound direction — your system calling Ionhour — see Incidents.)

Create a webhook channel

Webhooks are delivered through an alert channel you create in the dashboard:

  1. Open Alerting → Channels and add a Webhook channel.
  2. Set the URL Ionhour should POST to.
  3. Optionally set a signing secret — when present, Ionhour signs every request so you can verify it came from Ionhour (strongly recommended).
  4. Optionally add custom headers (for example, an auth token your endpoint expects).

Route alerts to the channel the same way you route email or Slack (per check, on-call policy, or alert routing rule). Each delivery is a single POST with a JSON body and a 10-second timeout.

Your endpoint must be publicly reachable and respond quickly (well under 10s). Ionhour validates the target URL against its egress rules — internal, loopback, and private-network addresses are rejected.

Payload

Every delivery has the same JSON shape:

{
  "event": "incident.created",
  "incidentId": 5567,
  "incidentState": "ACTIVE",
  "checkId": 1234,
  "checkName": "checkout-api",
  "checkStatus": "DOWN",
  "workspaceId": 42,
  "workspaceName": "Acme Production",
  "link": "https://app.ionhour.com/42/incidents/5567",
  "message": "🔴 checkout-api is DOWN\n\nYour service \"checkout-api\" has stopped responding…",
  "timestamp": "2026-07-10T14:03:11.000Z"
}
FieldTypeNotes
eventstringThe event type (see below).
incidentIdnumber | nullThe incident, when the event relates to one.
incidentStatestring | nullACTIVE, MONITORING, or RESOLVED.
checkIdnumber | nullThe check that triggered the alert.
checkNamestringCheck name (empty string if unavailable).
checkStatusstringCheck status, e.g. DOWN, LATE, OK.
workspaceIdnumber | nullWorkspace the alert belongs to.
workspaceNamestring | nullWorkspace name.
linkstring | nullDeep link into the Ionhour app.
messagestringHuman-readable summary (the same text used for email/Slack).
timestampstringISO-8601 UTC send time.

Event taxonomy

eventFired whenincidentId / incidentStatelink points to
incident.createdA check goes DOWN and an incident opensPopulatedIncident
incident.resolvedA check recovers and its incident resolvesPopulatedIncident
alert.lateA check is LATE (overdue, not yet DOWN)PopulatedIncident
latency.breachA check exceeds its latency thresholdnullCheck page
ssl.expiringA check's TLS certificate is nearing expirynullCheck page
testYou send a test delivery from the dashboardnull

Legacy alert event. FLAPPING and ESCALATION alerts are outside this taxonomy. They still deliver with event: "alert" and without the structured incident fields. If you switch on event, treat any unrecognized value (including "alert") as a catch-all rather than asserting the set is closed.

An incident-less event such as latency.breach looks like this — note the null incident fields and the check-page link:

{
  "event": "latency.breach",
  "incidentId": null,
  "incidentState": null,
  "checkId": 1234,
  "checkName": "checkout-api",
  "checkStatus": "OK",
  "workspaceId": 42,
  "workspaceName": "Acme Production",
  "link": "https://app.ionhour.com/42/checks/1234",
  "message": "[IonHour] Latency Breach: checkout-api…",
  "timestamp": "2026-07-10T14:03:11.000Z"
}

Verify the signature

When the channel has a signing secret, Ionhour adds an X-IonHour-Signature header:

X-IonHour-Signature: sha256=<hex>

The value is sha256= followed by the hex HMAC-SHA256 of the exact raw request body, keyed by your channel secret. Verify it before trusting a payload — compute the HMAC over the raw bytes you received (do not re-serialize the parsed JSON, which may reorder keys) and compare in constant time.

import crypto from 'node:crypto';

// `rawBody` MUST be the exact bytes received. In Express, capture it with
// express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }).
function verifyIonhourSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader) return false;
  const expected = `sha256=${crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex')}`;
  const a = Buffer.from(signatureHeader);
  const b = Buffer.from(expected);
  // timingSafeEqual throws on length mismatch — guard first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post('/ionhour/webhook', (req, res) => {
  const ok = verifyIonhourSignature(
    req.rawBody,
    req.get('X-IonHour-Signature'),
    process.env.IONHOUR_WEBHOOK_SECRET,
  );
  if (!ok) return res.status(401).send('bad signature');

  const payload = JSON.parse(req.rawBody.toString('utf8'));
  // handle payload.event …
  res.sendStatus(200);
});
import hashlib
import hmac
import os

def verify_ionhour_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
    if not signature_header:
        return False
    expected = "sha256=" + hmac.new(
        secret.encode("utf-8"), raw_body, hashlib.sha256
    ).hexdigest()
    # compare_digest is constant-time and length-safe.
    return hmac.compare_digest(signature_header, expected)


# Flask example — request.get_data() returns the exact raw bytes.
@app.post("/ionhour/webhook")
def ionhour_webhook():
    if not verify_ionhour_signature(
        request.get_data(),
        request.headers.get("X-IonHour-Signature", ""),
        os.environ["IONHOUR_WEBHOOK_SECRET"],
    ):
        return "bad signature", 401

    payload = request.get_json()
    # handle payload["event"] …
    return "", 200

If no secret is configured on the channel, Ionhour omits the X-IonHour-Signature header entirely. Configure a secret in production so your endpoint can reject unsigned or forged requests.