undownable

Using the REST API

Everything the MCP server can do is a plain HTTP endpoint too, so a cron job, a deploy script, or a status board can drive your monitoring without speaking MCP. Same tokens, same rules, same answers: both surfaces run the same code underneath.

Base URL

https://undownable.com/api/v1

JSON in, JSON out, authenticated with an Authorization: Bearer <token> header. Versioned, so a script written today keeps working: a breaking change ships as /api/v2.

1. Create a token

  1. Log in and open API tokens in the dashboard sidebar.
  2. Pick the abilities the script actually needs. A status board wants monitors:read and nothing else.
  3. If you belong to more than one team, pin the token to one of them so it can never touch the others.
  4. Copy the token immediately: it is shown exactly once.
curl -H "Authorization: Bearer $UNDOWNABLE_TOKEN" \
  https://undownable.com/api/v1/monitors

2. Two rules worth knowing up front

A resource write must name its team

Pass team on every create, update, or delete. Reads may omit it, in which case they span every team the token can see. We refuse to guess on a write on purpose: the alternative is following whichever team you last clicked in the dashboard, which would one day point a nightly script at the wrong systems without anything having changed on its side. A token pinned to one team may omit it, because there is nothing to guess. Incident acknowledgement and resolution use the same scope as incident reads: its public ID selects the incident, and an optional team narrows the teams the token can reach.

Monitor config replaces, it does not merge

Sending config on an update overwrites the stored settings wholesale, so include every key the monitor still needs. Drop the keyword from a keyword monitor and it quietly becomes a plain reachability check that passes against an error page.

3. Endpoints

Every response is shaped {"data": ..., "meta": ...}. Monitor, incident, and window IDs are the ULIDs the list endpoints return.

Endpoint Ability What it does
GET /teamsteams:readTeams this token can reach, and the identifiers every other endpoint accepts as team
GET /monitorsmonitors:readEvery monitor with status, interval, and ID. Filter with ?tag=
GET /monitors/{id}monitors:readOne monitor in detail: config (secrets masked), 24h, 7d, and 30d uptime, recent incidents
POST /monitorsmonitors:writeCreate an HTTP, keyword, JSON, SSL, TCP, DNS, ping, or heartbeat monitor
PATCH /monitors/{id}monitors:writeEdit in place, keeping check history. The type cannot change
DELETE /monitors/{id}monitors:writeRemove a monitor and stop collecting its history
POST /monitors/{id}/pause
POST /monitors/{id}/resume
monitors:writeStop and restart checking. A pause never expires: for planned work use a maintenance window instead
GET /meteams:readYour name, email, and readable teams with membership roles
GET /incidents/{id}incidents:readIncident details and notification delivery history. Inaccessible incidents return 404
GET /incidentsincidents:readRecent outages, newest first. Filter with ?monitor_id=, ?ongoing_only=, ?limit=
POST /incidents/{id}/acknowledgeincidents:writeAcknowledge as the token owner. Returns 200 with the incident, including on retries; the original person, time, and source stay unchanged. An inaccessible incident returns 404
GET /status-pages
POST /status-pages
status-pages:read
status-pages:write
List your status pages, or publish one with chosen monitors in display order
GET /maintenance-windows
POST /maintenance-windows
maintenance-windows:read
maintenance-windows:write
List windows (filter with ?state=), or schedule downtime for chosen monitors, a tag, or the whole team
POST /maintenance-windows/{id}/end
DELETE /maintenance-windows/{id}
maintenance-windows:writeEnd an active window early, or cancel one entirely
GET /fleet-statusfleet-status:readOne compact health rollup per team, built for dashboards
GET /event-sourcesevent-sources:readList inbound sources, counts, token prefixes and token-free ingest URLs
POST /event-sourcesevent-sources:writeCreate a source; 201 includes its plaintext token once
PATCH /event-sources/{id}event-sources:writeUpdate name, kind, stale_after_minutes or default_level
DELETE /event-sources/{id}event-sources:writeDelete a source and its event monitors (their open incidents resolve)
POST /event-sources/{id}/rotate-tokenevent-sources:writeShow a replacement token once; the old token stops working immediately
GET /channelschannels:readList channels, email member routing, verification and health; secrets are masked
POST /channelschannels:writeCreate a channel; email addresses require confirmation
PATCH /channels/{id}channels:writeUpdate a channel; omitted config keys and [redacted] values retain existing secrets
DELETE /channels/{id}channels:writeDelete a channel
POST /channels/{id}/test-pagechannels:write202 with data.delivery: {id, status_url}; sends a real page
GET /deliveries/{id}incidents:readRead delivery outcome and per-device receipts
POST /deliveries/receipts/{receipt}devices:writeStamp receipt received_at once; other users’ device receipts return 404
POST /incidents/{id}/resolveincidents:writeResolve as the token owner; repeated calls preserve the original resolution
PATCH /devices/{token}devices:writeReport notification permissions and app version, refresh last_seen_at, return health
GET /me/preferencesteams:readRead your daily quiet push preference and local time
PATCH /me/preferencesteams:readSave quiet_push_enabled and quiet_push_time (HH:MM)

4. A worked example

Open a maintenance window before you take something down, and close it when you are finished. This is the pattern worth copying into a deploy script: a window ends itself, so forgetting to close it leaves you monitored rather than blind.

WINDOW=$(curl -sS -X POST https://undownable.com/api/v1/maintenance-windows \
  -H "Authorization: Bearer $UNDOWNABLE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"team": "acme", "name": "Rack move", "tag": "homelab", "duration_minutes": 90}' \
  | jq -r .data.id)

# ... do the work ...

curl -sS -X POST https://undownable.com/api/v1/maintenance-windows/$WINDOW/end \
  -H "Authorization: Bearer $UNDOWNABLE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"team": "acme"}'

Paging settings

Pass these optional fields to POST /monitors or PATCH /monitors/{id}. Available on every plan, including Free. Repeats continue while an incident is open and unacknowledged, up to the chosen maximum. Omitted fields stay unchanged on update. Create, update, get, and list responses include both settings.

ParameterValues
realert_interval_minutes5, 10, 15, 30, or 60 minutes. Null disables repeats (the default).
realert_maxInteger from 1 to 24. Default: 6 repeat alerts per incident.

Incident payloads include acknowledged, acknowledged_at (ISO 8601 or null), acknowledged_by ({name} of the account, or null), acknowledged_via (panel, api, app, mcp, link, or null), and realert_count. A signed link can acknowledge without an account. Acknowledgement stops repeats; recovery resolves the incident.

Incident history and uptime

GET /incidents orders by started_at descending, then ID descending. Pass meta.next_cursor as ?cursor= for the next page; null means the end. limit defaults to 20 (maximum 100). meta.open_count and meta.unacknowledged_count count ongoing and ongoing unacknowledged incidents across the filtered scope, before pagination. meta.badge_count is the iOS app badge number: ongoing unacknowledged incidents across every team the token can read, whatever team names, excluding monitors set to "Do not alert" (and, for an iOS app token, teams muted in the app). meta.badge_oldest is the oldest incident behind that number (the list shape plus team), or null when the badge is zero; it ignores team, ongoing_only and the page.

Incident details add last_check, the newest check sample since the incident started ({checked_at, status, status_code, response_ms, error, region}, or null when none is retained). The incident list never carries it.

Incident details add deliveries: rows with channel_label, type, transition, alert_sequence, status (sent, suppressed, pending), and sent_at (ISO 8601 or null).

Monitor lists add 30 days of history; monitor details add 90 days, oldest first. Each day has day (YYYY-MM-DD), uptime (percentage or null), and level: up at 99.95% or higher, dip at 95% or higher, down below 95%, none without checks. Days follow UTC. Details also add heatmap, with four-hour cells containing start, end, level, checks, and ratio. The heatmap covers retained history (up to 90 days); its levels are up-1 through up-4, dip, partial, down, and empty.

Every monitor, in lists and details, carries ssl_expires_at (ISO 8601 expiry of the certificate the last SSL check parsed) and ssl_days_remaining (whole days left, negative once expired). Both are null for other monitor types and until a check parses a certificate; a later failed handshake keeps the last known date.

Sign in on iOS

Sign in with unauthenticated POST /api/v1/mobile/login and JSON fields email, password, and device_name. It returns {token, user: {name, email}, teams: [{id, name, role}]}. Wrong credentials return 422 with one message for an unknown address and a wrong password alike; an unverified address returns 403. Five attempts a minute are allowed per address and IP.

An account with an authenticator app enabled returns {challenge, method: "app"} instead of a token. Redeem it within five minutes using POST /api/v1/mobile/login/challenge with challenge and code, where the code is the current six-digit one-time code or a recovery code. Wrong, expired, and replayed codes return 400, and five wrong codes burn the challenge.

Tokens are named iOS: <device>, span the user's teams, and grant exactly teams:read monitors:read monitors:write incidents:read incidents:write maintenance-windows:read devices:write client:ios. A token signed in before maintenance-windows:read was granted gets 403 from GET /maintenance-windows until the app signs in again. GET /api/v1/me returns data.user and data.teams with the same identity shape, each team carrying muted. PATCH /api/v1/me/teams/{team} with {muted: true|false} mutes a team in the app: its incidents and monitors leave the all-teams lists and counts, and the member gets no pushes for it, while naming the team still reads it. Other tokens and the web app ignore the mute. Acknowledgements using client:ios record acknowledged_via=app. DELETE /api/v1/mobile/token with bearer authentication revokes only that token. API tokens in Settings labels these sessions "iOS app".

Devices and Live Activities

The iOS app registers its push token with POST /api/v1/devices (token, environment, app_version) and signs it out with DELETE /api/v1/devices/{token}. Both need devices:write and a token session, and only reach the caller's own devices.

For the incident Live Activity it also reports ActivityKit tokens for a registered device: PUT /api/v1/devices/{token}/activity-tokens/push-to-start with {token} (one per device; when another of the account's devices holds the same token, the reporting device keeps it and the other device is signed out) and PUT /api/v1/devices/{token}/activity-tokens/update with {incident_id, token, activity_id} (one per device, incident and ActivityKit activity; activity_id is optional, up to 64 letters, digits, and hyphens, and without it the token is one per device and incident). Both are upserts that answer 204; another account's device, a signed-out device, or an incident the caller cannot see returns 404. The server starts, updates and ends the activity itself: one per device, for the oldest open unacknowledged incident that would count toward the badge. Device endpoints have no MCP counterpart.

Good to know

Errors say what to do

Failures return a message, and validation failures an errors object. 422 means fix the request, 403 means the token lacks the ability, 404 means the token cannot see it, 409 means the resource is in the wrong state.

Plan limits apply

Monitor count, fastest check interval, and status page count follow your plan, and the error names the limit you hit.

Rate limited

120 requests per minute per token. The MCP server has its own limit of 60 per minute.

Secrets stay masked

Request headers you store on a monitor, and anything else that looks like a credential, come back redacted. Lists return config key names only.

Put it in your pipeline

Free includes 10 monitors, every alert channel, and a public status page. No card required.

Start monitoring free

Inbound events

Create a source with POST /event-sources and {team, name, kind: native|alertmanager, stale_after_minutes?, default_level?}. The default level is page and the stale threshold defaults to 360 minutes. The response contains {id, team, name, kind, token_prefix, stale_after_minutes, default_level, last_event_at, monitors_count, open_incidents, ingest_url, created_at}, plus token on create or rotation only. Sources have public ULIDs. Lists never return plaintext tokens or hashes. Source and channel writes require a team slug unless the token is pinned to that team.

Post native events to https://undownable.com/ingest with Authorization: Bearer <source token>, or to /ingest/{token}. Each source and key identifies one event monitor. Repeat firing events update that incident; a notify upgraded to page receives one additional alert. Resolve the key when it clears.

{
  "key": "homelab/disk-space",
  "status": "firing",
  "level": "page",
  "title": "Disk space low",
  "summary": "Root filesystem has less than 5% free space.",
  "url": "https://example.com/runbooks/disk-space",
  "labels": {"instance": "nas", "severity": "critical"}
}

Send one object or {"events": [...]} with up to 100 events. Required fields are key (up to 191 characters) and status (firing or resolved). A new firing key also requires title (up to 191). Optional fields are level (notify or page, otherwise the source default), summary (up to 2000), an HTTP(S) url (up to 2048), and labels (up to 32 scalar entries; names longer than 64 and values longer than 256 characters are clipped, and line breaks become spaces). Unknown keys are ignored. Firing events retain their URL and labels on the incident as event_url and event_labels. Later firing events replace supplied values; omitted or null values preserve the stored values, and an empty labels object clears labels. This also applies while a manual resolve holds the incident closed. Alerts include the runbook URL when present; Slack, Discord and Telegram include label pairs (clipped to fit Discord and Telegram message limits), and webhooks include both structured fields. Email keeps View monitor and adds Open runbook. The response is 202 {"accepted": n, "opened": n, "resolved": n, "ignored": n}. Unknown or revoked source tokens return 401; invalid events return Laravel validation errors with 422. Ingest allows 600 requests per minute per source token and uses no session or CSRF cookie.

For Alertmanager, create an alertmanager source and use its token with the version 4 adapter:

receivers:
  - name: undownable
    webhook_configs:
      - url: https://undownable.com/ingest/alertmanager
        send_resolved: true
        http_config:
          authorization:
            type: Bearer
            credentials: "<source token>"

The adapter uses the fingerprint as the key (or a hash of sorted labels), alertname and instance as the title, summary or description as the summary, and runbook_url, generatorURL or externalURL as the URL. Critical, page, emergency and p1 severity become page (case-insensitive); other severities become notify. Missing severity uses the source default. Translation stops at the door: native and Alertmanager events share the same incident lifecycle.

Paging

Notify is a normal alert; page is Time Sensitive on iOS and urgent on ntfy, with a [PAGE] email prefix. Critical Alerts require the server flag, channel opt-in, and Apple permission. Monitor payloads include degraded_alerts (off, notify or page); event monitors also include event_source and event_key. Incidents include alert_level, resolved_by (name or null), resolved_via, stale_at, event_count, event_url and event_labels (both null when absent). Acknowledge means “I'm on it” and stops repeats while leaving the incident open. Resolve closes it; resolved_via is api for scripts and CLI, app for iOS, mcp for agents, or panel for the web dashboard (the same words as acknowledged_via). A manually resolved event stays closed while it is still firing, until the sender resolves and fires again. A quiet source with open incidents receives a stale warning after its configured threshold. Maintenance suppresses event alerts while keeping their state; an incident still firing at window end receives one notice.

Channel create accepts {team, type, label, config, members?, is_active?}. Types are email, slack, discord, webhook, ntfy, telegram and push. Config uses email, webhook_url, ntfy_server/ntfy_topic/ntfy_token, bot_token/chat_id, or push all_members/critical. Push members are current team members' emails; internal member IDs are never exposed. Responses include {id, team, type, label, config, members, is_active, verified, health, created_at}. Secret URLs, topics and tokens use [redacted]. Channel PATCH preserves omitted config keys and existing masked values. Email address changes require confirmation again.

A test page returns 202 with data.delivery.id and status_url. Fetch that URL to read {id, transition, alert_level, outcome, delivered_at, received, receipts: [{device, sent_at, received_at}], undelivered_notified_at}. Outcomes are pending, sent, suppressed or skipped. A provider acceptance is not a phone receipt: received is true only when every device receipt is confirmed, and false when there are none. The iOS extension posts to /deliveries/receipts/{receipt} with the app token; only the device owner can stamp it. Retries return the original received_at. Pages unconfirmed two minutes after provider acceptance trigger an email to the device owner. unctl test-page --wait waits for these receipts and exits unsuccessfully on timeout.

Flap protection suppresses repeated monitor alerts while preserving recovery notices. Five page events in ten minutes open a team storm: one storm page replaces further pages until ten minutes pass without a new page. Notify alerts and test pages bypass the storm guard. Device health reports accept notification_authorization (authorized, denied, notDetermined, provisional or ephemeral), time_sensitive_setting (enabled, disabled or notSupported), and optional app_version. The response is data.health: {state, since}. States are ok, dead_token, permission_revoked, time_sensitive_off or unseen; a reporting device becomes unseen after 72 hours without check-in. Health changes email the account owner. GET /fleet-status adds top-level pager_health for active member devices: [{user, device, state, since, last_seen_at, notification_authorization, time_sensitive_setting}].

Daily all quiet is opt-in. Read or PATCH /me/preferences with {"quiet_push_enabled": true, "quiet_push_time": "09:00"}. Time must be zero-padded HH:MM in your account time zone (UTC when unset). The server sends one passive daily summary per local date, including open incident counts when needed. This preference and device transport use the existing app token abilities. unctl paging export saves versioned channel, source and monitor settings without source tokens or device credentials. unctl paging apply prints a plan; use --yes to apply, --dry-run to inspect, and --prune --yes only to delete unmatched settings.