Vigil

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 confirmed
  • incident.resolved — the monitor recovered
  • incident.update — a public update was posted on an incident (status-page subscriber webhooks only)
  • ssl.expiring — certificate inside the warning window
  • monitor.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 expired
  • domain.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"
}