Skip to content
SentinelWeb

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.)

HeaderAlways sentMeaning
Content-TypeYesAlways application/json.
X-Sentinel-EventYesThe event type — currently always incident.status_changed.
X-Sentinel-DeliveryYesA unique id for this delivery. Retries reuse it, so it is a good idempotency key.
X-Sentinel-TimestampSigned onlyUnix time (whole seconds) when the request was signed.
X-Sentinel-SignatureSigned onlyv1= 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
  }
}
FieldNotes
versionPayload schema version. Currently 1.
idStable id for this event; matches the X-Sentinel-Delivery prefix.
eventAlways incident.status_changed today.
createdAtISO 8601 UTC timestamp of the status change.
data.incident.id / .titleThe incident's id and headline.
data.incident.statusThe new status (open, investigating, identified, monitoring, resolved).
data.incident.severitySeverity label, e.g. high (mirrors the dashboard).
data.incident.detectionHow the incident was raised (auto or manual).
data.incident.affectedMonitorTypesArray of the monitor types involved (e.g. uptime, ssl).
data.incident.websiteNameName of the affected website.
data.incident.startedAtISO 8601 UTC time the incident opened.
data.incident.description / resolvedAtnull until set.
data.incident.urlLink to the incident in your dashboard.
data.previousStatusThe status before this change, or null when the incident first opened.
data.actorNameThe 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.

  1. Read the raw request body exactly as received — do not re-serialise the parsed JSON, or the bytes (and therefore the signature) will differ.
  2. Build the signed string "{X-Sentinel-Timestamp}.{rawBody}".
  3. Compute HMAC-SHA256(signing_secret, signedString) and hex-encode it.
  4. Prefix it with v1= and compare against X-Sentinel-Signature using a constant-time comparison.
  5. Reject deliveries whose X-Sentinel-Timestamp is 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.

Stop finding out from your customers.

Set up your first monitor in minutes and let SentinelWeb keep watch.