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
- Log in and open API tokens in the dashboard sidebar.
- Pick the abilities the script actually needs. A status board wants
monitors:readand nothing else. - If you belong to more than one team, pin the token to one of them so it can never touch the others.
- 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 /teams | teams:read | Teams this token can reach, and the identifiers every other endpoint accepts as team |
GET /monitors | monitors:read | Every monitor with status, interval, and ID. Filter with ?tag= |
GET /monitors/{id} | monitors:read | One monitor in detail: config (secrets masked), 24h, 7d, and 30d uptime, recent incidents |
POST /monitors | monitors:write | Create an HTTP, keyword, JSON, SSL, TCP, DNS, ping, or heartbeat monitor |
PATCH /monitors/{id} | monitors:write | Edit in place, keeping check history. The type cannot change |
DELETE /monitors/{id} | monitors:write | Remove a monitor and stop collecting its history |
POST /monitors/{id}/pausePOST /monitors/{id}/resume | monitors:write | Stop and restart checking. A pause never expires: for planned work use a maintenance window instead |
GET /me | teams:read | Your name, email, and readable teams with membership roles |
GET /incidents/{id} | incidents:read | Incident details and notification delivery history. Inaccessible incidents return 404 |
GET /incidents | incidents:read | Recent outages, newest first. Filter with ?monitor_id=, ?ongoing_only=, ?limit= |
POST /incidents/{id}/acknowledge | incidents:write | Acknowledge 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-pagesPOST /status-pages | status-pages:readstatus-pages:write | List your status pages, or publish one with chosen monitors in display order |
GET /maintenance-windowsPOST /maintenance-windows | maintenance-windows:readmaintenance-windows:write | List windows (filter with ?state=), or schedule downtime for chosen monitors, a tag, or the whole team |
POST /maintenance-windows/{id}/endDELETE /maintenance-windows/{id} | maintenance-windows:write | End an active window early, or cancel one entirely |
GET /fleet-status | fleet-status:read | One compact health rollup per team, built for dashboards |
GET /event-sources | event-sources:read | List inbound sources, counts, token prefixes and token-free ingest URLs |
POST /event-sources | event-sources:write | Create a source; 201 includes its plaintext token once |
PATCH /event-sources/{id} | event-sources:write | Update name, kind, stale_after_minutes or default_level |
DELETE /event-sources/{id} | event-sources:write | Delete a source and its event monitors (their open incidents resolve) |
POST /event-sources/{id}/rotate-token | event-sources:write | Show a replacement token once; the old token stops working immediately |
GET /channels | channels:read | List channels, email member routing, verification and health; secrets are masked |
POST /channels | channels:write | Create a channel; email addresses require confirmation |
PATCH /channels/{id} | channels:write | Update a channel; omitted config keys and [redacted] values retain existing secrets |
DELETE /channels/{id} | channels:write | Delete a channel |
POST /channels/{id}/test-page | channels:write | 202 with data.delivery: {id, status_url}; sends a real page |
GET /deliveries/{id} | incidents:read | Read delivery outcome and per-device receipts |
POST /deliveries/receipts/{receipt} | devices:write | Stamp receipt received_at once; other users’ device receipts return 404 |
POST /incidents/{id}/resolve | incidents:write | Resolve as the token owner; repeated calls preserve the original resolution |
PATCH /devices/{token} | devices:write | Report notification permissions and app version, refresh last_seen_at, return health |
GET /me/preferences | teams:read | Read your daily quiet push preference and local time |
PATCH /me/preferences | teams:read | Save 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.
| Parameter | Values |
|---|---|
realert_interval_minutes | 5, 10, 15, 30, or 60 minutes. Null disables repeats (the default). |
realert_max | Integer 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 freeInbound 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.