Repository navigation
Status Pages
Public status pages show visitors the live state of a set of monitors, grouped the way you choose, plus
incidents you post. Every organization can publish any number of pages; each page has a globally unique
slug and lives at /status/<slug> (or at the root of a custom domain).
To publish one: Status pages → New page, give it a title and slug, add groups and drag monitors into them on the Groups & monitors tab, then flip Published in the header. Visitors see each monitor's current status, its last 50 heartbeats and 24h/30d uptime (unless hidden), static components whose status you set through incidents, the incidents you post, running and upcoming maintenance windows, the last days of incidents, status badges, a history page with a permalink per incident and maintenance window and subscriptions by email, SMS, webhook or Slack. Members and above can edit pages; viewers can see drafts but not change them.
Three org-scoped collections (src/collections/StatusPages.ts, src/collections/Incidents.ts,
src/collections/StatusPageViewers.ts):
status-pages field |
Notes |
|---|---|
organization |
Owning organization (required). |
slug |
Globally unique, lower-cased; reserved words from src/lib/reserved-slugs.ts. |
title, description, logo (media), footerText
|
Shown on the page; description and footer accept the Markdown subset below. |
logoDark, favicon (media) |
Dark-mode logo and favicon (Themes); uploaded through the builder. |
homepageUrl |
Where the logo (or the title, without a logo) links to; http(s) only. |
contactUrl |
"Contact us" button in the header; http(s) URL or mailto: address. |
theme |
auto (visitor picks system/light/dark), or a forced light / dark. |
themePreset, themeOverrides
|
Built-in palette id and validated colour overrides (Themes). |
bannerText |
Optional headline (≤ 140 characters) that replaces the overall-status text. |
defaultLocale, additionalLocales[]
|
Page languages (en, fr, de, es); see Languages. |
timeZoneMode, timeZone
|
visitor (default) or a fixed page zone; IANA timeZone, else the org's. |
published |
Only published pages are served; drafts 404 for visitors. |
access, password (write-only) |
public, password, email-domain or ip-allowlist; see Access. |
allowedEmailDomains[].domain |
Domains admitted by email-domain access (lower-cased, without @). |
allowedIpRanges[] { cidr, label } |
IPv4/IPv6 CIDR ranges admitted by ip-allowlist access. |
searchEngineIndex |
Emits robots: index, follow instead of noindex. |
showTags, showCertificateExpiry, showPoweredBy
|
Display toggles. |
showValues |
Default true. Off hides uptime % and response times page-wide (HTML + JSON). |
uptimeBarDays |
Daily uptime bars per component: 30, 45 or 90 (default). |
uptimeCalculation |
checks (default), duration or manual; see Daily uptime bars. |
showUptimePercentage |
Default true. Off keeps the bars but hides every uptime percentage. |
autoRefreshInterval |
Seconds between client refreshes of the public API; 0 disables. |
maintenanceVisibilityHours |
Hours a completed or cancelled maintenance window stays on the page (24). |
pastIncidentsDays |
Days of past incidents listed by day on the page (7, max 90, 0 hides them). |
customCSS |
Injected into the public page as a <style> tag. |
googleAnalyticsId |
G-… measurement id; the gtag snippet is only emitted when set. |
domains[].hostname |
Custom hostnames (see below). Unique across all pages. |
groups[] |
name, defaultOpen + monitors[] (components, see below), in order. |
incidents field |
Notes |
|---|---|
statusPage, organization
|
The organization is derived from the page in a beforeChange hook. |
title |
Shown on the card. |
publicId |
8 base36 characters of the permalink; assigned on create, never by clients. |
updates[] |
The timeline: status, message (Markdown), postedAt, editedAt, components[] { component, impact }. |
status |
Derived: status of the latest update (investigating, identified, monitoring, resolved). |
impact |
Derived: worst current component impact; set directly for incidents that name no component. |
affectedComponents[] |
Current { component, impact } per component named by any update (derived; editing it posts an update). |
pinned |
Pinned incidents render above the monitor groups; resolving unpins. |
active, resolvedAt
|
Derived from the timeline. Setting active: false posts a resolved update (and true reopens). |
content, style
|
Legacy (pre-timeline). Still accepted on create and turned into the first update. |
An incident is a list of updates. Each update has a status — investigating → identified → monitoring → resolved — a Markdown message, the time it was posted and, optionally, the impact it declares on page components (rows of the page's groups, by component id — see Components):
| Impact | Shown as | Counts for the page's overall status as |
|---|---|---|
operational |
Operational | the component's own status |
degraded_performance |
Degraded performance | degraded (→ degraded performance) |
partial_outage |
Partial outage | not fully up (→ partially degraded) |
major_outage |
Major outage | down |
- Components left out of an update keep their last impact. A
resolvedupdate resets them all to operational; posting another update afterwards reopens the incident. - The incident's indicator is the worst current impact. An incident that names no component carries a
declared impact (for page-wide notices);
degraded_performancemakes the page "degraded performance",partial_outage"partially degraded" andmajor_outage"major outage". - Posted updates are history. Their text can be corrected later — the update is then shown as edited — but status, time and impacts stay as posted. Updates may be back-dated, not post-dated.
- Only components of the incident's page can be affected, each once per update. Components removed from the page later stay in the history and are ignored publicly.
-
affectedComponentsis the incident's current state, kept in sync with the timeline. Clients that write it directly (the Payload admin, API clients of the component model) post an update with the changed impacts and the current status; components they drop go back to operational. - Incidents created before the timeline existed migrate without data loss: they read as one update with
their old text and
affectedComponents, posted when they were created (or when they were resolved), and theirstylemaps to an impact (info/primary→ operational,warning→ degraded performance,danger→ major outage). The update is stored on the incident's next write; no migration job is needed on either database. - Every new update is announced through
onIncidentUpdatePosted()(src/server/status-pages/incident-events.ts), the hook subscriber notifications build on.
Templates (templates collection, Settings → Templates, /{orgSlug}/settings/templates) hold
pre-approved wording for common situations ("Database failover", "Degraded API latency", "Planned network
maintenance"). They belong to the organization; everyone reads them (template:read, viewer) and members
and above write them (template:create/update/delete), so viewers can't edit them. Each template has:
| Field | Meaning |
|---|---|
name |
Unique within the organization |
kind |
incident (new incident dialog), incident-update (incident update composer), maintenance (maintenance form), maintenance-update (maintenance update composer) |
title |
Incident or maintenance title (not used by the update kinds) |
body |
Markdown message / maintenance description with {{ placeholders }}
|
status |
Status the incident or update starts with (empty: investigating / the suggested next step) |
impact |
Declared impact when the template names no component |
statusPage |
Page the default components belong to; empty = offered on every page, without components |
components |
Default affected components ({ component, impact }, row ids of statusPage) |
duration |
Maintenance: default window length in minutes |
Use template in the incident dialog, the update composer (Incidents tab of the builder), the new maintenance form and the maintenance update composer (a window's message on the maintenance edit page) pre-fills the fields in one click: title, message, status, affected components with their impact (components the page no longer has are skipped; the composer merges them into the current impacts), the declared impact, and for maintenance the end of a single window (start + duration) or a cron window's duration. The page's own templates and the organization-wide ones are offered.
Placeholders are {{ name }} with a plain identifier, rendered by a tiny, safe renderer
(src/lib/placeholders.ts: no expressions, no code). Unlike notification templates, which are Liquid
(Notifications → Templates), unknown placeholders stay in the text for the
author to fill in. These variables are filled in when the template is applied:
| Variable | Kinds | Value |
|---|---|---|
organization |
all | Organization name |
page |
incident, incident-update | Status page title |
components (component) |
incident, incident-update | Names of the affected components, as a list |
incident |
incident-update | Title of the incident being updated |
maintenance |
maintenance-update | Title of the maintenance |
date |
all | Today's date |
start, end, duration
|
maintenance | The window, when the form already has it |
start, end
|
maintenance-update | The occurrence's window |
Any other placeholder ({{ eta }}, {{ workaround }}) or a variable without a value stays in the text. It is
highlighted, listed with a field to fill it in, and publishing is blocked until it is replaced: the
buttons are disabled, and the incident routes (POST …/incidents, PATCH …/incidents/:incidentId,
POST …/updates, PATCH …/updates/:updateId) and the maintenance routes (POST/PATCH /api/orgs/:orgId/maintenance[/:id], POST …/occurrences/:occurrenceId/updates) answer 400 while a title,
description or message still contains one.
Placeholders inside Markdown code (`{{ x }}` or fenced blocks) are ignored. Templates travel with the
Marmot export.
Every row of groups[].monitors[] is a component (src/lib/status-page-components.ts). The array
keeps its original name, so pages created before components existed migrate unchanged (every row becomes
a monitor component with values shown, every group starts expanded).
| Component field | Notes |
|---|---|
id |
Payload array row id (a string on every database). The stable component id incidents point at. |
type |
monitor (default) or static. |
monitor |
Required for monitor components; always cleared on static ones. |
name |
Public display name. Required for static. For monitors it overrides the monitor's name. |
description |
Shown to visitors as a tooltip next to the name. |
showValues |
Default true. Off hides this component's uptime and response times (needs the page toggle on). |
showChart |
Default false. Publishes latency charts for a monitor component. |
sendUrl, customUrl
|
Link shown to visitors (the monitor URL, or a custom link). |
-
Display name: component
name→ the monitor'spublicName(Monitor form → Public name) → the monitor'sname. The internal monitor name never reaches the public page, its JSON or the RSS feed when a public name is set. -
Impact: incident updates set the impact of the components they affect (
operational,degraded_performance,partial_outageormajor_outage; see Incident timeline). While an incident is active, each affected component reports the worst impact of all active incidents (impactin the JSON, a label on the page), and the page's overall status counts it. -
Degraded monitors (a successful check slower than the monitor's
degradedAfter, see Monitors → Degraded) keepstatus: "degraded"in the JSON and read asdegraded_performanceeverywhere else: the "Degraded performance" label on the row, the overall status and the badge (effectiveImpact()insrc/lib/status-page-components.ts). Incident impacts stay inimpact; a worse incident impact wins. -
Static component status comes only from incidents and maintenance: a non-operational impact maps to
degraded(degraded performance),pending(partial outage) ordown(major outage); otherwise a running maintenance window attached to the page showsmaintenance; otherwiseup. Monitor components keep their monitor's status and show the impact next to it. -
Groups render as collapsible sections;
defaultOpen: falsestarts them closed. The header shows the worst status of the group's components (down>pending>degraded>maintenance>up>unknown). -
Values: when
showValuesis off (page or component), the public JSON omitsuptime24h/uptime30dand the beats carry noping; the page renders neither.
Removing a component from a page leaves incident references dangling; they are ignored on the public page and shown as "Removed component" in the builder. The builder sends row ids back on save so components keep their ids when groups are reordered or edited.
status-page-viewers field |
Notes |
|---|---|
page, organization
|
The email-domain page the visitor signed in to, and its organization. |
email |
The verified address (lower-cased); unique per page. |
status |
active or revoked (signed out, no new links to that address). |
lastSeenAt |
Last visit (refreshed at most every five minutes). |
Access (src/access/permissions.ts): status-page:read is granted to every role, status-page:create
/update/delete to members and above. Reads of status-pages are special-cased: anonymous requests
and members of other organizations only see published: true documents with public access;
members see their drafts and protected pages too. Viewers are created only by the sign-in link
(server side); status-page:read lists them and status-page:update revokes, restores and deletes them.
Incidents are never readable anonymously — the public API serves them. A beforeChange hook refuses
monitors that belong to another organization and hostnames already claimed by another page.
Each component row shows one bar per UTC day for the last uptimeBarDays days (30, 45 or 90), oldest
left (#112). A bar takes the day's worst state: down (the monitor was DOWN, or a major outage),
partial (a partial outage), degraded (degraded checks or degraded performance), maintenance, up,
or nodata when nothing was recorded. Hovering, focusing or tapping a bar shows the day's uptime,
downtime and the incidents and maintenance windows of that day, linked to their
permalinks; the arrow keys move between days. Under the bars the row shows the
uptime over the whole window (unless showUptimePercentage is off).
The page's uptimeCalculation decides every percentage (src/lib/uptime-history.ts):
| Mode | Uptime |
|---|---|
checks |
Successful / all checks of the day, as in the statistics (degraded and maintenance beats count as up, pending ones as down). |
duration |
Time not in a DOWN state / time whose state is known, replayed from the important heartbeats (status transitions). |
manual |
From incident impacts only: a major outage counts as down, a partial outage as half down, degraded performance as up. |
Static components have no checks and always use manual; their history starts on the day the page was
created. Overlapping incidents are not added up: at each instant the worst impact counts. A window's
figure sums the days that have data.
Preserved history. Bars read stat-daily and the important heartbeats while retention keeps them
(KEEP_DATA_PERIOD_DAYS). The status-page-uptime-snapshot job (daily at 00:20 UTC on the maintenance
queue) copies every complete day of each monitor component into status-page-uptime-history (one row per
page, component and UTC day: check counts, degraded and maintenance beats, seconds DOWN and seconds with a
known state, the day's state). It looks back 35 days, and over the whole retained history (400 days) the
first time it sees a page; existing rows are never rewritten. Retention never deletes these rows, so
shortening KEEP_DATA_PERIOD_DAYS no longer rewrites a page's history: days whose statistics are gone are
read from the snapshot. The rows go with their page.
Monthly history. Admins (status-page:history) get a History tab in the builder: the uptime of
each component per month (3 to 24 months, newest first) in the page's mode, with an Export CSV button
(month,component_id,component,uptime_percent,downtime_seconds,days) for SLA reports.
Some teams want to show response-time performance publicly, not just up and down (#113). Turn on
Public latency chart for a monitor component in Groups & monitors (showChart, off by default
because it exposes performance data). Visitors then get a Response time link under the component's
bars that opens /status/<slug>/monitors/<componentId>; /status/<slug>/monitors shows every
published component (on a custom domain: /monitors and /monitors/<componentId>). Each shows, over
24 hours, 7 days or 30 days (?range=1d|7d|30d):
- the uptime (unless
showUptimePercentageis off) and the p50, p95 and p99 response time of the period; - a response time chart with p50, p95 and p99 lines per interval (5 minutes for 24 hours, 1 hour for 7 days, 4 hours for 30 days);
- a checks chart with the successful, degraded and failed checks per interval;
- for monitors checked from several locations (#92), a table of each location's uptime and average response time; the local workers read as "Primary".
Charts need the component's values to be public: with showValues off on the page or the component
there is no chart, whatever showChart says. Static components and third-party status page monitors
have none, and paused monitors are hidden as on the page. Everything is read from the statistics rollups
(getRangeStats(), stat-location-hourly), never from raw heartbeats, and the JSON
(GET /api/status-pages/:slug/charts, src/server/status-pages/latency-charts.ts) carries aggregates
only: the component id and public name, no monitor id, URL, hostname, location id or check message.
Protected pages need access like the page itself. Responses are cached for 30 seconds, like the page's
JSON.
Beyond up/down and latency, a page can chart business figures such as an API error rate or a queue
backlog (#155). Define them under Settings → Metrics (or /api/orgs/:orgId/metrics, metric:*
permissions, members by default): a name, description, unit (suffix, e.g. % or ms), decimals,
how the values of one interval are combined (aggregation: average, sum, last or max), the
period it opens with (defaultView: hour, day, week or month), optional warning and critical
thresholds drawn as reference lines, and an order.
A metric gets its values in one of two ways (source):
-
push (default): a script sends points with an API key that has write scope:
curl -X POST https://status.example.com/api/orgs/<orgId>/metrics/<metricId>/points \ -H "Authorization: Bearer mk_…" -H "Content-Type: application/json" \ -d '{"points":[{"value":0.42},{"value":0.40,"timestamp":"2026-10-12T09:59:00Z"}]}'
{ "value": 0.42 }works for a single point. A request carries up to 1 000 points from the last 24 hours (timestampin unix seconds, milliseconds or ISO 8601; default now) and answers202 { "accepted": n }; a bad point rejects the whole batch with400. Pushes are rate limited per key and metric (METRIC_PUSH_RATE_LIMIT, 60 a minute) on top of the key's write budget (API_KEY_WRITE_RATE_LIMIT). -
monitor: the value a Prometheus query monitor records, read from its statistics.
Points are never stored one by one: each push is folded into minutely, hourly and daily rollups
(metric-stats) that follow the monitor statistics' retention (24 hours, 30 days, the data period). The
hour view draws 1-minute intervals, day 5 minutes, week 1 hour and month 4 hours.
A metric appears in the Metrics section of a page only when it is published (visible) and the
page is one of its statusPages; hidden metrics never reach visitors. Visitors switch the period per
metric; the data comes from GET /api/status-pages/:slug/metrics[?metric=<id>&view=hour|day|week|month],
which carries the display settings and aggregates only (no source monitor, push time or organization),
follows the page's access control and is cached for 30 seconds.
All of these are anonymous and return 404 for unknown or unpublished slugs. Protected pages also need
access (see Access): the page's access cookie or ?pw= (401 otherwise), or a client address
in the IP allow-list (403 otherwise).
| Route | Returns |
|---|---|
GET /status/:slug |
Server-rendered page (OpenGraph meta, manifest link, feed alternates). |
GET /api/status-pages/:slug/public |
JSON (below); Cache-Control: public, max-age=30. |
GET /status/:slug/rss |
RSS 2.0: one item per incident update and monitors currently down. |
GET /status/:slug/feed/atom |
Atom 1.0, the same items (Feeds and machine-readable output). |
GET /status/:slug/feed/json |
JSON Feed 1.1, the same items. |
GET /status/:slug/maintenance.ics |
iCalendar feed of recent and upcoming maintenance occurrences. |
GET /status/:slug/api/v2/summary.json (and others) |
Statuspage-compatible JSON. |
GET /status/:slug.md, <permalink>.md
|
The page, an incident or a maintenance window as Markdown. |
GET /status/:slug/llms.txt |
Describes the page and links every endpoint above. |
GET /status/:slug/api/openapi.json |
OpenAPI 3.1 description of the read-only endpoints. |
GET /status/:slug/manifest.json |
Web app manifest. |
GET /status/:slug/badge.svg |
Overall status badge (below). |
GET /status/:slug/events |
History page (below). |
GET /status/:slug/monitors[/:componentId] |
Latency charts of the published components (404 when there are none). |
GET /api/status-pages/:slug/charts |
Their data as JSON (?range=1d|7d|30d, ?component=); Cache-Control: public, max-age=30. |
GET /api/status-pages/:slug/metrics |
Custom metrics of the page (?metric=, ?view=hour|day|week|month). |
GET /status/:slug/events/incident/:id |
Permalink of an incident (:id is its publicId). |
GET /status/:slug/events/maintenance/:id |
Permalink of a maintenance window (occurrence publicId). |
GET /api/status-pages/:slug/events |
History as JSON (filters as on the page). |
GET /api/status-pages/:slug/events/:kind/:id |
One incident or maintenance window as JSON. |
GET /status/:slug/sitemap.xml |
Sitemap of indexable pages (404 otherwise). |
GET /status/:slug/robots.txt |
robots.txt (served at /robots.txt on custom domains). |
GET /api/status-pages/resolve-domain?host= |
{ slug } for a custom hostname (used by the proxy). |
GET /status/:slug/login |
Password form of a protected page. |
POST /api/status-pages/:slug/access |
Checks the page password and sets the access cookie. |
{
"config": {
"slug": "acme",
"title": "Acme Status",
"description": "…",
"logo": "/api/media/file/logo.png",
"logoDark": null, // dark-mode logo
"favicon": null, // falls back to the logo
"homepageUrl": "https://example.com",
"contactUrl": "mailto:support@example.com",
"theme": "auto",
"themePreset": "default",
"bannerText": null, // custom headline replacing the overall-status text
"published": true,
"showTags": false,
"showCertificateExpiry": false,
"showPoweredBy": true,
"showValues": true,
"uptimeBarDays": 90, // 30 | 45 | 90
"uptimeCalculation": "checks", // checks | duration | manual
"showUptimePercentage": true,
"autoRefreshInterval": 300,
"customCSS": null,
"footerText": null,
"googleAnalyticsId": null,
},
"overall": "up", // up | degraded | partial | down | maintenance | unknown (monitors and active incident impacts)
"groups": [
{
"name": "Core",
"defaultOpen": true,
"status": "pending", // worst status of the components
"monitors": [
{
"id": "12", // monitor id (component id for static components)
"componentId": "6702f1c4e1b2a3d4e5f60718",
"type": "monitor", // monitor | static
"name": "Website", // display name
"description": "Marketing site", // only when set
"url": "https://example.com", // url only when sendUrl/customUrl
"status": "up", // up | down | pending | maintenance | unknown
"impact": null, // worst active incident impact, or null
"showValues": true,
"showChart": false, // public latency charts at <page>/monitors/<componentId> (only with showValues)
"uptime24h": 0.9993, // only when showValues
"uptime30d": 0.9981, // only when showValues
"beats": [{ "status": "up", "time": "2026-10-05T03:00:00.000Z", "ping": 42 }], // oldest first, ≤ 50; ping only when showValues
"uptime": 0.9987, // over the bars' window, in the page's mode; only with showValues and showUptimePercentage
"days": [
// one per UTC day, oldest first (uptimeBarDays of them)
{
"date": "2026-10-04",
"state": "down", // nodata | up | maintenance | degraded | partial | down (worst of the day)
"uptime": 0.9, // only with showValues and showUptimePercentage; null without data
"downtimeSeconds": 21600, // only with showValues
"incidents": [{ "id": "k3x9a0b1", "title": "API errors", "impact": "major_outage" }], // id = permalink id
"maintenance": [], // [{ id, title }], id = permalink id
},
],
"tags": [{ "name": "env", "color": "#2563EB", "value": "prod" }], // only when showTags
},
{
"id": "6702f1c4e1b2a3d4e5f60719",
"componentId": "6702f1c4e1b2a3d4e5f60719",
"type": "static",
"name": "Customer support",
"status": "pending",
"impact": "partial_outage",
"showValues": true,
"showChart": false, // always false for static components
"beats": [], // always empty for static components
"days": [], // daily bars as above, from incidents only
},
],
},
],
"incidents": [
// active incidents, pinned first, newest first
{
"id": "3",
"publicId": "k3x9a0b1", // permalink: <page>/events/incident/k3x9a0b1
"title": "API errors",
"status": "identified", // investigating | identified | monitoring | resolved
"impact": "major_outage", // operational | degraded_performance | partial_outage | major_outage
"components": [{ "id": "6702f1c4e1b2a3d4e5f60718", "name": "API", "impact": "major_outage" }], // current, visible components only
"updates": [
// newest first
{
"id": "6703f0c1a2b3c4d5e6f70812",
"status": "identified",
"message": "A bad deploy; rolling back.",
"postedAt": "2026-10-05T03:10:00.000Z",
"editedAt": null,
"components": [], // impacts this update set
},
{
"id": "6703f0c1a2b3c4d5e6f70811",
"status": "investigating",
"message": "We are looking into errors.",
"postedAt": "2026-10-05T03:00:00.000Z",
"editedAt": null,
"components": [
{ "id": "6702f1c4e1b2a3d4e5f60718", "name": "API", "impact": "major_outage" },
],
},
],
"content": "A bad deploy; rolling back.", // latest message (pre-timeline clients)
"style": "danger", // card colour derived from impact (pre-timeline clients)
"pinned": true,
"active": true,
"startedAt": "2026-10-05T03:00:00.000Z", // first update
"createdAt": "…",
"updatedAt": "…",
"resolvedAt": null,
},
],
"maintenance": [
// one entry per occurrence: running first, then the next window of each maintenance starting
// within 7 days, then windows finished within maintenanceVisibilityHours (Maintenance.md)
{
"id": "41", // occurrence id
"publicId": "p0m4r7q2", // permalink: <page>/events/maintenance/p0m4r7q2
"maintenanceId": "7",
"title": "Database upgrade",
"description": "Expect a few minutes of read-only mode.",
"strategy": "single", // manual | single | recurring-interval | recurring-weekday | recurring-day-of-month | cron
"status": "under-maintenance", // under-maintenance | scheduled | completed | cancelled
"state": "verifying", // scheduled | in-progress | verifying | completed | cancelled
"start": "2026-10-06T02:00:00.000Z", // planned window
"end": "2026-10-06T03:00:00.000Z", // null when open-ended (manual)
"startedAt": "2026-10-06T02:00:00.000Z",
"completedAt": null,
"cancelledAt": null,
"timezone": "Europe/Berlin",
"updates": [
// newest first; an empty message means an automatic or button transition
{
"id": "…",
"status": "verifying",
"message": "Migration done, checking replicas.",
"postedAt": "…",
},
{ "id": "…", "status": "in-progress", "message": "", "postedAt": "…" },
],
},
],
"pastIncidentsDays": 7,
"pastIncidents": [
// one entry per day (organization time zone), today first; quiet days have no incidents
{
"date": "2026-10-05",
"start": "2026-10-04T22:00:00.000Z", // when that day starts
"incidents": [
{
"kind": "incident",
"publicId": "k3x9a0b1",
"title": "API errors",
"status": "identified",
"impact": "major_outage", // worst impact the incident had
"ongoing": true,
"start": "2026-10-05T03:00:00.000Z",
"end": null, // resolution time once resolved
"components": [{ "id": "6702f1c4e1b2a3d4e5f60718", "name": "API" }],
"latest": { "status": "identified", "message": "…", "postedAt": "…" },
},
],
},
],
"generatedAt": "2026-10-05T03:00:30.000Z",
}Internal ids (organization, media rows, domains) never appear in the payload. Paused or deleted monitors
are dropped from the groups. The page is rendered on the server from the same data and then, when
autoRefreshInterval > 0, the client component re-fetches the JSON on that interval (polling only; the
realtime socket is not used on public pages).
Every page publishes the same information in formats for feed readers, calendars, dashboards and LLM
agents. All of them hang off the page URL (/status/<slug>, or the root of a custom domain) and the page
<head> advertises the RSS, Atom, JSON Feed, iCalendar and Markdown versions as
<link rel="alternate">.
| Path below the page | Format |
|---|---|
/rss, /feed/atom, /feed/json
|
RSS 2.0, Atom 1.0 (RFC 4287), JSON Feed 1.1 |
/maintenance.ics |
iCalendar (RFC 5545) |
/api/v2/summary.json |
Statuspage v2: page, components, unresolved incidents, upcoming maintenance, status |
/api/v2/status.json |
Statuspage v2: page and status indicator |
/api/v2/components.json |
Statuspage v2: page and components |
/api/v2/incidents.json |
Statuspage v2: the 50 most recent incidents |
/api/v2/scheduled-maintenances.json |
Statuspage v2: maintenance occurrences, unfinished and of the last 30 days |
.md (/status/<slug>.md), /index.md
|
Markdown: status, components, ongoing incidents, maintenance |
/events/<kind>/<publicId>.md |
Markdown: one incident or maintenance window and its whole timeline |
/llms.txt |
llms.txt: what the page is and links to everything above |
/api/openapi.json |
OpenAPI 3.1 description of these endpoints |
Feeds. RSS, Atom and JSON Feed render one shared model (src/server/status-pages/feed.ts): one item
per incident update, newest first, linked to the incident's permalink
(/status/<slug>/events/incident/<publicId>; statusPageLinks() / incidentPermalink() in
src/server/status-pages/urls.ts), plus one item per monitor that is currently
down (identified by the start of its down streak). Atom and JSON Feed ids are tag: URIs built from the
server host and the page creation date, so they are the same on the main host and a custom domain; an
edited update keeps its id and moves its updated / date_modified. The opening update carries the
incident title, later ones [Status] Title.
iCalendar. One VEVENT per maintenance occurrence (the persisted windows of
#154): every unfinished occurrence, plus those that started or finished in the last 30 days. UID is
maintenance-occurrence-<occurrence id>@<server host> and stays stable; SEQUENCE is the number of
seconds between the occurrence's creation and the last change of the occurrence or its maintenance, so it
grows with every edit and state change. Cancelled occurrences, and unfinished occurrences of a paused
maintenance, are sent as STATUS:CANCELLED so subscribed calendars drop them. A manual maintenance's
run has no planned end and appears once it is completed. Lines are folded at 75 octets;
X-PUBLISHED-TTL and REFRESH-INTERVAL ask clients to refresh hourly.
Statuspage-compatible JSON. Field names and enum values follow the public Atlassian Statuspage v2
API (src/server/status-pages/statuspage.ts), so existing clients and aggregators can point at
https://<host>/status/<slug> (or the custom domain) as if it were a Statuspage page:
-
Components: each page group becomes a
group: truecomponent whosecomponentslists its rows; every row becomes a component withgroup_id. Ids are the stable component ids.statusis the worse of the monitor state (down→major_outage,pending→degraded_performance,maintenance→under_maintenance) and the active incident impact (degraded_performance,partial_outage,major_outage). Static components follow their incidents and maintenance. -
Incidents:
statusis the incident status (investigating,identified,monitoring,resolved);impactis the peak impact over the timeline (operational→none,degraded_performance→minor,partial_outage→major,major_outage→critical), so a resolved incident keeps it. Every update becomes anincident_updatesentry (newest first,bodyin Markdown) withaffected_componentslistingold_status→new_status; a resolving update lists the components it returned tooperational.shortlinkis the incident's permalink. -
Scheduled maintenances: one per maintenance occurrence with
impact: "maintenance",statusscheduled,in_progress,verifyingorcompleted(the occurrence state),scheduled_for/scheduled_until(the planned window; no end for manual maintenance),started_at/resolved_at(when it actually started and completed),monitoring_at(firstverifyingupdate), and the components of the monitors it covers;shortlinkis the occurrence's permalink. The occurrence's update timeline becomesincident_updates(newest first; automatic transitions without text get the default message). Statuspage has no cancelled maintenance, so cancelled occurrences and unfinished ones of a paused maintenance are left out.summary.jsonlists what the page announces and is not finished (running occurrences and the next one of each maintenance within 7 days);scheduled-maintenances.jsonlists every unfinished occurrence and those of the last 30 days, newest first. -
Status:
indicatoris the worst of the active incidents' impacts and the components:minorfor degraded performance,majorfor a partial outage or some components down,criticalwhen every component is down. Thedescriptionis Statuspage's (All Systems Operational,Minor Service Outage,Partial System Outage,Major System Outage,Service Under Maintenancewhile maintenance runs and nothing is wrong), in the page's language. - Not mapped:
postmortemstatus, subscriptions and theunresolved/upcoming/activesub-resources.
Markdown and llms.txt. /status/<slug>.md is rewritten by src/proxy.ts to /status/<slug>/index.md
(also reachable directly), and a permalink plus .md (/status/<slug>/events/<kind>/<publicId>.md, or
/events/<kind>/<publicId>.md on a custom domain) to the internal route /status/<slug>/event-md/<kind>/<publicId>,
since the permalink pages own /events/<kind>/<id>. Text is rendered in the page language with times in the
organization's zone; incident and maintenance text is the author's Markdown, passed through.
These endpoints (and /rss) are a stability contract: fields are only added, never renamed or
removed; a breaking change ships under a new version prefix (/api/v3/…) while the old one keeps
working. The OpenAPI document at /api/openapi.json describes them (src/server/status-pages/openapi.ts).
-
Access: every endpoint runs
checkStatusPageAccess, so every access mode applies: password pages need the access cookie or?pw=<password>, email-domain pages the access cookie, and IP allow-list pages a client address in the list. Without access they answer401(403outside the IP allow-list,429while rate limited). -
Caching:
Cache-Control: public, max-age=N, stale-while-revalidate=NwhereNis the page's auto-refresh interval clamped to 30–300 seconds (300 when the page does not refresh);private, no-storeplusX-Robots-Tag: noindexfor protected pages.Vary: Accept-Language, Cookie(the access cookie decides protected pages; the language comes from the URL prefix). -
Validation: a weak
ETagof the body;If-None-Matchwith a matching tag returns304. Bodies contain no "generated at" timestamps, so the tag only changes when the data does. -
CORS:
Access-Control-Allow-Origin: *withETagexposed;OPTIONSanswers the preflight thatIf-None-Matchtriggers. Responses to visitors admitted by an IP allow-list carry no CORS header: the network position is shared by every site the visitor opens, so no other site's script may read them. -
Errors: RFC 9457
application/problem+jsonwithtype: about:blank, the HTTP status phrase astitle, a localiseddetailand acode(not-found,login-required,invalid-password,rate-limited,ip-not-allowed).
A page is written in its defaultLocale and can offer additionalLocales (#114). Marmot ships the public
page, its history and permalinks, the access and subscription pages, the feeds, the Markdown and
llms.txt outputs, the subscriber emails, SMS and chat messages in English, French, German and Spanish
(src/i18n/messages/<locale>.json, the keys in PUBLIC_MESSAGE_SCOPES). User content (titles, incident
updates, component names) is shown as written.
-
URLs: the default language has no prefix; the others live under their code:
/status/acme/fr,/status/acme/fr/events,/status/acme/fr/rss, and/fr/…on a custom domain. The proxy strips the prefix before routing, so every page path, feed, badge and machine endpoint is available in every offered language. A language the page does not offer falls back to the default. -
First visit: on a plain URL, a visitor whose browser prefers an offered language
(
Accept-Language, matched by base language) is redirected to it. The footer's language switcher links to the current view in every offered language and remembers the choice in themarmot-status-localecookie, which then wins overAccept-Language(choosing the default language stops the redirect). -
SEO: every HTML page links its
hreflangalternates (plusx-default), and its canonical URL and feed links stay in its language. -
Subscribers receive messages in the language they subscribed in (
localeof the subscription), and their manage, confirm and unsubscribe links open in it. -
Time zones: timestamps are formatted with
Intlin the page language. The server renders them in the page'stimeZone(else the organization's), so HTML and hydration match. WithtimeZoneMode: visitor(default) they switch to the visitor's browser zone after hydration; withpagethey stay in the page zone. The footer says which zone is in use and, when the visitor's zone differs, lets them switch between the two (remembered inlocalStorage). Day and month grouping of the history follows the page zone.
/{orgSlug}/status-pages lists the organization's pages; /{orgSlug}/status-pages/{id} edits one with
nine tabs (ten for admins): Settings (title, slug, description, languages and time zone, refresh,
custom CSS, analytics id, header links, display toggles, uptime bars and calculation mode, delete), Theme (colour mode, preset, colour
overrides, banner headline, logos and favicon, with a live preview; see Themes), Groups &
monitors (drag-and-drop groups and components with
dnd-kit, static components, per-component public name, description, values toggle, public latency chart toggle, "show URL" /
custom link, per-group "expanded by default"), Incidents (post, edit, pin, resolve, reopen, delete, affected
components and their impact, templates), Subscribers and Notifications (see Subscribers),
Domains, Access (public, password, email domain or IP allow-list, with the list of signed-in
visitors for email-domain pages; see below) and Share (the status badge with
Markdown and HTML snippets), plus History for admins (monthly uptime and its CSV export, see
Daily uptime bars). The header switch publishes/unpublishes.
Mutations go through route handlers under /api/orgs/:orgId/status-pages/**
(src/app/api/orgs/[orgId]/status-pages), which authenticate the Payload session and call the Local API
with overrideAccess: false, so the collections' access rules decide what each role may do:
| Method & path | Purpose |
|---|---|
GET/POST /api/orgs/:orgId/status-pages
|
List / create |
GET/PATCH/DELETE /api/orgs/:orgId/status-pages/:id
|
Read / partial update / delete (+ incidents) |
POST/DELETE …/:id/logo
|
Upload (multipart file) / remove logo |
POST/DELETE …/:id/logo-dark
|
Upload / remove the dark-mode logo |
POST/DELETE …/:id/favicon
|
Upload / remove the favicon |
GET/POST …/:id/incidents
|
List / open incident (first update) |
GET/PATCH/DELETE …/:id/incidents/:incidentId
|
Read / rename, pin, resolve / delete |
GET/POST …/incidents/:incidentId/updates
|
Timeline (oldest first) / post an update |
PATCH …/incidents/:incidentId/updates/:updateId |
Edit an update's text ({ message }) |
GET …/:id/viewers |
Visitors of an email-domain page |
PATCH/DELETE …/:id/viewers/:viewerId
|
Revoke or restore ({ status }) / forget |
GET …/:id/uptime-history?months=12[&format=csv] |
Monthly uptime per component (admins) |
Opening an incident: POST …/incidents with
{ title, pinned?, status?, message?, components?: [{ component, impact }], impact? } (status defaults to
investigating; impact is the declared impact when no component is named). Pre-timeline clients may
still send { title, content, style, affectedComponents }. Posting an update: POST …/updates with
{ status, message?, components?, postedAt?, impact? }; it returns 201 { doc, update }. Invalid statuses
or impacts, components that are not on the page and future postedAt values are refused with 400;
viewers get 403. The Payload REST API (/api/incidents) applies the same hooks.
Visitors can subscribe to a page's incident and maintenance announcements by email, SMS, webhook or Slack, for every component or only the ones they choose. Raw monitor alerts are never sent to subscribers: only what you publish on the page.
On the builder's Subscribers tab:
-
Show a Subscribe button: turns subscriptions on (
subscriptions.enabled). - Channels visitors can choose: any of email, SMS, webhook and Slack (default: email). Owners can add subscribers on every channel regardless.
-
Sending: Review before sending (default) or Send automatically (
subscriptions.deliveryModereview|auto). -
SMS sender: SMS go out through a Twilio notification channel of the organization
(
subscriptions.smsChannel; its account SID, auth token, sender number and messaging service are used, the recipient is the subscriber). Without one, SMS is not offered to visitors. Optional SMS templates per event (new incident, incident update, new maintenance, maintenance update) take{{ siteName }} {{ title }} {{ status }} {{ message }} {{ url }}; texts are shortened to Maximum SMS segments (default 2, GSM-7 or UCS-2 aware) and always keep the URL. - Emails go through the instance's SMTP settings (Configuration); without
SMTP_HOSTthey are only written to the log. No other configuration is needed.
| Event | event |
|---|---|
| An incident is opened | incident_opened |
| An incident update is posted (also reopening) | incident_updated |
| An incident is resolved | incident_resolved |
| A maintenance window attached to the page is announced | maintenance_scheduled |
| A maintenance reminder is due (Maintenance) | maintenance_reminder |
| A maintenance starts, gets an update, completes or is cancelled |
maintenance_started / _updated / _completed / _cancelled
|
Only published pages with subscriptions on announce anything, and only when at least one confirmed
subscriber would receive it. Every message links to the event's permalink
(statusPageEventUrl(), {{ url }} in SMS templates).
Component scoping. An incident concerns every component it ever named (any update's components and
the current affectedComponents); a maintenance concerns the page's components showing one of its
monitors. A subscriber who chose components receives an announcement when one of them is concerned, or
when the announcement names no component at all (page-wide); subscribers without a choice receive
everything. So a subscriber of component A gets nothing about component B.
Every announcement is a notification (subscriber-notifications): a snapshot of the title, status,
message, affected components and maintenance window taken when the event happened. In review mode it
waits as a draft (pending_review) on the Notifications tab, which shows the rendered email and SMS,
the number of recipients per channel (counted live) and a warning when SMS subscribers exist without an
SMS sender. Someone with subscriber:send (admins by default) sends or discards it. Nothing is
sent before that. In auto mode notifications skip the draft.
Sending fans out on the worker: one row per recipient in subscriber-deliveries (the delivery log:
channel, state queued / retrying / sent / failed / skipped, attempts, last error) and one job
per recipient. The notification ends sent, partially_failed or failed; Retry failed re-queues
the failed deliveries only. Delivery rows are kept 90 days.
The page header gets a Subscribe button (a dialog with the channel, the address and an optional
component choice). Sign-ups go to POST /api/status-pages/:slug/subscribe:
-
Email: double opt-in. The confirmation email links to
/status/:slug/confirm/:token, a page with a Confirm button (a form post, so mail scanners that prefetch links cannot confirm). Unconfirmed sign-ups receive nothing and are deleted after 72 hours. -
SMS: a six-digit code by SMS, entered in the dialog (
POST …/subscribe/verify); codes expire after 15 minutes and allow five tries. -
Webhook / Slack: active at once; the first request (
type: "test") carries the manage links and, for webhooks, the signing secret.
The response is always 202 { ok: true, next } (confirm-email, enter-code or done) whatever the
address's state: an address that is already subscribed gets a "manage your subscription" email instead of
an error, so the form cannot be used to find out who is subscribed. Sign-ups are rate limited per page and
client IP (10 per 10 minutes, then 15 minutes blocked), keyed on the trusted client address (the instance
setting trustProxy, never a raw X-Forwarded-For); without one, all visitors of a page share a wider
bucket. Each address receives at most three sign-up messages per hour.
On password-protected pages, signing up needs the page's access cookie like the rest of the page; the links in messages carry the token instead and work without it.
Every message carries two signed links: /status/:slug/manage/:token (choose components, unsubscribe)
and /status/:slug/unsubscribe/:token (a one-button page). Tokens are <subscriber id>.<HMAC> keyed by
PAYLOAD_SECRET; they cannot be guessed, and die with the subscription. Emails also carry
List-Unsubscribe: <…/api/status-pages/:slug/subscriptions/:token/unsubscribe> and
List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058), so mail clients unsubscribe in one click.
Unsubscribing deletes the subscriber and its delivery log.
SMS subscribers can reply STOP (also UNSUBSCRIBE, CANCEL, END, QUIT, STOPALL, OPTOUT,
REVOKE): point the Twilio number's incoming-message webhook at
<NEXT_PUBLIC_SERVER_URL>/api/status-pages/:slug/sms-inbound (requests are checked against
X-Twilio-Signature with the channel's auth token). Twilio also blocks numbers that replied STOP; when it
refuses a send for that reason (error 21610) Marmot removes the subscriber.
Webhook subscribers receive a POST with Content-Type: application/json, a 5-second timeout and no
redirects:
{
"version": "1",
"type": "incident", // incident | maintenance | test
"event": "incident_updated",
"id": "42", // notification id
"created_at": "2026-10-07T09:00:00.000Z",
"page": { "name": "Acme status", "url": "https://status.acme.com" },
"url": "https://marmot.example.com/status/acme/events/incident/k3x9q2ab", // permalink
"data": {
"incident": {
"id": "7",
"title": "API errors",
"status": "identified",
"components": [{ "id": "66f1…", "name": "API" }],
"update": { "id": "…", "status": "identified", "message": "Markdown", "posted_at": "…" },
},
// maintenance: { id, occurrence_id, title, state, message, starts_at, ends_at, reminder_minutes, components }
},
"subscription": { "manage_url": "…", "unsubscribe_url": "…" },
}Requests carry X-Marmot-Event, X-Marmot-Delivery (stable across retries) and
X-Marmot-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")> with the
subscriber's whsec_… secret (shown to owners in the builder and sent once in the test request). Reject
signatures older than five minutes. The signer lives in src/server/webhooks/signature.ts and is the one
organization-level outbound webhooks use too. Owners can add custom headers (for example an
Authorization token) to a webhook subscriber. Discord webhook URLs get a Discord embed instead (unsigned);
Slack subscribers must use an incoming-webhook URL (https://hooks.slack.com/services/…) and get Block
Kit messages.
Retries. Network errors, timeouts, 5xx and 429 are retried up to five attempts with jittered
exponential backoff (about 10 s, 20 s, 40 s, 80 s); other 4xx responses fail at once. Email (SMTP
4xx / connection errors) and SMS (Twilio 5xx / 429) follow the same policy.
The Subscribers tab lists subscribers (search, 50 per page, confirmed counts per channel), adds
subscribers without confirmation (source: added_by_owner, for example a customer's webhook), removes
them, and exports or imports CSV (channel,target,components,source,confirmed_at; imports need channel
and target, components are ;-separated component ids, and imported rows are confirmed with
source: import). Webhook secrets and headers are never exported.
| Permission | Default | Allows |
|---|---|---|
subscriber:read |
member | See subscribers, notifications and the delivery log; export |
subscriber:manage |
member | Add, change, remove and import subscribers |
subscriber:send |
admin | Send or discard drafts, retry failed deliveries |
| Method & path | Purpose |
|---|---|
GET/POST …/:id/subscribers
|
List (?page=&q=&channel=) / add |
PATCH/DELETE …/:id/subscribers/:subscriberId
|
Change components or headers / remove |
GET …/:id/subscribers/export, POST …/:id/subscribers/import
|
CSV export / import (text/csv or multipart file) |
GET …/:id/notifications |
Notifications, newest first |
GET/POST …/:id/notifications/:notificationId
|
Preview, recipients, deliveries / { action: "send" | "discard" | "retry" }
|
Public routes (anonymous, never cached):
| Route | Purpose |
|---|---|
POST /api/status-pages/:slug/subscribe |
Sign up { channel, target, components? }
|
POST /api/status-pages/:slug/subscribe/verify |
SMS code { target, code }
|
GET/PATCH/DELETE /api/status-pages/:slug/subscriptions/:token
|
Read / change components / unsubscribe |
POST …/subscriptions/:token/confirm |
Email confirmation (form or JSON) |
POST …/subscriptions/:token/unsubscribe |
One-click unsubscribe (RFC 8058) and the form |
POST /api/status-pages/:slug/sms-inbound |
Twilio incoming SMS (STOP) |
The whole flow is described in Architecture.
Resolved incidents and finished maintenance windows leave the page but stay in its history:
-
The page lists the incidents of the last
pastIncidentsDaysdays (default 7; Settings → Past incidents shown) grouped by the day they started in the organization's time zone, with "No incidents reported" on quiet days, and links to View history. Incident and maintenance cards link to their permalink. Upcoming maintenance windows show their planned time in the visitor's time zone (the server renders the organization's zone; the browser switches after loading). -
/status/:slug/eventsis the history: every incident and maintenance window, newest first and grouped by month, 20 per page, with filters for the type (?type=incident|maintenance), a component (?component=<component id>) and a month (?month=YYYY-MM, organization time zone). The filters are a plainGETform, so the page works without JavaScript. Incidents are dated by creation, maintenance windows by their planned start. -
/status/:slug/events/incident/:idand/status/:slug/events/maintenance/:idare permalinks with the full update timeline, the status, start and end, the duration, the impact, the affected components and a Copy link button.:idis the event'spublicId: 8 random base36 characters assigned when the incident or maintenance occurrence is created, never the database id. Documents created before public ids existed get an id derived from their database id with an HMAC keyed byPAYLOAD_SECRET(src/server/status-pages/public-ids.ts) until their next write stores it, so their links are stable without a data migration (rotatingPAYLOAD_SECRETbefore that write changes them).
What is public (src/server/status-pages/events.ts): every incident of the page (incidents have no
draft state), and the occurrences of maintenances that list the page under Status pages: running,
completed and cancelled ones always, upcoming ones while the maintenance is active. Only running,
completed and cancelled windows are listed in the history; upcoming ones are on the page. Unannounced
windows that were dropped are deleted, so they never appear. Components are named only when they are
visible on the page; a maintenance window affects the components of its monitors. Unknown ids, other
pages' events and windows of paused maintenances answer 404.
Every surface goes through the page's access check: on a password-protected
page the HTML pages redirect to the login form, which returns to the history or permalink after signing
in (next, only paths below the page are accepted), and the JSON endpoints answer 401. The RSS feed
links each item to its incident's permalink. statusPageEventUrl() in src/server/status-pages/urls.ts
builds the absolute permalink for emails, and maintenance events (MaintenanceEvent.occurrence.publicId)
and incident events (incident.publicId) carry the id that subscriber notifications link to.
All public HTML pages of a status page share one decision (isIndexable in
src/server/status-pages/seo.ts): a page is indexed only when it is published, Search engine indexing
is on and it is not access-protected. Otherwise every page, the history and every permalink is
noindex, nofollow, the JSON endpoints send X-Robots-Tag: noindex, nofollow, sitemap.xml answers 404
and robots.txt disallows everything. Filtered or paginated history views are noindex, follow.
| Page | Title | Description |
|---|---|---|
| Status page | {title} |
The page description. |
| History | History · {siteName} |
Incidents and maintenance windows of {siteName}, {dateRange}. |
| Incident | {title} · {siteName} |
The latest update (≤ 200 characters), else Incident on {siteName}: …. |
| Maintenance | {title} · {siteName} |
The latest update, else Maintenance on {siteName}: {status}, {dateRange}.
|
The templates are messages under statusPages.seo in src/i18n/messages/en.json, so they follow the
page language. Every page has a canonical URL (on the custom domain when the request arrived on one),
Open Graph (website, or article with published and modified times for permalinks) and Twitter card
metadata with the logo as image, the RSS alternate and the favicon.
/status/:slug/sitemap.xml (/sitemap.xml on a custom domain) lists the page, the history and every
public permalink with its last modification. /status/:slug/robots.txt is meant for custom domains, where
it is served as /robots.txt and points crawlers at the sitemap.
GET /status/:slug/badge.svg is one SVG badge with the overall state of the page, for a README or a
website footer (per-monitor badges are in Integrations). The Share tab of the
editor builds the URL and copies Markdown or HTML snippets that link the badge to the page. On a custom
domain the same badge is served at https://status.example.com/badge.svg.
| State | Colour | When |
|---|---|---|
| All systems operational | #66c20a |
Every checked monitor is up. |
| Degraded performance | #eed202 |
A monitor is degraded, or an active incident marks a component as degraded performance. |
| Partial outage | #f8a306 |
Some monitors are down, or an incident marks a component as a partial outage. |
| Major outage | #c2290a |
All monitors are down, or an incident marks a component as a major outage. |
| Under maintenance | #1747f5 |
A monitor is in maintenance, or a maintenance window attached to the page is running. |
| Unknown | #999 |
No monitor has been checked yet, the page is not published, or the visitor may not see it. |
The state is computed from the same data as the page (the components' statuses, the incident impacts and
the running maintenance, see badgeInput() / statusPageBadgeState() in
src/server/status-pages/badge.ts); an active incident that names no component counts with its declared
impact. When several apply, the most severe wins
(major > partial > degraded > maintenance > operational > unknown). A static component with an impact
also changes its own status (amber or red), which counts towards overall exactly as on the page. Only the state is rendered: never
monitor names, uptime or response times, whatever the page's display settings.
| Parameter | Values | Default |
|---|---|---|
theme |
light, dark
|
light |
size |
sm, md, lg, xl
|
md |
variant |
default (filled), outline (status-coloured border) |
default |
style |
flat, flat-square, plastic, for-the-badge, social
|
none: Marmot's pill (dot + text) |
label |
any text (≤ 64 characters) | none for the pill; Status with style (as the monitor badges) |
theme, size and variant apply to the pill; with style the shields.io renderer of the monitor
badges draws a label | state badge in the colours above. Unknown parameter values fall back to the
defaults. Responses are image/svg+xml with Access-Control-Allow-Origin: * and
Cache-Control: public, max-age=<autoRefreshInterval> (at least 30 s; 300 s when auto-refresh is off).
Unknown or unpublished slugs answer 404 with an Unknown badge and no-store. Every request goes
through one access check (statusPageBadgeAccess → checkStatusPageAccess): a
protected page renders Unknown (200, private, no-store) unless the visitor has access
(access cookie, ?pw=, or a client address in the IP allow-list), and granted responses of protected
pages are private, no-store too.
The Share tab also offers two ways to show the status inside another site (#109), each with copy-to-clipboard snippets.
/status/<slug>?embed=all (or https://status.example.com/?embed=all on a custom domain) renders the
page without its header and footer. embed takes all or a comma-separated list of sections:
banner (overall status), maintenance, incidents (active incidents), components (the groups) and
history (past incidents); unknown names are ignored and a list without a known section shows them all.
&theme=light or &theme=dark overrides the page's theme. Every link of the embed opens in a new tab,
the page refreshes on its auto-refresh interval like the full page, and the embed is noindex.
Framing is opt-in per page with Allow embedding (allowEmbedding):
| Setting |
Content-Security-Policy of the page |
X-Frame-Options |
|---|---|---|
| Off (default) | frame-ancestors 'none' |
DENY |
On any website (any) |
frame-ancestors * |
none |
Only on listed (origins) |
frame-ancestors https://docs.acme.com https://*.acme.com … |
none |
Origins (embedOrigins, at most 50) are a scheme, a host (or *. plus a domain) and an optional port,
nothing else; the server normalizes them and rejects anything else, and origins needs at least one.
Only the page itself follows the setting: every other response under /status/ (login, subscription
management, history, feeds, …) is sent with frame-ancestors 'none' and X-Frame-Options: DENY, as is
everything outside /status/.
Only public pages can be framed. A draft or a page with any access mode but public always
answers frame-ancestors 'none', whatever the setting, and its embed mode answers 404, so the access
cookie (and the IP allow-list) is never used inside a frame. The headers are set by src/proxy.ts, which
looks the policy up through GET /api/status-pages/:slug/embed-policy (on custom domains through
resolve-domain); lookups are cached for 60 seconds, so switching embedding off takes up to a minute, and
a failed lookup refuses framing. The snippet's sandbox attribute keeps the frame from navigating the host
page; links still open in a new tab.
<script
async
src="https://marmot.example.com/status/acme/widget.js"
data-theme="light"
data-size="md"
></script>GET /status/:slug/widget.js (also /widget.js on a custom domain) is a dependency-free script (well
under 5 KB gzipped) that renders a status pill ("All Systems Operational", "Partial System Outage", …)
right after its <script> element, linking to the page. It reads the page's cached
status.json without credentials and re-reads it on the page's
auto-refresh interval (at least every 30 seconds, never when auto-refresh is off). data-theme is
light (default), dark or auto (the visitor's system setting), data-size sm, md (default) or
lg; the pill lives in a shadow root, so the host site's CSS does not affect it. The Share tab also has a
React component that appends the script to its own element. The widget does not need Allow embedding.
Only public pages have a widget: for a draft or a protected page widget.js answers 404 whatever
credentials the request carries, and status.json sends no CORS header to visitors admitted by an IP
allow-list, so an intranet status never reaches another site through a visitor's browser.
A page's Access tab switches it between Public (default), Password, Email domain,
IP allow-list and Audiences. Protection applies to published pages; signed-in members still preview their pages
from the builder. Whatever the mode, every public surface checks access through
checkStatusPageAccess (src/server/status-pages/access.ts), which dispatches to one strategy per mode
and denies modes it does not know: the HTML page, /api/status-pages/:slug/public, /rss and the
other machine-readable endpoints, manifest.json (linked with crossorigin="use-credentials"), badges of monitors that only appear on
protected pages, the page's own badge.svg (an Unknown badge without access), and all of these on
custom domains. Responses of protected pages carry
Cache-Control: private, no-store and X-Robots-Tag: noindex; protected pages are always noindex,
whatever searchEngineIndex says, and without access their metadata reveals only the title. Exports
never contain access settings: a protected page is exported as a draft, so importing it does not publish
it unprotected.
The session modes (password, email domain) set an HttpOnly, SameSite=Lax cookie named
marmot_sp_<page id> holding a signed token for that page; it lasts STATUS_PAGE_SESSION_DAYS (default 30) days. The token embeds a keyed fingerprint of the access mode (and of the password hash), so
switching modes or changing the password signs every visitor out. The cookie path is / because the
page, its API, feed and badges live under different paths; the name and the signed page id keep it to
one page.
- The password (8–256 characters) is written through the write-only
passwordfield and stored as an scrypt hash inpasswordHash, which field access hides from every API; only server code reading withoverrideAccesssees it. Switching back to Public deletes the hash. - Visitors without access are redirected from the page to
/status/<slug>/login(/loginon a custom domain). The form posts toPOST /api/status-pages/:slug/access(form fields or JSON{ "password": "…" }). A correct password sets the access cookie. - Feed readers and scripts can pass the password as
?pw=<password>to the JSON endpoint, the feeds, the calendar, the Statuspage JSON, Markdown,llms.txt,manifest.jsonand badge URLs. This puts the password in URLs, browser history, proxy and server logs; prefer the cookie (JSON login) where the client can keep one. - The HTML page only honours the cookie. Without access the machine endpoints answer
401: the JSON endpoint with{ "error", "code": "login-required" | "invalid-password" }, the feeds and other machine-readable endpoints with problem details carrying the samecode. - Password checks (form, JSON and
?pw=) are rate limited: 10 attempts per minute per page and client IP, then a 5-minute block. The client IP is only known behind a trusted proxy (instance settingtrustProxy); without one, all visitors of a page share a bucket of 30 attempts per minute. Blocked clients get429withRetry-After(the form shows a message) and cannot try the right password either. Already signed-in visitors are unaffected.
For internal pages: "anyone with an @acme.com address". List the domains on the Access tab (exact
match: acme.com does not admit eng.acme.com; list each subdomain). Sign-in uses one-time links sent
through the instance mailer (SMTP_*, see Configuration; without SMTP the mail is
only logged).
- The login screen (
/status/<slug>/login) asks for an email address and posts it toPOST /api/status-pages/:slug/access(form field or JSON{ "email": "…" }). The answer is always the same "if this address may view the page, a link is on its way" (202 { ok, sent }, or the form redirected to?sent=1), whether the domain is allowed, unknown or the visitor revoked: the lookup and the email happen in the background, so neither the response nor its timing reveals the allowed domains. - For an admitted address, Marmot mints a 256-bit random token and stores only its SHA-256 digest, with
the page and the address, in Redis for 15 minutes (
src/server/status-pages/magic-link.ts). The email (in the page's language,email.statusPageMagicLink.*) links to…/login?token=…on the host the visitor used, custom domains included, so the cookie lands where the page is served. - Opening the link shows a Continue button that posts the token back; mail scanners that prefetch
links cannot use it up. Redeeming is single use (the entry is read and deleted atomically), bound to
the page, and re-checks that the domain is still allowed. It records the visitor in
status-page-viewersand sets the access cookie, which names that row. Invalid, used or expired links send the visitor back to the form (?error=link-invalid, JSON400 link-invalid). - Every request re-checks the session: the viewer row must exist and be
active, and its domain must still be listed. Revoking a visitor on the Access tab signs them out at once and refuses new links to that address; removing them only forgets them (they may sign in again while their domain is allowed). Removing a domain signs out its visitors.
Link requests are rate limited before anything else happens, with the same limits for allowed and
unknown addresses: 3 per page and address per 15 minutes, and 10 per client IP per 15 minutes (behind a
trusted proxy; without one, 30 per page per 15 minutes shared by all visitors). Limited requests get
429 with Retry-After (the form shows a message). The feeds, JSON and badges of an email-domain page
only accept the cookie; there is no ?pw= equivalent.
For pages that should only be reachable from the office or the VPN. List IPv4 and IPv6 addresses or CIDR
ranges (203.0.113.0/24, 2001:db8::/32, optionally followed by a label); they are validated and
stored canonically. IPv4 clients seen as IPv4-mapped IPv6 (::ffff:203.0.113.7) match IPv4 ranges;
other embeddings (NAT64, 6to4) are not unwrapped. Every request is checked; there is no session and
nothing to sign in to. Requests from other addresses get a static "restricted" screen on the HTML page
and 403 { "code": "ip-not-allowed" } from the JSON endpoint, the feed and the manifest (badges answer
404, as for any monitor the visitor may not see). Responses are never cached publicly, because the
answer depends on the client address.
Reverse-proxy requirements. Next.js route handlers never see the TCP peer, so the client address
comes from X-Forwarded-For (first entry) or X-Real-IP, and only when the instance setting Trust
proxy headers (trustProxy) is on (Security). Turn it on
only behind a proxy that overwrites those headers (the bundled Caddy does; nginx needs
proxy_set_header X-Forwarded-For $remote_addr; rather than $proxy_add_x_forwarded_for when it is the
first hop). With trustProxy off, nobody is admitted (the Access tab warns), because a client could
otherwise claim any address. If a CDN or load balancer sits in front of the proxy, make sure the
address that reaches Marmot is the visitor's, not the CDN's.
For B2B products with per-customer or per-region infrastructure (#151): one page, several audiences, each seeing only its own components. An incident on the EU-2 cluster is written once and reaches only the customers on EU-2, on the page and in their notifications.
Setting it up. On the Audiences tab add an audience per customer or region: a name ("Acme
Corp", "EU customers"), the components it sees, and its members as addresses (ops@acme.com) and/or
email domains (acme.com, exact match as for email domain pages). Then set the
Access tab to Audiences. You can prepare audiences before switching; View as audience on the
same tab renders the page exactly as an audience sees it (drafts too), from
GET /api/orgs/:orgId/status-pages/:id/audiences/:audienceId/preview.
Signing in. Visitors use the same one-time email links as email domain pages (same
form, rate limits, single use, status-page-viewers rows you can revoke). A link is only sent to
addresses that some audience lists. On every request the viewer's address is matched against the page's
audiences again (audienceGrantFor), so editing or deleting an audience applies at once; a signed-in
visitor no audience lists any more is sent back to the sign-in form. A visitor in several audiences
sees the union of their components.
What a visitor sees.
- Components: only those of their audiences; groups left empty are hidden.
- Incidents: those naming (in any update, or in their current state) at least one visible component. An incident that names no component is page-wide and every audience sees it. Component lists, impacts and the overall status only count visible components, so an incident shared by two audiences says nothing about the other audience's components.
- Maintenance: windows covering a monitor of a visible component, and page-wide ones (no monitor).
- Nothing in the URL widens this: the scope comes only from the signed session, and filters such as
?component=ignore components the visitor cannot see. An event of another audience answers404.
This holds for every public surface: the HTML page, history and permalinks, /api/status-pages/:slug/public
and /events, the RSS/Atom/JSON feeds, maintenance.ics, the Statuspage-compatible JSON, Markdown
and llms.txt, the latency charts (page and JSON), the custom metrics
(page and JSON: a monitor-backed metric only for the audiences that see its monitor, a pushed metric for
every audience, like a page-wide incident), the page badge and monitor badges (a monitor of another
audience answers 404). Like
every protected page, an audience page has no embed mode, no widget, no sitemap and is never cached
publicly. The filtering is in src/server/status-pages/audiences.ts: findPublishedStatusPage returns
audience pages without groups until scopePageForVisitor narrows them to the visitor's
components, and the incident and maintenance loaders take the scoped page, so a code path that forgets
to scope shows nothing rather than everything.
Subscribers. A visitor who subscribes is bound to their audiences (from the session, never from the request) and can only pick, and later manage, their audiences' components. An announcement reaches a subscriber only when its components overlap the subscriber's audiences (page-wide announcements reach every audience), and the message names only components the subscriber's audiences see. Subscribers you add on the Subscribers tab need an audience; without one an audience page sends them nothing. Removing someone from an audience does not remove their subscriptions; remove those on the Subscribers tab.
SSO groups as members (with SSO) are not supported yet.
Pages are themed through a documented set of CSS variables instead of internal class names, so a brand
survives Marmot upgrades. Everything lives in src/lib/status-page-themes/ and is shared by the
collection (validation), the public page (CSS) and the editor (preview).
Colour mode (theme): auto shows visitors a system / light / dark switch in the page header; the
choice is remembered in localStorage (marmot:status-page-theme) and applied before paint, so the page
never flashes. light and dark force that mode and hide the switch.
Presets (themePreset) define every token for light and dark mode:
| Id | Notes |
|---|---|
default |
Marmot's own palette (styles.css). Emits no CSS at all. |
high-contrast |
Black/white surfaces; text ≥ 7:1, status colours ≥ 4.5:1 (WCAG AAA / AA). |
ocean |
Blues, violet maintenance colour. |
forest |
Greens on warm surfaces. |
graphite |
Neutral greys, tighter corners. |
Every preset except default keeps text and status colours at ≥ 4.5:1 against background and cards
in both modes; themes.test.ts enforces it. To add a preset, add a file under
src/lib/status-page-themes/presets/, register it in presets/index.ts and add its display name under
statusPages.theme.presets in src/i18n/messages/en.json. The id is stored as text, so no migration is
needed.
Overrides (themeOverrides) replace single tokens per mode on top of the preset:
{
"light": { "primary": "#0b5cad" },
"dark": { "primary": "#ff8800", "destructive": "oklch(0.7 0.19 25)" },
"radius": "0.5rem",
}| Token | CSS variables |
|---|---|
background |
--background |
foreground |
--foreground, --card-foreground, --popover-foreground, … |
card |
--card, --popover
|
primary, primaryForeground
|
--primary, --ring; --primary-foreground
|
muted, mutedForeground
|
--muted, --secondary, --accent; --muted-foreground
|
border |
--border, --input
|
success (up), warning (pending, degraded), info (maintenance) |
--status-up, --status-pending, --status-degraded, --status-maintenance (+ -text) |
destructive (down) |
--status-down, --status-down-text, --destructive
|
chart1 … chart5
|
--chart-1 … --chart-5
|
radius (both modes) |
--radius: 0, up to 2rem or up to 32px
|
Colours must be hex (#rgb, #rgba, #rrggbb, #rrggbbaa), rgb()/rgba(), hsl()/hsla() or
oklch() with plain numbers; named colours, var(), calc(), url() and anything else are rejected
with a 400 (the API, the builder and the Payload admin all go through the same field validation). The
public layout renders the result as html:not(.dark){…} and html.dark{…} rules containing only known
variables, re-validating every value, so overrides cannot inject CSS. The builder warns when text
contrast drops below 4.5:1 (or an overridden status colour below 3:1) but does not block saving.
customCSS stays available as an escape hatch.
Logos and favicon: logo is shown in light mode (and in dark mode when there is no logoDark);
logoDark replaces it in dark mode, swapped by CSS so it follows the visitor switch. The page title is
always shown as text. Logos accept PNG, JPEG, GIF, WebP, AVIF or SVG up to 2 MB; the favicon accepts
PNG, ICO or SVG up to 100 KB and falls back to the logo. Formats are detected from the file's bytes,
not the declared type. SVGs are rebuilt by an allowlist sanitiser (src/server/status-pages/svg.ts):
scripts, event handlers, foreignObject, external references, <style>, DOCTYPEs and entities are
removed or rejected. Media URLs point at Marmot's own host, so logos and favicon also load on custom
domains; the favicon and logo are listed in the web manifest. Uploads only go through these routes
(after the status-page:update check): the media collection is not writable through /api/media.
Banner headline (bannerText): replaces the automatic overall-status text (for example "Scheduled
upgrade tonight"). The banner colour still follows the monitors, and screen readers still hear the
real state.
A page can be served at the root of its own hostnames (domains[].hostname). src/proxy.ts (the
Next.js 16 proxy, formerly middleware) runs for the page's public paths only: /, /rss,
/manifest.json, /login, /badge.svg, /widget.js, /events, /events/incident/:id[.md],
/events/maintenance/:id[.md], /sitemap.xml, /robots.txt, /feed/atom, /feed/json,
/maintenance.ics, /api/v2/*.json, /api/openapi.json, /index.md and /llms.txt:
- It reads the visitor's host (
X-Forwarded-Host, thenHost) and ignores requests for Marmot's own hostname (NEXT_PUBLIC_SERVER_URL) orlocalhost. - It asks
GET /api/status-pages/resolve-domain?host=<host>on the same origin (the response is cached for 60 s) and, when a published page lists that host, rewrites the request to/status/<slug>followed by the same path (/maps to the page itself). Links on the page, the history and the permalinks stay on the custom domain. - Any failure (lookup error, invalid host, no match) falls through to normal routing, so the proxy can never take the main site down.
Setup for a domain:
- Create a
CNAME(orA) record forstatus.example.compointing at the Marmot host. - Add the hostname on the page's Domains tab and make sure the page is published.
- Let the reverse proxy obtain a certificate. With the bundled Caddy, enable on-demand TLS so any hostname that resolves to a published page gets a certificate on first request:
{
email "{$ACME_EMAIL}"
on_demand_tls {
# Caddy asks Marmot before issuing: only hostnames of published status pages are allowed.
ask http://web:3000/api/status-pages/resolve-domain
}
}
# Marmot itself (DOMAIN from .env).
{$SITE_ADDRESS::80} {
import marmot
}
# Any other hostname: certificates on demand, same upstreams.
https:// {
tls {
on_demand
}
import marmot
}
(marmot) {
encode zstd gzip
@realtime path /socket.io/*
handle @realtime {
reverse_proxy realtime:3001
}
handle {
reverse_proxy web:3000
}
}Caddy calls the ask endpoint with ?domain=<host>; the route accepts both domain= and host=
and answers 200 for a hostname of a published page and 404 for anything else, which is exactly what
on_demand_tls needs (so certificates are never issued for hostnames you did not configure).
With another proxy (nginx, Traefik, Cloudflare), terminate TLS there, forward the original Host (or set
X-Forwarded-Host) and point the hostname at the same upstream as the main site.
-
tests/int/status-page-feeds.int.spec.ts— Atom, JSON Feed and RSS items (well-formed XML, stable unique ids, permalinks), the iCalendar feed (folding, UIDs, cancelled windows), the Statuspage JSON checked against a Statuspagesummary.jsonfixture (tests/fixtures/statuspage/summary.json), Markdown,llms.txt, OpenAPI, ETag/304, CORS, problem details, password, email-domain and IP allow-list protection and custom-domain links.src/server/status-pages/machine-formats.test.tscovers line folding, cancelled maintenance events, the Statuspage status mapping and the proxy rewrites. -
tests/int/status-page-history.int.spec.ts— public ids (assigned, not spoofable, derived for older documents), the history (merge order, type, component and month filters, pagination, what is public), past incidents in the public payload, permalinks of incidents and maintenance windows, the JSON API, RSS links, password protection (401, return path after login, no open redirect), sitemap and robots on the main host and custom domains.src/lib/status-page-events.test.ts,src/server/status-pages/event-summary.test.ts(days and months across time zones and DST) andsrc/proxy.test.ts(custom domain paths) are the unit tests. -
tests/int/incident-timeline.int.spec.ts— the investigating → identified → monitoring → resolved flow through the REST routes, per-component impact in the public payload and overall status, RSS items per update, edited text, validation and roles, declared impacts, legacy migration and the update event. -
src/lib/incident-timeline.test.ts— the timeline rules (impact carry-over, resolve reset, ordering, style mapping). -
tests/int/status-page-components.int.spec.ts— static components driven by incident impact and maintenance, public names, collapsible group status,showValuesstripping the JSON, validation and the component helpers. -
tests/int/status-pages.int.spec.ts— access rules (anonymous / member / other organization), slug and hostname normalisation, cross-organization monitor refusal, incident derivation and resolution, the public payload shape, 404s,resolve-domain, RSS validity and escaping, manifest. -
tests/int/status-page-themes.int.spec.ts— presets and overrides through the builder API, rejection of invalid colours (CSS injection,var(), unknown tokens), viewer access, light/dark logo and favicon uploads (SVG sanitising, size and format caps, no orphaned media), public payload and manifest. -
src/lib/status-page-themes/themes.test.ts— the colour grammar, CSS generation and preset contrast;src/server/status-pages/svg.test.ts— the SVG sanitiser. -
tests/int/status-page-badge.int.spec.ts— badge route: each state from monitor statuses and running maintenance, headers, shields style and pill options, no figures in the SVG, 404s.src/server/status-pages/badge.test.ts(state precedence, incident impact),src/server/badges/status-page.test.ts(renderers) andsrc/lib/status-page-badge.test.ts(embed snippets) are the unit tests. -
src/lib/markdown.test.ts— the Markdown subset and its HTML escaping. -
tests/int/status-page-uptime.int.spec.ts— daily bars (30/45/90 per component, day state and incidents), each calculation mode on the same data, hiding the percentage, the snapshot job (idempotent, read once retention pruned the statistics), the monthly history JSON and CSV (admins only) and the cascade on page delete.src/lib/uptime-history.test.tscovers the maths of each mode. -
tests/e2e/status-pages.e2e.spec.ts— seeds an organization, monitor and published page through the Local API, visits/status/<slug>anonymously, checks title, group, monitor link, incident (including a timeline with a major outage until it is resolved) and the public API / RSS / manifest; unpublished slugs return 404. -
tests/int/status-page-access.int.spec.ts— password protection: hashing and field access, 401 on the JSON endpoint, RSS, manifest and badges without access, login (JSON and form, cross-site refusal),?pw=, cookies per page, custom domains, password change ending sessions, rate limiting, export.src/server/status-pages/access.test.tsandsrc/server/security/password-hash.test.tscover the token and hash primitives. -
tests/int/status-page-subscribers.int.spec.ts— email double opt-in, review mode (nothing sent before approval, preview and recipient counts, permissions), one-click unsubscribe, component scoping, signed and retried webhooks, Slack, SMS codes and templates, STOP (inbound and provider error), maintenance announcements and discarding, CSV import/export, password-protected pages, rate limiting and cascades.src/lib/status-page-subscribers.test.ts(targets, scoping, SMS segments) andsrc/server/webhooks/signature.test.tscover the helpers. -
tests/e2e/status-pages.e2e.spec.tsalso protects a page, checks the login redirect and form, and that the page and its feed open after signing in.
This wiki is published from docs/ on every push to main; edits made in the wiki are overwritten.
Found a mistake? Open a pull request with the docs(docs): … prefix.
Run it
- Getting started
- Configuration
- Deployment
- Kubernetes
- Single sign-on
- Security
- Telemetry
- Billing
- Demo mode
Use it
- Monitors
- Monitor types
- Probe locations
- Notifications
- Status pages
- Maintenance
- Organizations and members
- Integrations
- MCP server for AI agents
- Import and export
- CLI and monitors as code
- GitHub Action
Change it
{ "range": "1d", "components": [ { "componentId": "6702f1c4e1b2a3d4e5f60718", "name": "API", "description": "Public REST API", // only when set "status": "up", "range": "1d", "step": 300, // interval width in seconds "uptime": 0.9989, // only with showUptimePercentage; null without checks "avgPing": 48.2, "percentiles": { "p50": 41, "p95": 97, "p99": 180 }, // ms, null without data "checks": { "total": 1440, "up": 1438, "failed": 2, "degraded": 3 }, "series": [ // oldest first; empty intervals have zero counts and null pings { "timestamp": 1760227200, "up": 5, "degraded": 0, "down": 0, "ping": 44, "p50": 40, "p95": 61, "p99": 61, }, ], "locations": [{ "name": "Frankfurt", "uptime": 1, "avgPing": 31.5 }], // multi-location monitors only }, ], "generatedAt": "2026-10-12T09:00:00.000Z", }