Vigil

REST API

A JSON API for dashboards, integrations and apps that does everything the web dashboard does: monitors of every kind, incidents, maintenance windows, alert channels, status pages, team and workspace settings. The Vigil iOS app is built on it. Owners create keys under Settings → API access; keys are shown once and stored hashed.

Authentication · Conventions · Account · Monitors · Locations · DNS · Third-party · Incidents · Maintenance · Overview · Alert channels · Status pages · Team · Workspace · API keys · Push devices · Platform

Authentication

curl -H "Authorization: Bearer vgl_api_…" \
  https://vigil.xzz.dk/api/v1/monitors

Keys come in two kinds. Workspace keys are integrations, created by owners under Settings → API access, either read-only or with full access. App sign-ins are minted for a person by POST /api/v1/auth/login and carry that person's role; they're listed under API access too and can be revoked there. A key can do what its role can do in the dashboard:

RoleCan
Read-only — viewers (member) and read-only keysEvery GET.
Editor — adminAlso create, change and delete monitors, incidents, maintenance windows, alert channels and status pages, and run DNS and status-page lookups. With an app sign-in also invite and remove team members.
Owner — owner and full-access keysEverything an editor can. With the owner's app sign-in also the workspace name, quiet hours and API keys.
SuperadminThe platform endpoints under /api/v1/admin — only with the platform operator's own app sign-in, never with a workspace key.

Unless an endpoint says otherwise, a GET works with any key and every other method needs an editor. Read-only keys get 403 from the rest ({"error": "your role is read-only — ask an owner to upgrade it"}) and see a little less in some reads: no heartbeat ping URLs, alert-channel destinations, basic-auth usernames, pending invitations or custom-domain tokens.

Account endpoints act for a person, so they only take app sign-ins: /api/v1/team and /api/v1/api-keys (every method), PATCH /api/v1/workspace and POST /api/v1/account/password. A workspace key — even a full-access one — gets 403 ({"error": "this needs a signed-in app key, not a workspace API key"}), so a leaked integration key can't mint keys, invite people or mute alerts.

POST /api/v1/auth/login

Exchange an account's email and password for a fresh app sign-in key — this is how the iOS app signs in. The key is returned exactly once and inherits the person's role in their workspace. deviceName is optional and only labels the key in Settings → API access. Unconfirmed email addresses get 403. Limited to 10 attempts per 5 minutes per IP, on top of the web login's throttle on failed attempts per account.

curl -X POST -H "Content-Type: application/json" \
  -d '{"email": "you@example.com", "password": "…", "deviceName": "Work iPhone"}' \
  https://vigil.xzz.dk/api/v1/auth/login

{
  "apiKey": "vgl_api_…",
  "role": "owner",             // owner | admin | member (member = read-only)
  "user": { "name": "Casper", "email": "you@example.com", "isSuperadmin": false },
  "org": { "name": "HTML ApS", "plan": "pro" }
}

POST /api/v1/auth/logout

Revokes the key used to authenticate the request, whatever its role (the app calls this on sign-out).

Conventions

  • Send JSON with Content-Type: application/json. Bodies are capped (64 KB unless noted); bigger ones get 413.
  • Lists come wrapped in an object ({"monitors": […]}). Ids are UUIDs; timestamps are ISO 8601 in UTC.
  • Errors are {"error": "…"} with a short lowercase message.
  • Text can't contain NUL characters (U+0000): a body with one in any string is a 400.
  • Secrets are write-only. Basic-auth passwords, request header values and bodies, webhook URLs, tokens and signing secrets never come back — at most a name, a has… flag or a masked destination. API keys and probe keys appear once, in the response that creates them.
StatusMeans
400Invalid input: a bad value, a misplaced field, or a field the monitor's kind doesn't take
401Missing, invalid or revoked key
403The key's role can't do this, or the plan doesn't include it (monitor limit, custom domains)
404Not found — also for ids that belong to another workspace
409Not possible in the current state, e.g. resolving an automatic incident
413Request body too large
422A live lookup failed: DNS records, a status page, a domain's TXT record
429Rate limited — try again in a few minutes
502The other side failed: an alert delivery, a status-page read, an email
503Not configured on this server (email, push notifications)

Rate limits apply per operation, mostly shared with the dashboard: sign-in 10 per 5 minutes per IP; password changes 10 per 15 minutes; DNS lookups (the email preset included) 30 and pasted status-page URLs 20 per 10 minutes — per person for app sign-ins, one shared budget per workspace for workspace keys; manual incidents and public updates 30 notifications an hour per workspace; invitations 20 an hour per workspace and 3 a day per address; test alerts 10 an hour; confirmation emails 3 a day per address; custom-domain checks 20 per 10 minutes; test pushes 5 per 10 minutes per key.

Account

GET /api/v1/me

Who a key belongs to: the person behind it (an app sign-in's user, else whoever created the workspace key; null if nobody, or they've left the workspace), the workspace with its plan and usage, and the key's role. isSuperadmin is only ever true for app sign-ins.

{
  "user": { "id": "a41f0b6e-…", "name": "Casper", "email": "you@example.com", "isSuperadmin": false },
  "org": {
    "id": "c07e5d12-…",
    "name": "HTML ApS",
    "plan": "pro",
    "planLabel": "Pro",
    "monitorLimit": 100,
    "monitorCount": 12
  },
  "role": "owner"
}

POST /api/v1/account/password

Change the signed-in person's password: {"current": "…", "password": "…"}, at least 8 characters. App sign-ins only (any role); workspace keys get 403. Like the dashboard it signs out every web session and every other app sign-in of that person, and revokeOtherKeys (default true) also revokes the workspace keys they created. The key making the request stays valid, and a wrong current password is a 400, never a 401.

Monitors

Four kinds: http (websites and APIs), dns (one hostname and record type), heartbeat (your job pings Vigil — see Monitors & checks) and thirdparty (a vendor's status page). A monitor's status is up, down, degraded, challenged (bot protection — never downtime), pending, paused or maintenance, which includes scheduled maintenance windows.

GET /api/v1/monitors

All monitors, newest first, with current status and uptime. For DNS monitors url is the hostname and target names the record that is looked up. A heartbeat's url is its secret ping URL — null for read-only keys.

{
  "monitors": [
    {
      "id": "5db41d29-…",
      "name": "Shop",
      "url": "https://shop.example.com",
      "kind": "http",            // http | dns | heartbeat | thirdparty
      "status": "up",            // up | down | degraded | challenged | pending | paused | maintenance
      "intervalSeconds": 60,
      "lastStatusChangeAt": "2026-06-10T14:43:55.000Z",
      "uptime24h": 99.93,
      "uptime30d": 99.98,
      "uptime90d": 99.99,
      "certExpiresAt": "2026-08-29T23:41:26.000Z",
      "createdAt": "2026-06-01T09:00:00.000Z"
    },
    {
      "id": "8e0c47a1-…",
      "name": "Mail routing",
      "url": "example.com",
      "kind": "dns",
      "target": "MX example.com",  // DNS monitors only
      "status": "up",
      "intervalSeconds": 300,
      …
    }
  ]
}

GET /api/v1/monitors/:id

One monitor in full: its settings, uptime, SSL details (HTTPS monitors), per-location state (HTTP status, response time, last error, where redirects led) and the 20 most recent incidents. Every kind reports notifyRecovery, renotifySeconds and its alert channels in channelIds; HTTP and DNS monitors add the active probe locations they're pinned to in locationIds ([] = every location).

{
  "id": "5db41d29-…",
  "name": "Shop",
  "url": "https://shop.example.com",
  "kind": "http",
  "status": "up",
  "method": "GET",
  "intervalSeconds": 60,
  "timeoutMs": 10000,
  "expectedStatus": "2xx",
  "followRedirects": true,
  "ignoreTlsErrors": false,
  "confirmations": 2,
  "quorum": 1,
  "domainExpiryAlertDays": 14,
  "paused": false,
  "maintenance": false,
  "notifyRecovery": true,
  "renotifySeconds": 0,
  "keyword": "Add to cart",
  "keywordMode": "contain",
  "sslExpiryAlertDays": 14,
  "notifyChallenge": false,
  "notifyRedirectChange": false,
  "requestHeaderNames": ["Authorization"],   // the values are write-only
  "hasRequestBody": false,
  "bodyType": null,
  "authUser": null,                          // null for read-only keys
  "hasAuthPassword": false,
  "channelIds": ["0f6d2c1e-…"],
  "locationIds": [],                         // [] = every location
  "lastStatusChangeAt": "2026-06-10T14:43:55.000Z",
  "uptime24h": 99.93,
  "uptime30d": 99.98,
  "uptime90d": 99.99,
  "ssl": {
    "expiresAt": "2026-08-29T23:41:26.000Z",
    "issuer": "Let's Encrypt",
    "error": null,
    "checkedAt": "2026-06-10T03:00:00.000Z"
  },
  "locations": [
    {
      "code": "skb",
      "name": "Copenhagen",
      "up": true,
      "challenged": false,
      "lastHttpStatus": 200,
      "lastResponseMs": 182,
      "lastCheckedAt": "2026-06-10T14:32:00.000Z",
      "lastError": null,
      "finalUrl": null                       // where redirects led, if anywhere else
    }
  ],
  "incidents": [
    {
      "id": "9b2f6c3a-…",
      "cause": "Unexpected HTTP status 503",
      "startedAt": "2026-06-02T09:14:00.000Z",
      "resolvedAt": "2026-06-02T09:21:00.000Z",
      "locationsAffected": ["skb", "sto"]
    }
  ],
  "thirdParty": null,
  "dns": null,
  "domain": { … }                            // see below
}

Heartbeat monitors add pingUrl (null for read-only keys), graceSeconds and lastPingAt. Third-party monitors add providerSlug (null for a pasted status page), componentIds, notifyDegraded and a thirdParty object: the provider, its status page, overall status, the watched components with their current status (none = following the overall status) and the incidents the provider has posted.

"thirdParty": {
  "provider": "GitHub",
  "pageUrl": "https://www.githubstatus.com",
  "overallStatus": "degraded_performance",
  "lastReadAt": "2026-06-10T14:30:00.000Z",
  "components": [
    { "id": "8l4ygp009s5s", "name": "Actions", "group": null, "status": "degraded_performance" }
  ],
  "activeIncidents": [
    { "name": "Delayed Actions runs", "url": "https://www.githubstatus.com/incidents/…", "impact": "minor" }
  ]
}

HTTP and DNS monitors add a domain object: the registration of the domain they point at. Its fields are null until the registry has been asked; expiresAt stays null for registries that don't publish it, and domain itself is null when there's no domain to track (an IP address, say).

DNS monitors add a dns object with the lookup settings, the DNSSEC status, each location's latest answer and the most recent record change. They carry the HTTP fields (method, expectedStatus, followRedirects, ignoreTlsErrors) as stored, but those mean nothing for a lookup and are refused on writes.

{
  "id": "8e0c47a1-…",
  "name": "Mail routing",
  "url": "example.com",
  "kind": "dns",
  "target": "MX example.com",
  "status": "up",
  "intervalSeconds": 300,
  "timeoutMs": 5000,
  "confirmations": 2,
  "quorum": 1,
  "notifyDegraded": true,
  "domainExpiryAlertDays": 14,
  …                                  // alerting, uptime, locations, incidents as above
  "dns": {
    "host": "example.com",
    "recordType": "MX",
    "mode": "resolver",              // resolver | authoritative
    "nameserver": null,              // null = public resolvers
    "expected": ["10 mx1.example.com", "20 mx2.example.com"],
    "match": "exact",                // any | all | exact
    "notifyChange": true,
    "dnssec": "secure",              // secure | insecure | bogus | null
    "locations": [
      {
        "code": "skb",
        "checkedAt": "2026-06-10T14:32:00.000Z",
        "server": "1.1.1.1",
        "rcode": "NOERROR",          // NXDOMAIN, SERVFAIL, … (null: no reply)
        "answers": ["10 mx1.example.com", "20 mx2.example.com"],
        "cname": [],                 // CNAMEs followed from the hostname
        "ttl": 300,
        "dnssec": "secure",
        "error": null,               // e.g. "timeout"
        "degraded": false,
        "warning": null              // nameservers down or disagreeing
      }
    ],
    "latestChange": {
      "detectedAt": "2026-06-09T08:12:00.000Z",
      "before": ["10 mx.example.com"],
      "after": ["10 mx1.example.com", "20 mx2.example.com"],
      "locations": ["skb", "sto"],
      "accepted": true,
      "acceptedAt": "2026-06-09T08:30:00.000Z"
    }
  },
  "domain": {
    "name": "example.com",
    "expiresAt": "2027-08-13T04:00:00.000Z",
    "registrar": "Example Registrar, Inc.",
    "statuses": ["client transfer prohibited"],
    "checkedAt": "2026-06-10T03:00:00.000Z",
    "alertDays": 14
  }
}

In authoritative mode each location also has zone and a nameservers list — every nameserver's reply with name, ip, rcode, authoritative, answers, the zone's SOA serial (informational — providers number serials differently, so a mismatch never degrades the monitor), ms and error. A lookup returns at most 100 records (16 KB). If a lookup is too large to keep whole, its biggest nameserver lists come back empty with answersOmitted: true. latestChange is null until the records first change.

POST /api/v1/monitors

Create an HTTP monitor (the default), a DNS monitor ("kind": "dns") or a heartbeat monitor ("kind": "heartbeat"); third-party monitors have their own endpoint. An HTTP monitor needs only name and url (strings); everything else falls back to the dashboard form's defaults, and numbers outside their range are clamped to it. A field of another kind is a 400. Returns 201 with the monitor and its settings, or 403 once the plan's monitor limit is reached.

curl -X POST -H "Authorization: Bearer vgl_api_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Shop",
    "url": "https://shop.example.com",  // https:// is assumed when there's no scheme
    "method": "GET",              // GET | HEAD | POST | PUT | PATCH | DELETE
    "intervalSeconds": 60,        // 30–3600
    "timeoutMs": 10000,           // 1000–60000, never longer than the interval
    "expectedStatus": "2xx",      // "2xx" … "5xx", or an exact code: "200" or 200
    "followRedirects": true,
    "ignoreTlsErrors": false,     // true = accept self-signed / mismatched certs (e.g. https://IP)
    "confirmations": 2,           // 1–10 consecutive fails before a location is down
    "quorum": 1,                  // 1–10 locations down before an incident opens
    "keyword": "Add to cart",     // check the response body …
    "keywordMode": "contain",     // … contain | not_contain | null (off)
    "notifyRecovery": true,
    "renotifySeconds": 0,         // remind while down: 0 (off), 300, 600, 1800, 3600 or 10800
    "notifyChallenge": false,     // warn about bot-protection challenges
    "notifyRedirectChange": false,
    "sslExpiryAlertDays": 14,     // 0–90, 0 = off
    "domainExpiryAlertDays": 14,  // 0–365, 0 = off
    "channelIds": ["0f6d2c1e-…"], // omitted or null = every alert channel, [] = none
    "locationIds": []             // omitted, null or [] = every location
  }' \
  https://vigil.xzz.dk/api/v1/monitors

Request options — custom headers, a request body and HTTP basic auth — follow the dashboard form's limits: up to 50 headers (values up to 2 KB, 8 KB in all), a body of up to 16 KB for POST, PUT, PATCH and DELETE checks (a JSON body must parse), a keyword of up to 500 and credentials of up to 256 characters. They're write-only: reads only tell which header names are set and whether there's a body or a password.

{
  "name": "Orders API",
  "url": "https://api.example.com/health",
  "method": "POST",
  "requestHeaders": { "Authorization": "Bearer …", "Accept": "application/json" },
  "requestBody": "{\"probe\": true}",
  "bodyType": "json",           // json | form | raw
  "authUser": "monitor",        // HTTP basic auth
  "authPass": "…"
}

Alert channels and locations. Without channelIds (or with null) a new monitor alerts every channel of the workspace, as the dashboard form starts out; send [] for none. This holds for every endpoint that creates monitors. Without locationIds (or with null or []) every location checks it, including ones added later. The ids come from GET /api/v1/channels and GET /api/v1/locations: an id that doesn't exist is a 400, and a location the operator has disabled is dropped.

For a DNS monitor send "kind": "dns" and the lookup settings in a dns object; dns.host and dns.recordType are required. Only locations with dns: true run DNS checks.

curl -X POST -H "Authorization: Bearer vgl_api_…" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "dns",
    "name": "Mail routing",
    "dns": {
      "host": "example.com",      // PTR: the IP address
      "recordType": "MX",         // A | AAAA | CNAME | MX | NS | TXT | SOA | CAA | SRV | PTR
      "mode": "resolver",         // resolver | authoritative (every nameserver)
      "nameserver": null,         // resolver mode only, e.g. "9.9.9.9"
      "expected": ["10 mx1.example.com", "20 mx2.example.com"],  // [] = must resolve
      "match": "exact",           // any | all | exact (TXT default: all)
      "notifyChange": true        // warn when the records change but still pass
    },
    "intervalSeconds": 300,       // 30–3600, default 300
    "timeoutMs": 5000,            // 1000–30000, default 5000
    "confirmations": 2,
    "quorum": 1,
    "notifyDegraded": true,       // warn when nameservers fail or disagree
    "domainExpiryAlertDays": 14
  }' \
  https://vigil.xzz.dk/api/v1/monitors

Expected values are normalised (case, trailing dots, quotes, IPv6 notation), so the response echoes them in canonical form. POST /api/v1/dns/lookup fetches a record's current values to start from.

A heartbeat monitor takes name, intervalSeconds (60, 300, 900, 1800, 3600, 21600, 43200, 86400 or 604800; default 3600), graceSeconds (0–86400, default 300), the alert options and channelIds. The response carries the secret pingUrl your job calls after every run.

curl -X POST -H "Authorization: Bearer vgl_api_…" \
  -H "Content-Type: application/json" \
  -d '{"kind": "heartbeat", "name": "Nightly backup", "intervalSeconds": 86400, "graceSeconds": 1800}' \
  https://vigil.xzz.dk/api/v1/monitors

{
  "id": "c2a91f04-…",
  "name": "Nightly backup",
  "kind": "heartbeat",
  "status": "pending",
  …
  "pingUrl": "https://vigil.xzz.dk/api/ping/k3J9…",
  "graceSeconds": 1800,
  "lastPingAt": null,
  "channelIds": ["0f6d2c1e-…"]
}

PATCH /api/v1/monitors/:id

Partial update — send any subset of the fields the monitor's kind takes at creation, plus paused and maintenance. {"paused": true} behaves like the dashboard toggle (status resets to pending so resuming starts clean). channelIds and locationIds replace the current sets; null resets them to every channel and every location. A monitor's kind can't change. Returns the monitor like POST.

curl -X PATCH -H "Authorization: Bearer vgl_api_…" \
  -H "Content-Type: application/json" \
  -d '{"paused": true}' \
  https://vigil.xzz.dk/api/v1/monitors/5db41d29-…
  • HTTP: request options you don't send keep their stored values, so a client that can't read them back can leave them alone. Sending one replaces it, and {}, "" or null clear headers, body and keyword. A blank authPass keeps the stored password, null removes it, and clearing authUser removes both. Switching to GET or HEAD drops the body.
  • DNS: a partial dns object is merged over the current settings, so send only what changes. Changing recordType needs new expected values too ([] to only require that the name resolves). A new host, record type, mode or nameserver restarts change tracking and triggers an immediate re-check.
  • Heartbeat: name, intervalSeconds, graceSeconds, the alert options and channelIds.
  • Third-party: name, notifyRecovery, notifyDegraded, renotifySeconds and channelIds; the watched components change with PATCH /api/v1/thirdparty/monitors/:id.
curl -X PATCH -H "Authorization: Bearer vgl_api_…" \
  -H "Content-Type: application/json" \
  -d '{"dns": {"expected": ["10 mx1.example.com"], "match": "all"}, "intervalSeconds": 600}' \
  https://vigil.xzz.dk/api/v1/monitors/8e0c47a1-…

DELETE /api/v1/monitors/:id

Deletes the monitor and all of its history. Irreversible.

POST /api/v1/monitors/:id/check

"Check now": every probe location re-checks an HTTP or DNS monitor on its next config poll (≈30 seconds), and a third-party monitor's status page is re-read at once (unless it was read in the last 15 seconds). Heartbeat monitors are checked by your own pings (400). Returns {"ok": true, "requestedAt": "…"}.

GET /api/v1/monitors/:id/results?hours=24

Raw check results, newest first, up to 5000 rows. hours accepts 1–168. For DNS monitors httpStatus is null and responseMs is the lookup time.

{
  "results": [
    {
      "checkedAt": "2026-06-10T14:32:00.000Z",
      "location": "skb",
      "ok": true,
      "httpStatus": 200,
      "responseMs": 182,
      "challenge": false,
      "error": null
    }
  ]
}

Add series=1 for the dashboard's response-time chart instead: every location averaged into about 180 buckets, so a week of one-minute checks isn't cut off at 5000 rows. A bucket with a failed check has ok: false, how many failed, the first failure's cause and the locations they came from.

{
  "series": [
    {
      "at": "2026-06-10T14:30:00.000Z",
      "avgMs": 196,
      "ok": false,
      "failedChecks": 1,
      "cause": "Timeout after 10000ms",
      "locations": ["sto"]
    }
  ]
}

GET /api/v1/monitors/:id/uptime?days=90

The daily uptime bars: one entry per day, oldest first, days 1–90. Days are calendar days in the workspace timezone (see GET /api/v1/workspace). A day only counts as down when a confirmed incident overlapped it; hasData is false when nothing was checked.

{
  "days": [
    { "day": "2026-06-09", "hasData": true, "downtimeSeconds": 0 },
    { "day": "2026-06-10", "hasData": true, "downtimeSeconds": 420 }
  ]
}

POST /api/v1/monitors/import

Bulk-create HTTP monitors from CSV, like the dashboard's import. csv is the text (up to 1 MB): a header row with a url column — optionally name, method, interval, timeout, expectedStatus and ignoreTls — then one monitor per row; name defaults to the hostname. Rows that fail validation, are already monitored or don't fit the plan are skipped with the reason, the rest are created. channelIds go to every new monitor (omitted or null = every channel).

curl -X POST -H "Authorization: Bearer vgl_api_…" \
  -H "Content-Type: application/json" \
  -d '{"csv": "url,name,interval\nhttps://shop.example.com,Shop,60\nexample.org,,300\nhttps://shop.example.com,Shop again,60"}' \
  https://vigil.xzz.dk/api/v1/monitors/import

{
  "created": [
    { "id": "5db41d29-…", "name": "Shop" },
    { "id": "e81b07c2-…", "name": "example.org" }
  ],
  "skipped": [{ "line": 4, "reason": "already monitored: https://shop.example.com" }]
}

POST /api/v1/monitors/:id/rotate-token

A new ping URL for a heartbeat monitor, like the dashboard's Regenerate. The old URL stops working at once, so update your jobs. Returns {"pingUrl": "https://vigil.xzz.dk/api/ping/…"}.

Monitoring locations

GET /api/v1/locations

The active monitoring locations, for locationIds: whether each is online (seen in the last two minutes) and runs DNS checks.

{
  "locations": [
    { "id": "3f1c9a70-…", "code": "skb", "name": "Copenhagen", "online": true, "dns": true }
  ]
}

DNS

Live lookups make the server itself query DNS, so they share one budget: 30 per 10 minutes per person for app sign-ins, and one budget per workspace for all of its workspace keys.

POST /api/v1/dns/lookup

A record's live answers, for filling in expected values: host, recordType and optionally mode and nameserver, validated like a DNS monitor. The answers are what a resolver-mode monitor would see (authoritative mode previews through the public resolvers too). A failed lookup — NXDOMAIN, no such records, a timeout — is a 422 saying why.

curl -X POST -H "Authorization: Bearer vgl_api_…" \
  -H "Content-Type: application/json" \
  -d '{"host": "example.com", "recordType": "MX"}' \
  https://vigil.xzz.dk/api/v1/dns/lookup

{ "answers": ["10 mx1.example.com", "20 mx2.example.com"] }

POST /api/v1/dns/email/preview

Step one of the email DNS preset: domain and optional DKIM selectors (a list, or one string like "google, selector1"; up to 10). Returns MX, SPF, DMARC and a DKIM key per selector, each with a state — found (can be monitored), missing, fix (there, but broken) or error (couldn't be checked) — the values found, a note and, where there's a safe one, a suggested record.

{
  "domain": "example.com",
  "selectors": ["google"],
  "items": [
    {
      "key": "mx",
      "label": "MX",
      "type": "MX",
      "host": "example.com",
      "state": "found",
      "values": ["1 smtp.google.com"],
      "note": null,
      "suggestion": null
    },
    {
      "key": "dmarc",
      "label": "DMARC",
      "type": "TXT",
      "host": "_dmarc.example.com",
      "state": "missing",
      "values": [],
      "note": "No DMARC record. Start with a monitoring-only policy — a TXT record at _dmarc.example.com:",
      "suggestion": "v=DMARC1; p=none; rua=mailto:dmarc@example.com"
    },
    …                              // spf, dkim:google
  ]
}

POST /api/v1/dns/email

Step two: the same domain and selectors, the records to monitor (item keys from the preview, e.g. ["mx", "spf", "dmarc", "dkim:google"]) and optional channelIds (omitted or null = every channel). Creates one resolver-mode DNS monitor per record, checked every 5 minutes from every location and expecting what the domain publishes now: exactly these MX records, SPF and DMARC among the name's TXT records, and DKIM keys only to exist (providers rotate them). The records are looked up again first; one that's no longer found is a 409. All or nothing: if they don't all fit the plan, none are created (403). Returns 201 with {"created": [{"id": "…", "name": "example.com · MX"}, …]}.

GET /api/v1/monitors/:id/dns/changes

A DNS monitor's record history: the latest 20 confirmed changes, newest first. acceptedBy names who accepted it — null while unaccepted, or when accepted with a workspace key.

{
  "changes": [
    {
      "id": "7c1e4b2a-…",
      "detectedAt": "2026-06-09T08:12:00.000Z",
      "before": ["10 mx.example.com"],
      "after": ["10 mx1.example.com", "20 mx2.example.com"],
      "added": ["10 mx1.example.com", "20 mx2.example.com"],
      "removed": ["10 mx.example.com"],
      "locations": ["skb", "sto"],     // the locations that confirmed it
      "acceptedAt": null,
      "acceptedBy": null
    }
  ]
}

POST /api/v1/monitors/:id/dns/accept

Make records the expected ones, like the dashboard's Accept buttons. With {"changeId": "…"} a recorded change is accepted along with every older unreviewed one, and a monitor with expected values now expects its records. Only the newest change can be accepted (409 once the records changed again), and not one that removed every record from a monitor that expects some (409). With {} or {"changeId": null} the monitor expects what its latest lookups return right now (409 when they returned nothing, or more than can be stored whole); any other changeId than a change's id is a 400. Probes re-check right away. Returns {"dns": {…}}, the monitor's lookup settings.

Third-party status pages

Third-party monitors follow a vendor's public status page — one from the catalog, or any page served by Atlassian Statuspage or incident.io — instead of probing. Outages open incidents; degraded performance and provider maintenance show as degraded.

GET /api/v1/thirdparty/catalog

The curated status pages a monitor can follow by slug, in picker order, with their categories.

{
  "categories": ["Cloud & hosting", "CDN, DNS & media", …],
  "providers": [
    { "slug": "github", "name": "GitHub", "category": "Developer tools", "pageUrl": "https://www.githubstatus.com" }
  ]
}

POST /api/v1/thirdparty/lookup

Read a status page to pick components: {"slug": "github"}, {"url": "https://status.example.com"} or {"monitorId": "…"} (the page an existing third-party monitor follows, for editing it). slug is null for pasted pages — create their monitors by url. A page without a status feed is a 422, one that can't be read right now a 502. Pasted URLs are limited to 20 lookups per 10 minutes — per person for app sign-ins, per workspace for workspace keys.

{
  "provider": {
    "slug": "github",
    "name": "GitHub",
    "pageUrl": "https://www.githubstatus.com",
    "overallStatus": "operational",
    "lastReadAt": "2026-06-10T14:30:00.000Z"
  },
  "components": [
    { "id": "8l4ygp009s5s", "name": "Actions", "group": null, "status": "operational" }
  ]
}

Component statuses are operational, degraded_performance, partial_outage, major_outage and under_maintenance.

POST /api/v1/thirdparty/monitors

Follow a status page. Defaults as in the dashboard: the provider's name, its overall status, recovery alerts on, degraded warnings off, every alert channel. Unknown component ids are dropped. The monitor is evaluated right away, so a vendor that's already down opens an incident at once. Returns 201 with the monitor plus providerSlug, provider (name and page), componentIds, notifyDegraded, notifyRecovery, renotifySeconds and channelIds — or 403 at the plan's monitor limit.

curl -X POST -H "Authorization: Bearer vgl_api_…" \
  -H "Content-Type: application/json" \
  -d '{
    "slug": "github",                  // or "url": "https://status.example.com"
    "componentIds": ["8l4ygp009s5s"],  // [] or omitted = the overall status
    "name": "GitHub Actions",
    "notifyDegraded": false,           // warn on degraded performance / provider maintenance
    "notifyRecovery": true,
    "renotifySeconds": 0,
    "channelIds": ["0f6d2c1e-…"]       // omitted or null = every alert channel
  }' \
  https://vigil.xzz.dk/api/v1/thirdparty/monitors

PATCH /api/v1/thirdparty/monitors/:id

Change what a third-party monitor watches: componentIds ([] = the overall status) and/or notifyDegraded. The followed provider can't change; the name, alert options and channels go through PATCH /api/v1/monitors/:id. The monitor is re-evaluated right away and returned like POST.

Incidents

Vigil opens incidents by itself when downtime is confirmed (kind auto) and resolves them when checks recover. You can also open one by hand for problems the probes can't see (manual), and publish updates on any incident to the status pages that show its monitor.

GET /api/v1/incidents

The 100 most recent incidents, newest first. Filter with status=open|resolved|all (default all) and monitorId.

{
  "incidents": [
    {
      "id": "9b2f6c3a-…",
      "monitorId": "5db41d29-…",
      "monitorName": "Shop",
      "monitorKind": "http",
      "kind": "auto",                // auto | manual
      "cause": "Unexpected HTTP status 503",
      "httpStatus": 503,
      "locationsAffected": ["skb", "sto"],
      "startedAt": "2026-06-10T14:32:00.000Z",
      "resolvedAt": null
    }
  ]
}

GET /api/v1/incidents/:id

One incident as listed, plus its public updates (newest first), the internal timeline in events (oldest first: opened, resolved, alert_sent, alert_failed, note) and diagnostics, the snapshot taken when downtime was confirmed: for HTTP the location, status, error, response headers and the first 4 KB of the body; for DNS the failing lookup (with every nameserver's reply in authoritative mode); null for manual incidents.

{
  "id": "9b2f6c3a-…",
  …                                  // as in the list
  "diagnostics": {
    "location": "skb",
    "checkedAt": "2026-06-10T14:32:00.000Z",
    "httpStatus": 503,
    "error": "Unexpected HTTP status 503",
    "headers": { "content-type": "text/html" },
    "bodySnippet": "<html>…"
  },
  "updates": [
    {
      "id": "61d0c3b2-…",
      "at": "2026-06-10T14:40:00.000Z",
      "status": "identified",
      "message": "A bad deploy — rolling back."
    }
  ],
  "events": [
    { "id": "e2f7a9c1-…", "at": "2026-06-10T14:32:05.000Z", "type": "opened", "message": "…" }
  ]
}

POST /api/v1/incidents

Open a manual incident: {"monitorId": "…", "cause": "Card payments fail at checkout"} (the cause up to 500 characters). It alerts the monitor's channels and notifies status-page subscribers, never changes the monitor's status and stays open until resolved by hand. Returns 201 with the incident as GET /api/v1/incidents/:id returns it.

POST /api/v1/incidents/:id/resolve

Resolves an open manual incident and notifies channels and subscribers; returns the incident. Automatic incidents resolve themselves when checks recover, so resolving one — or one that's already resolved — is a 409.

POST /api/v1/incidents/:id/updates

Publishes an update on every status page showing the monitor and sends it to their subscribers: status is investigating, identified, monitoring or resolved, message up to 2000 characters. Posting resolved doesn't resolve the incident. Returns 201 with the update (id, at, status, message).

DELETE /api/v1/incidents/:id/updates/:updateId

Takes a public update down. Manual incidents and updates notify subscribers, so a workspace can send at most 30 such notifications an hour (429); resolving always goes through.

Maintenance windows

During a window its monitors keep recording checks, but nothing alerts. Times are absolute instants — ISO 8601 with Z or an offset, kept to the minute — whereas the dashboard form takes wall-clock times. A weekly window repeats at its start's wall-clock time in the workspace timeZone, so its UTC time follows daylight saving.

GET /api/v1/maintenance

{
  "windows": [
    {
      "id": "4e7a2d91-…",
      "name": "Database upgrade",
      "startsAt": "2026-10-01T20:00:00.000Z",      // the first occurrence
      "endsAt": "2026-10-01T22:00:00.000Z",
      "repeatWeekly": true,
      "active": false,                             // running right now
      "nextStartsAt": "2026-10-15T20:00:00.000Z",  // the running or next occurrence
      "nextEndsAt": "2026-10-15T22:00:00.000Z",
      "over": false,                               // true once a one-off has ended
      "monitorIds": ["5db41d29-…"],
      "monitorCount": 1
    }
  ],
  "timeZone": "Europe/Copenhagen"
}

POST /api/v1/maintenance

Schedule a window: name, startsAt, endsAt and monitorIds are required, repeatWeekly defaults to false. It lasts at most 7 days and covers at least one monitor; a monitor id the workspace doesn't have is a 404. A workspace can have up to 100 upcoming or weekly windows (403). Returns 201 with the window.

curl -X POST -H "Authorization: Bearer vgl_api_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Database upgrade",
    "startsAt": "2026-10-01T22:00:00+02:00",
    "endsAt": "2026-10-02T00:00:00+02:00",
    "repeatWeekly": true,
    "monitorIds": ["5db41d29-…"]
  }' \
  https://vigil.xzz.dk/api/v1/maintenance

PATCH /api/v1/maintenance/:id

Change any of name, startsAt, endsAt, repeatWeekly and monitorIds (which replaces the list); the result must pass the same checks. Returns the window.

DELETE /api/v1/maintenance/:id

Removes the window; its monitors alert normally again.

Overview

GET /api/v1/overview

The dashboard header: monitors per status (scheduled windows count as maintenance) against the plan's limit, open incidents, and maintenance that is running or starts within 7 days (its current or next occurrence).

{
  "monitors": {
    "total": 12, "limit": 100,
    "up": 10, "down": 1, "degraded": 0, "challenged": 0,
    "pending": 0, "paused": 1, "maintenance": 0
  },
  "openIncidents": 1,
  "upcomingMaintenance": [
    {
      "id": "4e7a2d91-…",
      "name": "Database upgrade",
      "startsAt": "2026-10-15T20:00:00.000Z",
      "endsAt": "2026-10-15T22:00:00.000Z",
      "repeatWeekly": true,
      "active": false,
      "monitorCount": 1
    }
  ]
}

Alert channels

GET /api/v1/channels

The workspace's alert channels, newest first. confirmed is false for an email channel still waiting for its recipient — it alerts nobody until they confirm. Editor keys also get a masked destination: email addresses as they are; webhook, chat and ntfy URLs as the host and the end of the path (ntfy.sh/…/9f3k), with any host but a well-known provider's cut to its registrable domain (….pipedream.net/…); Pushover's user key masked — never a token, password or signing secret. Push channels report how many iPhones they reach.

{
  "channels": [
    {
      "id": "0f6d2c1e-…",
      "name": "On-call",
      "type": "slack",
      "confirmed": true,
      "createdAt": "2026-06-01T09:05:00.000Z",
      "destination": "hooks.slack.com/…/x7Kq",  // null for read-only keys
      "deviceCount": null                         // push channels only
    }
  ]
}

POST /api/v1/channels

Add a channel: type, name (only shown in Vigil) and the config its type takes. Channels can't be edited — add a new one and delete the old.

typeconfig
emailto — one address. Anyone but a verified member of the workspace is sent a confirmation link first.
slack, discord, teamsurl — the incoming-webhook URL
webhookurl, optional secret to sign payloads (see Webhook payloads)
ntfyurl (a topic URL), auth: none (default), token with token, or basic with user and pass
pushoveruser (user key) and token (application API token)
pushNone — notifications to every iPhone signed in to the workspace with the Vigil app (needs at least one)

URLs must be public (private and internal addresses are refused). With "attachToAllMonitors": true the channel is also added to every existing monitor and the response says to how many (attachedMonitors) — this is how the app turns on push for everything in one tap. New monitors start with every channel anyway. Returns 201 with the channel as GET lists it.

curl -X POST -H "Authorization: Bearer vgl_api_…" \
  -H "Content-Type: application/json" \
  -d '{"type": "push", "name": "iPhones", "config": {}, "attachToAllMonitors": true}' \
  https://vigil.xzz.dk/api/v1/channels

DELETE /api/v1/channels/:id

Removes a channel; monitors using it stop alerting through it.

POST /api/v1/channels/:id/test

Sends a test alert through the channel (10 per workspace an hour). An email channel still waiting for confirmation is a 409; a failed delivery is a 502 carrying the destination's error.

POST /api/v1/channels/:id/resend-confirmation

Mails an unconfirmed email channel's confirmation link again (3 per address a day). 409 when there's nothing to confirm, 503 when the server can't send email.

Status pages

GET /api/v1/status-pages

The workspace's public status pages, newest first, and whether its plan includes custom domains (customDomainsMessage says what to do when it doesn't).

{
  "pages": [
    {
      "id": "1b9e4c70-…",
      "name": "Acme status",
      "slug": "EqETayhXsX",
      "publicUrl": "https://vigil.xzz.dk/status/EqETayhXsX",
      "published": true,
      "allowSubscriptions": true,
      "customDomain": "status.acme.com",
      "customDomainVerified": true,
      "subscribers": { "email": 42, "webhook": 3 },  // email addresses once confirmed
      "monitorCount": 5,
      "createdAt": "2026-06-01T09:10:00.000Z"
    }
  ],
  "customDomainsAllowed": true,
  "customDomainsMessage": null
}

GET /api/v1/status-pages/:id

One page: the summary above plus description, logoUrl, accentColor, theme and its monitors in page order (monitorId, name, displayName, sortOrder). Editor keys also get domainVerification — the TXT record that proves a not-yet-verified custom domain, {"txtName": "_vigil-challenge.status.acme.com", "token": "vigil-verify=…"} or null — and webhookSubscribers (id, the URL with its path masked and the host shown as for channel destinations, e.g. https://….pipedream.net/…, failures, createdAt).

POST /api/v1/status-pages

Only name is required; a new page is published, light-themed, without subscriptions and without monitors unless you say otherwise. monitors lists the page's monitors in order; one the workspace doesn't have is a 404. A custom domain needs a plan that includes them (403), is refused when verified on another page (409) and isn't served until verified. Returns 201 with the page as GET returns it.

curl -X POST -H "Authorization: Bearer vgl_api_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme status",
    "description": "Live status of Acme services",
    "logoUrl": "https://acme.com/logo.png",
    "accentColor": "#00c873",
    "theme": "auto",                      // light | dark | auto
    "published": true,
    "allowSubscriptions": true,           // visitors subscribe by email or webhook
    "customDomain": "status.acme.com",
    "monitors": [
      { "monitorId": "5db41d29-…", "displayName": "Web shop" },
      { "monitorId": "8e0c47a1-…" }
    ]
  }' \
  https://vigil.xzz.dk/api/v1/status-pages

PATCH /api/v1/status-pages/:id

The same fields, all optional; monitors, when sent, replaces the page's list. Turning allowSubscriptions off hides the subscribe box and pauses notifications to existing subscribers. A new custom domain starts unverified with a fresh token; null removes it. Returns the page.

DELETE /api/v1/status-pages/:id

Deletes the page; its public link stops working.

POST /api/v1/status-pages/:id/regenerate-url

Issues a new random slug, so the old public link stops working immediately. Returns the page.

POST /api/v1/status-pages/:id/verify-domain

Checks the custom domain's TXT record (domainVerification) and returns {"ok": true, "message": "verified"} once it's there. Otherwise 422 with what's missing, 409 when the domain is verified on another page, 403 without a plan for custom domains. See Status pages for the DNS setup.

DELETE /api/v1/status-pages/:id/subscribers/:subscriberId

Removes one subscriber.

DELETE /api/v1/status-pages/:id/subscribers?type=webhook

Removes every webhook subscriber — anyone can add those on the public page. Returns {"ok": true, "removed": 3}.

Team

Roles are owner, admin (editor) and member (viewer). Every team endpoint needs an app sign-in; workspace keys get 403.

GET /api/v1/team

Members in the order they joined (isYou marks the signed-in person) and, for owners and admins, pending invitations — they expire after 14 days. When the server doesn't send email (invitesByEmail: false), each invitation also carries the link to share by hand.

{
  "members": [
    { "userId": "a41f0b6e-…", "name": "Casper", "email": "you@example.com", "role": "owner", "isYou": true }
  ],
  "invitations": [
    {
      "id": "d3b8e2f5-…",
      "email": "new@example.com",
      "role": "member",
      "createdAt": "2026-06-10T10:00:00.000Z",
      "expiresAt": "2026-06-24T10:00:00.000Z"
    }
  ],
  "invitesByEmail": true
}

POST /api/v1/team/invites

Invite someone: {"email": "new@example.com", "role": "admin"} (role defaults to member). Re-inviting an address replaces its pending invitation; someone already on the team is a 409, and more than 20 invitations an hour per workspace or 3 a day to one address a 429. People with an account accept it too, signed in as that address. Returns 201 with the invitation.

DELETE /api/v1/team/invites/:id

Revokes an invitation; its link stops working.

DELETE /api/v1/team/members/:userId

Removes someone from the workspace, along with their app sign-ins and any invite links they could still hold. Owners can't be removed (403), and nobody removes themselves (400).

Workspace

GET /api/v1/workspace

The name, plan and usage (plans are changed by the Vigil team — upgradeHint says how to ask), quiet hours and the time zone they're in.

{
  "name": "HTML ApS",
  "plan": "pro",
  "planLabel": "Pro",
  "monitorLimit": 100,
  "monitorCount": 12,
  "upgradeHint": "Email support@example.com to change your plan.",
  "quietHours": { "enabled": true, "start": "22:00", "end": "07:00" },
  "timezone": "Europe/Copenhagen"
}

PATCH /api/v1/workspace

The owner's app sign-in only: name (up to 200 characters) and/or "quietHours": {"enabled": true, "start": "22:00", "end": "07:00"} — a daily window (HH:MM in the workspace timezone) during which team alerts stay silent; enabled: false clears it. Status-page subscribers aren't affected. Returns the workspace like GET.

API keys

App sign-ins only — a workspace key never manages keys, not even reading them (403).

GET /api/v1/api-keys

The workspace's keys, newest first: workspace keys and app sign-ins (kind), with isThisKey marking the one making the request. Any role's app sign-in can list them. Key values are never returned — only their hashes are stored.

{
  "keys": [
    {
      "id": "b6f1d8a3-…",
      "name": "App · Work iPhone",
      "access": "full",            // full | read
      "kind": "app",               // app | workspace
      "lastUsedAt": "2026-06-10T14:33:00.000Z",
      "createdAt": "2026-06-01T09:00:00.000Z",
      "isThisKey": true
    }
  ]
}

POST /api/v1/api-keys

Owners: {"name": "Grafana", "access": "read"} (read by default, or full — full-access keys act as an owner, except on account endpoints). Returns 201 with the listing entry as key and the raw key as apiKey, shown this once. A workspace can have up to 50 workspace keys (app sign-ins don't count; 403).

DELETE /api/v1/api-keys/:id

Owners: revokes a key, app sign-ins included. Anything using it stops working at once — including this request's own key, if that's the one revoked.

Push devices

Devices are registered by the iOS app. Besides editors, a viewer's own app sign-in can register and remove its own devices; read-only workspace keys can't.

GET /api/v1/devices

The workspace's registered devices (id, environment, name, createdAt, lastSeenAt) — never their tokens.

POST /api/v1/devices

Registers an APNs device token so the iOS app receives incident push notifications. Idempotent — re-registering refreshes the token. environment is "production" (default) or "sandbox" for debug builds; name is an optional device label. The device is tied to the key that registered it: revoking the key stops pushes to it.

curl -X POST -H "Authorization: Bearer vgl_api_…" \
  -H "Content-Type: application/json" \
  -d '{"token": "a1b2c3…", "environment": "production", "name": "Work iPhone"}' \
  https://vigil.xzz.dk/api/v1/devices

DELETE /api/v1/devices/:token

Unregisters a device (the app calls this on sign-out). Dead tokens are also pruned automatically when Apple rejects them.

POST /api/v1/devices/test

Sends a test push to the workspace's devices (a viewer's app sign-in reaches only its own) and returns {"sent": 1, "total": 1}. 400 when no device is registered, 503 when the server has no APNs credentials.

Platform

For the operator running the Vigil platform, not for workspaces. These endpoints only accept the app sign-in of a superadmin (POST /api/v1/auth/login); workspace keys never qualify, whoever created them (403).

EndpointDoes
GET /api/v1/admin/overviewThe admin dashboard: tiles, what needs attention (silent probes, unreadable status pages, workspaces over their plan), latestSignups and the raw stats
GET /api/v1/admin/locationsEvery probe location: liveness, agent version and pending self-update, capabilities. Keys are never returned
POST /api/v1/admin/locationsRegister one: name, code. Returns 201 with the probe's apiKey — only here — and the lines to set on its server
PATCH /api/v1/admin/locations/:idname, code, isActive; the key stays. A disabled location's probe is refused until re-enabled
DELETE /api/v1/admin/locations/:idIts key stops working at once; its raw check results go with it (daily uptime stays)
GET /api/v1/admin/workspaces?q=Every workspace (newest first, up to 500; search by name or owner email) with usage, plus the plans there are
PATCH /api/v1/admin/workspaces/:id{"plan": "pro"} — saved even below current usage, with warnings saying what's over the new limits
GET /api/v1/admin/users?q=Every user (newest first, up to 500; search by name or email) with their workspaces, last sign-in and the soleWorkspaces deleting them would take along
DELETE /api/v1/admin/users/:idDeletes a user and the workspaces they're alone in. Refused for yourself, superadmins and the only owner of a workspace with other members
POST /api/v1/admin/users/:id/verifyConfirms a user's email address by hand
POST /api/v1/admin/users/:id/resend-verificationEmails a fresh verification link; sent: false when already verified
GET /api/v1/admin/providersThe third-party status pages monitors follow, failing ones first, with read health and usage
POST /api/v1/admin/providers/:id/refreshRe-reads a status page now (502 with the reason when it fails)

Notes

  • Uptime percentages are calculated over daily buckets; null means no data yet.
  • For pushing data out in real time, use signed webhooks instead of polling — see Webhook payloads.
  • AI assistants can use the same keys through the MCP server.