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:
- Open Alerting → Channels and add a Webhook channel.
- Set the URL Ionhour should POST to.
- Optionally set a signing secret — when present, Ionhour signs every request so you can verify it came from Ionhour (strongly recommended).
- 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"
}| Field | Type | Notes |
|---|---|---|
event | string | The event type (see below). |
incidentId | number | null | The incident, when the event relates to one. |
incidentState | string | null | ACTIVE, MONITORING, or RESOLVED. |
checkId | number | null | The check that triggered the alert. |
checkName | string | Check name (empty string if unavailable). |
checkStatus | string | Check status, e.g. DOWN, LATE, OK. |
workspaceId | number | null | Workspace the alert belongs to. |
workspaceName | string | null | Workspace name. |
link | string | null | Deep link into the Ionhour app. |
message | string | Human-readable summary (the same text used for email/Slack). |
timestamp | string | ISO-8601 UTC send time. |
Event taxonomy
event | Fired when | incidentId / incidentState | link points to |
|---|---|---|---|
incident.created | A check goes DOWN and an incident opens | Populated | Incident |
incident.resolved | A check recovers and its incident resolves | Populated | Incident |
alert.late | A check is LATE (overdue, not yet DOWN) | Populated | Incident |
latency.breach | A check exceeds its latency threshold | null | Check page |
ssl.expiring | A check's TLS certificate is nearing expiry | null | Check page |
test | You send a test delivery from the dashboard | null | — |
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 "", 200If 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.