# Status pages API

10 operations under /api/v1. Errors are RFC 9457 problem+json with a stable `code` field. The complete schemas are in https://nebulactrl.com/api/v1/openapi.json.

## GET /status-pages

List status pages

The workspace's public status pages with their components. Component service, environment and project names are empty for tokens restricted to other projects.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Response 200: OK (PageListOutputBody)
  - `items` (StatusPageView[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 500 Internal Server Error

## POST /status-pages

Create a status page

Creates an unpublished page. Add components with PUT /status-pages/{pageId}/components (or start from a project's production services with `fromProjectId`), point the hostname's DNS at the control-plane server (GET /status-pages/{pageId}/dns; with a connected Cloudflare zone NebulaCtrl manages the records itself, see GET /status-pages/{pageId}/cloudflare), then publish with PATCH. 409 `hostname_reserved` for the dashboard's own host, `hostname_conflict` for a hostname another page or a service domain uses, `slug_conflict` for a slug in use. Requires the admin scope.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.

Request body (application/json, required): CreatePageInputBody
  - `description` (string)
  - `footer` (string): Plain text shown under the public page; line breaks kept
  - `fromProjectId` (string): Start from a project: the page gets that project's production-environment services that have at least one public domain as components (display name = service name, no group, in name order). Editable afterwards. 422 unknown_project when the project is not in the workspace
  - `hostname` (string): The public DNS name the page is served on, e.g. status.example.com. Lowercased; unique; not the dashboard's host or a service domain
  - `showUptime` (boolean): Show availability percentages (default true)
  - `slug` (string): Unique in the workspace: lowercase letters, digits and hyphens. Default: derived from the title
  - `theme` (enum: auto | light | dark): Default auto: the visitor's preference
  - `title` (string, required): Shown as the page's heading

Response 201: Created (StatusPageView)
  - `components` (StatusPageComponentView[] | null, required): In page order
  - `createdAt` (string (date-time), required)
  - `description` (string, required)
  - `footer` (string, required): Plain text shown under the page
  - `hostname` (string | null, required): The public hostname; null until one is set
  - `id` (string, required)
  - `published` (boolean, required): Served on the hostname (needs a hostname and at least one component)
  - `showUptime` (boolean, required): Show availability percentages next to the day bars
  - `slug` (string, required)
  - `theme` (enum: auto | light | dark, required)
  - `title` (string, required)
  - `updatedAt` (string (date-time), required)
Errors: 401 Unauthorized; 403 Forbidden; 409 Conflict (`hostname_reserved`); 422 Unprocessable Entity; 500 Internal Server Error

## GET /status-pages/{pageId}

Get a status page

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `pageId` (path, string, required)

Response 200: OK (StatusPageView)
  - `components` (StatusPageComponentView[] | null, required): In page order
  - `createdAt` (string (date-time), required)
  - `description` (string, required)
  - `footer` (string, required): Plain text shown under the page
  - `hostname` (string | null, required): The public hostname; null until one is set
  - `id` (string, required)
  - `published` (boolean, required): Served on the hostname (needs a hostname and at least one component)
  - `showUptime` (boolean, required): Show availability percentages next to the day bars
  - `slug` (string, required)
  - `theme` (enum: auto | light | dark, required)
  - `title` (string, required)
  - `updatedAt` (string (date-time), required)
Errors: 401 Unauthorized; 403 Forbidden; 422 Unprocessable Entity; 500 Internal Server Error

## PATCH /status-pages/{pageId}

Update a status page

Omitted fields stay as they are. `published: true` needs a hostname and at least one component (422 `publish_needs_hostname`, `publish_needs_components`). Changing the hostname of a published page, or publishing or unpublishing it, re-sends the control-plane server's routes: the page is served (or withdrawn) within moments. Requires the admin scope.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `pageId` (path, string, required)

Request body (application/json, required): UpdatePageInputBody
  - `description` (string)
  - `footer` (string): Plain text shown under the public page; line breaks kept
  - `hostname` (string): Empty removes the hostname (the page must not be published)
  - `published` (boolean): true serves the page on its hostname; needs a hostname and at least one component
  - `showUptime` (boolean)
  - `slug` (string)
  - `theme` (enum: auto | light | dark)
  - `title` (string)

Response 200: OK (StatusPageView)
  - `components` (StatusPageComponentView[] | null, required): In page order
  - `createdAt` (string (date-time), required)
  - `description` (string, required)
  - `footer` (string, required): Plain text shown under the page
  - `hostname` (string | null, required): The public hostname; null until one is set
  - `id` (string, required)
  - `published` (boolean, required): Served on the hostname (needs a hostname and at least one component)
  - `showUptime` (boolean, required): Show availability percentages next to the day bars
  - `slug` (string, required)
  - `theme` (enum: auto | light | dark, required)
  - `title` (string, required)
  - `updatedAt` (string (date-time), required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity (`publish_needs_hostname`); 500 Internal Server Error

## DELETE /status-pages/{pageId}

Delete a status page

Deletes the page and withdraws its route. Incidents and maintenance windows posted to it stay. Requires the admin scope.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `pageId` (path, string, required)

Response 204: No Content
Errors: 401 Unauthorized; 403 Forbidden; 422 Unprocessable Entity; 500 Internal Server Error

## GET /status-pages/{pageId}/cloudflare

Live Cloudflare DNS status of a status page's hostname

Reads the records of the hostname from Cloudflare and classifies them like `GET /domains/{domainId}/dns` does: `managed`, `present`, `wrong_target`, `missing` or `not_in_zone`. The wanted records are the control-plane server's public addresses (`desiredRecords`). `originLock` says whether the page is locked to Cloudflare. Changes nothing. 422 `no_hostname` while the page has no hostname.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `pageId` (path, string, required)

Response 200: OK (DNSStatus)
  - `checkedAt` (string (date-time) | null, required)
  - `configured` (boolean, required): The team has a Cloudflare connection
  - `conflict` (string, required): Why adopting is impossible (for example a CNAME next to an A record)
  - `desired` (RecordView, required)
  - `desiredRecords` (RecordView[] | null, required)
  - `effectiveProxied` (boolean, required)
  - `error` (string, required): The last failed sync, empty when the last sync worked
  - `manage` (boolean, required): NebulaCtrl keeps this hostname's records in sync
  - `message` (string, required)
  - `needsAdopt` (boolean, required)
  - `originLock` (OriginLockView, required)
  - `proxied` (boolean, required): The proxy setting of the hostname (orange cloud)
  - `records` (RecordView[] | null, required): A, AAAA and CNAME records of the hostname
  - `sslMode` (enum:  | off | flexible | full | strict, required): The zone's SSL/TLS mode; empty when unknown
  - `status` (enum:  | managed | present | wrong_target | missing | not_in_zone, required)
  - `tunnel` (boolean, required): The server is behind a Cloudflare Tunnel: the record is a proxied CNAME to it (never for a status page)
  - `warnings` (string[] | null, required)
  - `zoneId` (string, required)
  - `zoneName` (string, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity (`no_hostname`); 500 Internal Server Error; 502 Bad Gateway

## PUT /status-pages/{pageId}/cloudflare

Change the Cloudflare settings of a status page's hostname

Sets the proxy toggle, whether NebulaCtrl manages the page's records and the origin lock opt-in, then syncs. A proxied, published page is locked to Cloudflare. A failed sync is reported as `error` in the status and retried in the background. Audited as `status_page.dns.update`. Requires the admin scope.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `pageId` (path, string, required)

Request body (application/json, required): PageDNSSettingsInputBody
  - `manage` (boolean): Let NebulaCtrl keep the page's DNS records in sync
  - `originLock` (boolean): Lock the control-plane server's route of the page to Cloudflare while it is proxied (default true). False opts out: anyone who finds the server's IP can bypass Cloudflare. Audited.
  - `proxied` (boolean): Proxy through Cloudflare (orange cloud). A proxied page is locked to Cloudflare while it is published.

Response 200: OK (DNSStatus)
  - `checkedAt` (string (date-time) | null, required)
  - `configured` (boolean, required): The team has a Cloudflare connection
  - `conflict` (string, required): Why adopting is impossible (for example a CNAME next to an A record)
  - `desired` (RecordView, required)
  - `desiredRecords` (RecordView[] | null, required)
  - `effectiveProxied` (boolean, required)
  - `error` (string, required): The last failed sync, empty when the last sync worked
  - `manage` (boolean, required): NebulaCtrl keeps this hostname's records in sync
  - `message` (string, required)
  - `needsAdopt` (boolean, required)
  - `originLock` (OriginLockView, required)
  - `proxied` (boolean, required): The proxy setting of the hostname (orange cloud)
  - `records` (RecordView[] | null, required): A, AAAA and CNAME records of the hostname
  - `sslMode` (enum:  | off | flexible | full | strict, required): The zone's SSL/TLS mode; empty when unknown
  - `status` (enum:  | managed | present | wrong_target | missing | not_in_zone, required)
  - `tunnel` (boolean, required): The server is behind a Cloudflare Tunnel: the record is a proxied CNAME to it (never for a status page)
  - `warnings` (string[] | null, required)
  - `zoneId` (string, required)
  - `zoneName` (string, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## POST /status-pages/{pageId}/cloudflare/fix

Fix a status page's DNS records

Creates or corrects the records so the hostname points at the control-plane server. A record NebulaCtrl did not create is never modified unless `adopt` is true (409 `adoption_required`; 409 `dns_conflict` when adopting is impossible). 422 `not_configured` / `not_in_zone` / `no_hostname`; 502 when Cloudflare fails. Requires the admin scope.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `pageId` (path, string, required)

Request body (application/json, required): PageDNSFixInputBody
  - `adopt` (boolean): Take over the existing record NebulaCtrl did not create: it is marked as managed and pointed at the control-plane server

Response 200: OK (DNSStatus)
  - `checkedAt` (string (date-time) | null, required)
  - `configured` (boolean, required): The team has a Cloudflare connection
  - `conflict` (string, required): Why adopting is impossible (for example a CNAME next to an A record)
  - `desired` (RecordView, required)
  - `desiredRecords` (RecordView[] | null, required)
  - `effectiveProxied` (boolean, required)
  - `error` (string, required): The last failed sync, empty when the last sync worked
  - `manage` (boolean, required): NebulaCtrl keeps this hostname's records in sync
  - `message` (string, required)
  - `needsAdopt` (boolean, required)
  - `originLock` (OriginLockView, required)
  - `proxied` (boolean, required): The proxy setting of the hostname (orange cloud)
  - `records` (RecordView[] | null, required): A, AAAA and CNAME records of the hostname
  - `sslMode` (enum:  | off | flexible | full | strict, required): The zone's SSL/TLS mode; empty when unknown
  - `status` (enum:  | managed | present | wrong_target | missing | not_in_zone, required)
  - `tunnel` (boolean, required): The server is behind a Cloudflare Tunnel: the record is a proxied CNAME to it (never for a status page)
  - `warnings` (string[] | null, required)
  - `zoneId` (string, required)
  - `zoneName` (string, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict (`adoption_required`, `dns_conflict`); 422 Unprocessable Entity (`not_configured`); 500 Internal Server Error; 502 Bad Gateway

## PUT /status-pages/{pageId}/components

Set the components of a status page

Replaces the page's components with the ordered list: the services it shows, each under a public display name (default: the service's name, at most 60 characters) and an optional group. Every service must belong to the workspace (422 `unknown_service`). A published page needs at least one component. Requires the admin scope.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `pageId` (path, string, required)

Request body (application/json, required): SetComponentsInputBody
  - `components` (ComponentBody[] | null, required): The full ordered list: it replaces the page's components

Response 200: OK (StatusPageView)
  - `components` (StatusPageComponentView[] | null, required): In page order
  - `createdAt` (string (date-time), required)
  - `description` (string, required)
  - `footer` (string, required): Plain text shown under the page
  - `hostname` (string | null, required): The public hostname; null until one is set
  - `id` (string, required)
  - `published` (boolean, required): Served on the hostname (needs a hostname and at least one component)
  - `showUptime` (boolean, required): Show availability percentages next to the day bars
  - `slug` (string, required)
  - `theme` (enum: auto | light | dark, required)
  - `title` (string, required)
  - `updatedAt` (string (date-time), required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity (`unknown_service`); 500 Internal Server Error

## GET /status-pages/{pageId}/dns

Check the DNS of a status page's hostname

The A and AAAA records the hostname needs (the control-plane server's public addresses) and whether the hostname resolves to them right now. A page whose Cloudflare record is proxied is `ok` when the hostname resolves to Cloudflare's addresses (`proxied: true`), not `mismatch`. 422 `no_hostname` while the page has no hostname.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `pageId` (path, string, required)

Response 200: OK (StatusPageDNSCheck)
  - `hostname` (string, required)
  - `message` (string, required)
  - `proxied` (boolean, required): The page's Cloudflare record is proxied and the hostname resolves to Cloudflare's addresses: status is ok then
  - `records` (StatusPageDNSRecord[] | null, required): The records to create: the control-plane server's public addresses
  - `resolved` (string[] | null, required): What the hostname resolves to right now
  - `status` (enum: ok | mismatch | missing | unknown, required)
Errors: 401 Unauthorized; 403 Forbidden; 422 Unprocessable Entity (`no_hostname`); 500 Internal Server Error
