Webhook payloads
Generic webhook channels receive a JSON POST for every event on the monitors they're attached to. Respond with any 2xx status; failures are recorded on the incident timeline.
Events
incident.opened— downtime confirmedincident.resolved— the monitor recoveredincident.update— a public update was posted on an incident (status-page subscriber webhooks only)ssl.expiring— certificate inside the warning windowmonitor.challenged— bot-protection challenge detected (if enabled)monitor.redirect_changed— redirect destination changed (if enabled)monitor.degraded— a third-party provider reports degraded performance or maintenance, or a DNS monitor's nameservers misbehave while its answers still pass (if enabled)monitor.dns_changed— a DNS monitor's records changed but still pass (if enabled — on by default)domain.expiring— a monitored domain's registration is inside the warning window, or has expireddomain.lapsed— the registry reports the domain on hold, in its redemption period, pending deletion or no longer registered
Incident payload
{
"event": "incident.opened",
"incidentId": "9b2f6c3a-…",
"monitorId": "5db41d29-…",
"monitorName": "Shop",
"monitorUrl": "https://shop.example.com",
"cause": "Timeout after 10000ms",
"httpStatus": null,
"locationsAffected": ["skb", "sto"],
"startedAt": "2026-06-10T14:32:00.000Z",
"resolvedAt": null
}incident.resolved sends the same shape with resolvedAt set. For heartbeat monitors monitorUrl is null: the ping URL is a secret and is never included in alerts or payloads. DNS monitors have no URL either, so theirs is null too (the alert text names the record instead). For third-party monitors it is the provider's status page.
SSL payload
{
"event": "ssl.expiring",
"monitorId": "5db41d29-…",
"monitorName": "Shop",
"monitorUrl": "https://shop.example.com",
"issuer": "Let's Encrypt",
"expiresAt": "2026-06-24T12:00:00.000Z",
"daysLeft": 14
}Warning payload
Shared by monitor.challenged, monitor.redirect_changed, monitor.degraded and monitor.dns_changed; detail is a human-readable description.
{
"event": "monitor.redirect_changed",
"monitorId": "5db41d29-…",
"monitorName": "Shop",
"monitorUrl": "https://shop.example.com",
"detail": "Now redirects to: https://new.example.com (was: https://old.example.com)",
"at": "2026-06-10T14:32:00.000Z"
}Domain payload
One alert per workspace and domain, however many monitors point at it: the monitor* fields name the first of them, and every channel attached to any of them is notified once.
{
"event": "domain.expiring", // domain.expiring | domain.lapsed
"monitorId": "5db41d29-…",
"monitorName": "Shop",
"monitorUrl": "https://shop.example.com",
"domain": "example.com",
"expiresAt": "2026-06-24T12:00:00.000Z", // null when the registry publishes none
"daysLeft": 14, // 0 or less: already expired; null without expiresAt
"registrar": "Example Registrar, Inc.",
"status": null // domain.lapsed: the registry status, e.g. "redemption period"
}Verifying signatures
If you set a signing secret on the channel, every request carries an X-Vigil-Signature header: the hex HMAC-SHA256 of the raw request body. Verify it before trusting the payload:
Node.js
import { createHmac, timingSafeEqual } from "crypto";
function verify(rawBody, signatureHeader, secret) {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}Status-page subscriber webhooks
Webhook subscribers on a status page receive a smaller, public payload — no monitor URL, check details, locations or internal ids — unsigned. Treat them as notifications, not authenticated commands.
{
"event": "incident.opened", // incident.opened | incident.resolved | incident.update
"incidentId": "…",
"component": "API", // the name shown on the status page
"status": "disruption", // disruption | resolved | the update's status
"message": null, // the posted text, for incident.update
"startedAt": "2026-01-01T12:00:00.000Z",
"resolvedAt": null,
"at": "2026-01-01T12:00:05.000Z"
}