Status pages
Status pages
A status page is the public face of your monitoring. Components show live status from monitors, pushes or manifests, and incidents and maintenance appear on it automatically.
Create a page
curl -X POST https://upbutler.com/api/v1/pages \
-H "Authorization: Bearer $UPBUTLER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Status",
"slug": "acme",
"theme": { "preset": "midnight", "accent": "#22c55e" },
"branding": { "logoText": "Acme", "websiteUrl": "https://acme.com", "supportUrl": "https://acme.com/support" }
}'The page is live at https://upbutler.com/s/acme. Wherever the API takes a pageId, you can pass either the id (pg_…) or the slug. Your plan sets how many pages you can have: 1 on Free, 1 on Starter, 5 on Pro, 20 on Business.
Slugs
Lowercase letters, digits and dashes, 3–48 characters, globally unique. If you leave it out, it is derived from the name (and gets -status appended if the result is reserved or too short). Reserved: app, api, admin, www, status, docs, login, new, pricing, blog, help, support, mail. A taken slug returns 409 conflict.
Page fields
Accepted by POST /pages and PATCH /pages/:pageId. Updates merge: nested objects (theme, branding, settings, seo) only change the keys you send. In branding, sending null removes a value.
| Field | Type | Description |
|---|---|---|
namerequired | string | max length 100 |
slug | string | Public URL slug (/s/<slug>). 3–48 characters, or omit to derive it from name.pattern ^[a-z0-9](?:[a-z0-9-]{1,46}[a-z0-9])?$ |
description | string | Shown under the page title.max length 500 |
customDomain | string | null | Starter and up (rolling out). A hostname such as status.example.com. null removes it.pattern ^(?=.{4,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$ · nullable |
visibility | string | public (default) or private.publicprivate |
theme | object | |
theme.preset | string | One of: midnight, grid, daylight, paper.max length 30 |
theme.mode | string | dark or light. Set by the preset.darklight |
theme.accent | string | Hex color such as #22c55e.pattern ^#[0-9a-fA-F]{6}$ |
theme.tokens | map<string, string> | Override individual color tokens. See Themes. |
theme.fontSans | string | UI font: Geist, Inter, Manrope, IBM Plex Sans, Space Grotesk, System.max length 40 |
theme.fontMono | string | Mono font: Geist Mono, IBM Plex Mono, JetBrains Mono, System Mono.max length 40 |
theme.radius | integer | Corner radius in px.min 0 · max 24 |
theme.customCss | string | Pro and up. Raw CSS appended to the page.max length 50,000 |
branding | object | |
branding.logoUrl | string | null | max length 1,000 · nullable |
branding.logoDarkUrl | string | null | max length 1,000 · nullable |
branding.logoText | string | null | max length 60 · nullable |
branding.faviconUrl | string | null | max length 1,000 · nullable |
branding.websiteUrl | string (url) | null | max length 500 · nullable |
branding.supportUrl | string (url) | null | max length 500 · nullable |
branding.headerLinks | object[] | max 8 items |
branding.headerLinks[].labelrequired | string | max length 60 |
branding.headerLinks[].urlrequired | string (url) | max length 500 |
branding.footerLinks | object[] | max 20 items |
branding.footerLinks[].labelrequired | string | max length 60 |
branding.footerLinks[].urlrequired | string (url) | max length 500 |
branding.footerText | string | null | max length 300 · nullable |
branding.hidePoweredBy | boolean | Starter and up. Removes the “Powered by UpButler” footer (also in subscriber emails). |
settings | object | |
settings.uptimeDays | integer | Days of uptime history bars (default 90).min 7 · max 90 |
settings.showUptimePercent | boolean | Show uptime % next to components (default true). |
settings.showResponseTimes | boolean | Allow public response-time charts (components opt in with showResponseTimes) |
settings.language | string | Public page + subscriber email language; auto = visitor Accept-Languageautoendefresitptnlelpltrja |
settings.autoIncidents | boolean | Monitor and push failures open public incidents automatically (default true). |
settings.incidentMinStatus | string | Lowest component status that opens an automatic incidentdegradedpartial_outagemajor_outage |
settings.aiPublicUpdates | boolean | Use AI-written public summaries for automatic incidents (default true). |
settings.aiGuidelines | string | Rules for AI-written public text, e.g. "Never mention internal infrastructure"max length 2,000 |
settings.aiAvoidTerms | string[] | AI public text containing any of these is replaced by a neutral templatemax 50 items |
settings.subscriptions | object | Which subscription types visitors may use (all default true). |
settings.subscriptions.email | boolean | |
settings.subscriptions.webhook | boolean | |
settings.subscriptions.slack | boolean | |
settings.subscriptions.rss | boolean | |
settings.timezone | string | IANA zone for dates on the page (default UTC).max length 60 |
seo | object | |
seo.title | string | max length 120 |
seo.description | string | max length 300 |
seo.noindex | boolean | Ask search engines not to index the page. |
Themes
Pick a preset, then adjust the accent, individual tokens, fonts and radius. Switching presets resets the token overrides and applies that preset's accent, fonts and radius, unless you send them in the same request.
| Preset | Look | Mode | Accent | Fonts | Radius |
|---|---|---|---|---|---|
midnight | Dark, quiet and precise. Hairline borders, mono labels. | dark | #22c55e | Inter / JetBrains Mono | 12px |
grid | Developer-tool dark with a masked line grid and soft glow behind the header. | dark | #6366f1 | Manrope / IBM Plex Mono | 12px |
daylight | Clean light theme for consumer-facing brands. | light | #16a34a | Inter / JetBrains Mono | 12px |
paper | Warm off-white with an editorial feel. | light | #c2410c | IBM Plex Sans / IBM Plex Mono | 6px |
Design tokens
theme.tokens overrides the CSS custom properties (--ub-<token>) used by the page renderer: bg, surface, surface-2, border, border-strong, text, muted, faint, ok, warn, partial, bad, maint, unknown, accent, accent-fg. The Grid preset also uses glow and grid.
Token names must match [a-z0-9-] (max 30 characters). Values may contain only letters, digits, spaces and #(),.%- (max 80 characters), so #0b0c0e and rgba(255,255,255,0.08) work. Anything else is silently dropped.
{
"theme": {
"preset": "paper",
"accent": "#1d4ed8",
"radius": 4,
"tokens": { "bg": "#fbf8f1", "ok": "#15803d" }
}
}Custom CSS (theme.customCss, up to 50,000 characters; Pro and up) is appended to the page. <style> and <script> tags are stripped. Sending custom CSS on a plan without it returns 402 plan_limit. Hiding the “Powered by UpButler” footer (branding.hidePoweredBy) needs Starter or up.
Groups
Groups are the sections of a page. They are created on the fly when you add a component with a new group name. To reorder, rename, describe or collapse them, replace the whole list:
curl -X PUT https://upbutler.com/api/v1/pages/acme/groups \
-H "Authorization: Bearer $UPBUTLER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"groups": [
{ "id": "grp_0n3q8kz4a1b2c3d4e5f", "name": "APIs" },
{ "name": "Dashboard", "description": "app.acme.com" },
{ "name": "Integrations", "collapsed": true }
]
}'Order in the array is display order. Send an existing group's id to keep it (and its components). Entries without a known id become new groups. Components in groups you leave out become ungrouped.
Components
A component is one row on the page. POST /pages/:pageId/components creates it, PATCH /components/:id updates it and DELETE /components/:id removes it. Your plan sets how many components a page can have: 10 on Free, 30 on Starter, 100 on Pro, 500 on Business. One more returns 402 plan_limit. After a downgrade, components over the cap are hidden from the bottom of the page up and come back on their own when you upgrade (see Downgrading).
| Field | Type | Description |
|---|---|---|
namerequired | string | max length 100 |
key | string | Stable machine key used by pushes and manifests (default: slug of name)pattern ^[a-z0-9][a-z0-9_.-]{0,62}$ |
description | string | max length 500 |
group | string | Group name or id; created if it does not existmax length 80 |
monitorIds | string[] | Status follows these monitorsmax 20 items |
aggregate | string | worstmajority |
push | boolean | Status is pushed via the component push URL |
pushTtlSec | integer | null | Become "unknown" when no push arrives for this long (null clears it)min 60 · max 604,800 · nullable |
manual | boolean | Switch the component to manual status (set via overrides/incidents) |
manifest | object | Status read from a manifest monitor key |
manifest.monitorIdrequired | string | |
manifest.keyrequired | string | max length 120 |
showUptime | boolean | |
showResponseTimes | boolean | Public response-time chart (monitor-backed components only; page setting showResponseTimes must be on) |
hidden | boolean | |
order | integer | min 0 · max 10,000 |
confirmations | integer | Push/manifest: consecutive fresh bad reports before a worse status shows (default 2; 1 = immediate)min 1 · max 10 |
key is the component's stable machine name. Pushes, manifests and subscriber filters use it, it is unique per page, and it defaults to the slugified name (“Search API” → search-api). group takes a group name or id. On update, "" removes the component from its group.
Sources
A component gets its status from exactly one source. If you send several, monitorIds wins over manifest, which wins over push. Sending none creates a manual component.
# Follows two monitors (e.g. two regions)
curl -X POST https://upbutler.com/api/v1/pages/acme/components \
-H "Authorization: Bearer $UPBUTLER_API_KEY" -H "Content-Type: application/json" \
-d '{"name": "Search API", "group": "APIs", "monitorIds": ["mon_0n3q8kz1m4hx7c2v9rt", "mon_0n3q8kz9z8y7x6w5v4u"]}'
# Fed by pushes; "unknown" if silent for 10 minutes
curl -X POST https://upbutler.com/api/v1/pages/acme/components \
-H "Authorization: Bearer $UPBUTLER_API_KEY" -H "Content-Type: application/json" \
-d '{"name": "Data pipeline", "key": "pipeline", "push": true, "pushTtlSec": 600}'
# Read from a manifest monitor
curl -X POST https://upbutler.com/api/v1/pages/acme/components \
-H "Authorization: Bearer $UPBUTLER_API_KEY" -H "Content-Type: application/json" \
-d '{"name": "Reddit", "group": "Platforms", "manifest": {"monitorId": "mon_0n3q8kzm4n1f3s7t2qa", "key": "reddit"}}'| Source | Set with | Behavior |
|---|---|---|
monitor | monitorIds (up to 20), aggregate | Recomputed whenever a linked monitor changes state. Starts from the monitors' current state. |
push | push: true, pushTtlSec | You POST statuses to source.pushUrl (returned in the response). Starts as unknown. See Push & manifest. |
manifest | manifest: {monitorId, key} | Reads key from a manifest monitor on every check. Starts as unknown. |
manual | (default) | Starts operational. Change it with overrides or incidents. |
Combining several monitors
Paused and pending monitors are ignored. aggregate decides how the remaining monitors combine:
| Monitors | worst (default) | majority |
|---|---|---|
| Any down | major_outage | More than half down: major_outage. Otherwise: partial_outage |
| None down, any degraded | degraded | degraded |
| All up | operational | operational |
| All paused or pending | unknown | unknown |
Use majority for redundant setups, such as the same service checked from several endpoints or replicas, where one failing instance is a partial outage rather than a full one.
Overrides
An override pins a component's displayed status above whatever its source says. Incidents set overrides for the components they list, and maintenance sets maintenance for its window. Both clear them on resolution. You can also set one by hand:
curl -X POST https://upbutler.com/api/v1/components/cmp_0n3q8kz2p7wd4yx0s3a/override \
-H "Authorization: Bearer $UPBUTLER_API_KEY" -H "Content-Type: application/json" \
-d '{"status": "degraded", "reason": "Upstream provider throttling"}'
# Clear it
curl -X POST https://upbutler.com/api/v1/components/cmp_0n3q8kz2p7wd4yx0s3a/override \
-H "Authorization: Bearer $UPBUTLER_API_KEY" -H "Content-Type: application/json" \
-d '{"status": null}'API responses show both status (from the source) and effectiveStatus (what visitors see). For real outages, prefer an incident: it also notifies subscribers.
Custom domains
Custom domains are included from Starter: 1 on Starter, 5 on Pro, 20 on Business. They are still rolling out (see below); until then, every page on every plan is live at https://<slug>.upbutler.com. Free pages can't use one (402 plan_limit), and the error names the plan that can.
Serve the page from your own hostname:
PATCH /pages/acmewith{"customDomain": "status.acme.com"}.- Coming soon. Until bring-your-own domains roll out, every page is live at
https://<slug>.upbutler.com(and/s/<slug>). SettingcustomDomainreturns503 unavailablewith your page address in the hint. - The domain is marked verified on the first request that reaches us through it. From then on, page links, emails and webhook payloads use
https://status.acme.com.
A hostname can belong to only one page (409 conflict). Send "customDomain": null to remove it. After a downgrade, domains beyond the new plan's count are removed (the oldest pages keep theirs) and those pages stay online at their upbutler.com address.
Settings
autoIncidents: when a linked monitor goes down, or a push or manifest component reachesincidentMinStatus, a public incident opens on this page and resolves on recovery. Turn it off to keep automatic incidents internal: your team is still alerted.incidentMinStatus: the lowest component status that opens an automatic incident:degraded,partial_outage(default) ormajor_outage. It applies to push- and manifest-fed components. Withmajor_outage, a partial outage still colors the component but doesn't open an incident or notify subscribers. A monitor that goes down always opens one. Maintenance never does.aiPublicUpdates: automatic incidents get an AI-written title and customer-safe summary, and recovery posts get an AI summary. With it off, a neutral template is used. If an incident spans several pages, every page must have this on. See AI policy.subscriptions:email,webhook,slack(covers Slack and Discord) andrss. See Subscribers.uptimeDays(7–90) andshowUptimePercentcontrol the uptime bars.timezonesets the zone for server-rendered dates. Visitors' browsers re-render times in their own zone.language: the language of the public page and subscriber emails:en,de,fr,es,it,pt,nl,el,pl,tr,ja, orautoto follow each visitor's browser (Accept-Language).showResponseTimes: allows public response-time charts. Each component also opts in with its ownshowResponseTimes, which only works for monitor-backed components. See Response times.visibility: "private"hides the page from the public API and subscriptions.
AI policy
Two settings control what AI may write on your page. They apply to automatic incident titles and summaries, recovery summaries, polish: true rewrites and published postmortems:
aiGuidelines(up to 2,000 characters): rules the AI must follow for all public text, such as “Never mention internal infrastructure or third-party vendors. Refer to the API as ‘the Acme API’.”aiAvoidTerms(up to 50 terms, 2–60 characters each): words that must never appear publicly, such as internal codenames, vendor names orkubernetes. They're passed to the AI, and every AI result is also checked case-insensitively. If the text contains a term, it isn't used. Automatic incidents and recoveries fall back to the neutral template, a polished update falls back to your original text, and publishing a postmortem is rejected with the reason.
{
"settings": {
"aiGuidelines": "Be brief. Never name our cloud provider or internal services.",
"aiAvoidTerms": ["hetzner", "postgres", "project-falcon"]
}
}When an incident spans several pages, the guidelines of all pages are combined, and so are their avoided terms. The team-facing AI analysis isn't restricted by these settings.
Response times
With settings.showResponseTimes on and a monitor-backed component's showResponseTimes on, the page shows a response-time chart for that component. The same data is public JSON:
curl "https://upbutler.com/api/v1/public/pages/acme/components/search-api/response-times?range=7d"range is 24h (default, 30-minute buckets), 7d (3-hour buckets) or 30d (daily). 24h and 7d give p50 and p95 in ms from raw checks. 30d gives the daily average in p50, with p95 null (kind: "daily_average"). Components with several monitors average them. Components that haven't opted in return 404.
Public JSON API
Every public page has a JSON view that needs no auth and is safe to poll every 30 seconds:
curl https://upbutler.com/api/v1/public/pages/acme{
"page": { "id": "pg_0n3q8kz0b2fd81mka5e", "slug": "acme", "name": "Acme Status", "description": null, "url": "https://status.acme.com", "websiteUrl": "https://acme.com" },
"status": "degraded",
"statusText": "Degraded performance",
"groups": [
{
"id": "grp_0n3q8kz4a1b2c3d4e5f", "name": "APIs", "description": null, "collapsed": false, "status": "degraded",
"components": [
{
"id": "cmp_0n3q8kz2p7wd4yx0s3a", "key": "search-api", "name": "Search API", "description": null,
"group": "grp_0n3q8kz4a1b2c3d4e5f", "status": "degraded", "statusMessage": "Slow response: 2140ms (threshold 1500ms)",
"updatedAt": "2026-10-09T08:41:02.118Z", "uptime": 99.982,
"days": [{ "date": "2026-10-09", "status": "degraded", "uptime": 100, "downSec": 0, "degradedSec": 1260, "incidents": [] }]
}
]
}
],
"activeIncidents": [],
"maintenance": [],
"recentIncidents": [],
"subscribe": { "url": "https://upbutler.com/api/v1/public/pages/acme/subscribers", "types": ["email", "webhook", "slack", "rss"] },
"generatedAt": "2026-10-09T08:42:10.004Z"
}Pass ?history=false to drop the per-day bars. History lives at GET /public/pages/:slug/incidents (with limit and before) and GET /public/pages/:slug/incidents/:id. Only public updates are included, never internal notes or AI engineering analysis. To be notified instead of polling, subscribe.