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:
| Role | Can |
|---|---|
Read-only — viewers (member) and read-only keys | Every GET. |
Editor — admin | Also 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 keys | Everything an editor can. With the owner's app sign-in also the workspace name, quiet hours and API keys. |
| Superadmin | The 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 get413. - 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.
| Status | Means |
|---|---|
| 400 | Invalid input: a bad value, a misplaced field, or a field the monitor's kind doesn't take |
| 401 | Missing, invalid or revoked key |
| 403 | The key's role can't do this, or the plan doesn't include it (monitor limit, custom domains) |
| 404 | Not found — also for ids that belong to another workspace |
| 409 | Not possible in the current state, e.g. resolving an automatic incident |
| 413 | Request body too large |
| 422 | A live lookup failed: DNS records, a status page, a domain's TXT record |
| 429 | Rate limited — try again in a few minutes |
| 502 | The other side failed: an alert delivery, a status-page read, an email |
| 503 | Not 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/monitorsRequest 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/monitorsExpected 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
{},""ornullclear headers, body and keyword. A blankauthPasskeeps the stored password,nullremoves it, and clearingauthUserremoves both. Switching to GET or HEAD drops the body. - DNS: a partial
dnsobject is merged over the current settings, so send only what changes. ChangingrecordTypeneeds newexpectedvalues 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 andchannelIds. - Third-party:
name,notifyRecovery,notifyDegraded,renotifySecondsandchannelIds; 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/monitorsPATCH /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/maintenancePATCH /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.
| type | config |
|---|---|
email | to — one address. Anyone but a verified member of the workspace is sent a confirmation link first. |
slack, discord, teams | url — the incoming-webhook URL |
webhook | url, optional secret to sign payloads (see Webhook payloads) |
ntfy | url (a topic URL), auth: none (default), token with token, or basic with user and pass |
pushover | user (user key) and token (application API token) |
push | None — 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/channelsDELETE /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-pagesPATCH /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/devicesDELETE /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).
| Endpoint | Does |
|---|---|
GET /api/v1/admin/overview | The admin dashboard: tiles, what needs attention (silent probes, unreadable status pages, workspaces over their plan), latestSignups and the raw stats |
GET /api/v1/admin/locations | Every probe location: liveness, agent version and pending self-update, capabilities. Keys are never returned |
POST /api/v1/admin/locations | Register 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/:id | name, code, isActive; the key stays. A disabled location's probe is refused until re-enabled |
DELETE /api/v1/admin/locations/:id | Its 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/:id | Deletes 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/verify | Confirms a user's email address by hand |
POST /api/v1/admin/users/:id/resend-verification | Emails a fresh verification link; sent: false when already verified |
GET /api/v1/admin/providers | The third-party status pages monitors follow, failing ones first, with read health and usage |
POST /api/v1/admin/providers/:id/refresh | Re-reads a status page now (502 with the reason when it fails) |
Notes
- Uptime percentages are calculated over daily buckets;
nullmeans 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.