Alerts and notifications
Webhook payloads and signatures
The JSON SentinelWeb POSTs when an incident changes, and how to verify the HMAC signature on your endpoint.
Webhook and Slack integrations are available on Growth and higher plans. A
webhook destination receives an HTTP POST every time one of your incidents
changes status (opened, investigating, monitoring, resolved, and so on). This
page documents the request SentinelWeb sends to a generic destination and
how to verify that it genuinely came from us.
Request headers
Every delivery to a generic destination carries these headers. (Slack deliveries carry none — see Slack destinations below.)
| Header | Always sent | Meaning |
|---|---|---|
Content-Type | Yes | Always application/json. |
X-Sentinel-Event | Yes | The event type — currently always incident.status_changed. |
X-Sentinel-Delivery | Yes | A unique id for this delivery. Retries reuse it, so it is a good idempotency key. |
X-Sentinel-Timestamp | Signed only | Unix time (whole seconds) when the request was signed. |
X-Sentinel-Signature | Signed only | v1= followed by the hex HMAC (see below). |
The two signature headers are present only when the destination has a signing secret configured. An unsigned destination relies on its secret capability URL plus HTTPS instead.
Payload
The body is a JSON object. Fields are stable; new fields may be added over time, so ignore any you don't recognise.
{
"version": 1,
"id": "evt_9f8e7d6c5b4a",
"event": "incident.status_changed",
"createdAt": "2026-07-22T12:00:00.000Z",
"data": {
"incident": {
"id": "inc_a1b2c3",
"title": "Homepage outage",
"status": "open",
"severity": "high",
"detection": "auto",
"affectedMonitorTypes": ["uptime"],
"websiteName": "Acme Store",
"description": null,
"startedAt": "2026-07-22T12:00:00.000Z",
"resolvedAt": null,
"url": "https://app.your-domain.com/incidents/inc_a1b2c3"
},
"previousStatus": null,
"actorName": null
}
}
| Field | Notes |
|---|---|
version | Payload schema version. Currently 1. |
id | Stable id for this event; matches the X-Sentinel-Delivery prefix. |
event | Always incident.status_changed today. |
createdAt | ISO 8601 UTC timestamp of the status change. |
data.incident.id / .title | The incident's id and headline. |
data.incident.status | The new status (open, investigating, identified, monitoring, resolved). |
data.incident.severity | Severity label, e.g. high (mirrors the dashboard). |
data.incident.detection | How the incident was raised (auto or manual). |
data.incident.affectedMonitorTypes | Array of the monitor types involved (e.g. uptime, ssl). |
data.incident.websiteName | Name of the affected website. |
data.incident.startedAt | ISO 8601 UTC time the incident opened. |
data.incident.description / resolvedAt | null until set. |
data.incident.url | Link to the incident in your dashboard. |
data.previousStatus | The status before this change, or null when the incident first opened. |
data.actorName | The person who made a manual change, or null for automatic transitions. |
The payload never contains your SentinelWeb account or user id.
Verifying the signature
For a signed destination, compute the expected signature and compare it in constant time.
- Read the raw request body exactly as received — do not re-serialise the parsed JSON, or the bytes (and therefore the signature) will differ.
- Build the signed string
"{X-Sentinel-Timestamp}.{rawBody}". - Compute
HMAC-SHA256(signing_secret, signedString)and hex-encode it. - Prefix it with
v1=and compare againstX-Sentinel-Signatureusing a constant-time comparison. - Reject deliveries whose
X-Sentinel-Timestampis more than a few minutes old to blunt replay attempts.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
// `rawBody` must be the exact string/Buffer received, before JSON parsing.
export function isValidSentinelWebhook(headers, rawBody, secret) {
const signature = headers['x-sentinel-signature'] ?? '';
const timestamp = headers['x-sentinel-timestamp'] ?? '';
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!timestamp || Number.isNaN(ageSeconds) || ageSeconds > 300) return false;
const expected =
'v1=' +
createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
Python
import hashlib
import hmac
import time
# `raw_body` is the exact request body received, as bytes (e.g. Flask's
# `request.get_data()`) or a str. Do not pass the parsed JSON object.
def is_valid_sentinel_webhook(headers, raw_body, secret):
signature = headers.get("X-Sentinel-Signature", "")
timestamp = headers.get("X-Sentinel-Timestamp", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
if isinstance(raw_body, str):
raw_body = raw_body.encode()
signed = f"{timestamp}.".encode() + raw_body
expected = "v1=" + hmac.new(
secret.encode(),
signed,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, expected)
Unsigned destinations
If you leave the signing secret blank, deliveries arrive without the
X-Sentinel-Timestamp and X-Sentinel-Signature headers. Treat the destination
URL itself as the secret: keep it private, serve it over HTTPS only, and rotate
it if it leaks.
Slack destinations
Slack destinations are different: SentinelWeb posts a preformatted Slack message to your incoming-webhook URL and never signs it — Slack authenticates the caller by the secret webhook URL alone. There is nothing to verify on your side.