Overview
What Brook does
Brook is a voice-AI uptime monitoring product. For each phone number you register, Brook places real outbound test calls on a schedule, records what the answering agent says, transcribes it, and compares the transcript against an expected greeting string. The outcome of each call feeds monitor health, incidents, alerts, and public status pages.
One check, mechanically:
scheduler picks a monitor whose next-due time has passed -> billing cap check (over the cap? the check is skipped entirely) -> outbound call placed to the monitor's phone number -> answering agent's speech is recorded, then transcribed -> transcript compared to the expected greeting, using greetingRule -> result recorded; monitor status and attached status pages update
What the v1 API covers (and what it does not)
The v1 API is the configuration surface. It manages monitors, status pages, groups, group membership, custom domains, and notification channels. It does not expose call records, call history, incident records, or live health status. There is no status field on a monitor in this API.
Resource model
Account (identified by the API key) | +-- NumberMonitor (independent; must exist before it can be placed on a page) | ^ | | referenced by numberMonitorId | | +-- StatusPage | | | +-- StatusPageGroup (1..n, ordered by sortOrder; a page needs >= 1) | | | | | +-- GroupNumber (1..n, ordered by sortOrder) | | \------> NumberMonitor | | | +-- CustomDomain (0..1, an attribute-set on the page) | | | \-- attached NotificationChannels (0..n, many-to-many) | ^ +-- NotificationChannel (independent; one channel may serve many pages)
Ordering constraints the model forces:
- Monitors first. Creating a status page requires at least one group, and every group requires at least one monitor id. You cannot create an empty page and populate it later.
- Domain before verification. The custom-domain sub-endpoints operate on a domain already stored on the page; set customDomain first.
- Verification before publication. A page carrying a custom domain will not go active until its customDomainStatus is verified.
- Group ids are not stable across a page-level write. A POST /status-pages with an id, or any PATCH /status-pages/{id}, deletes and recreates every group and membership on that page. Use the group sub-endpoints for incremental structure edits.
Getting started
Create an API key
- Sign in to the Brook dashboard. Email verification is mandatory before an account can do anything.
- Go to Settings → API keys.
- Create a key. Give it a name (keep it to 32 characters or fewer — see below) and pick a scope: read or write. The dialog defaults to write.
- Copy the key from the confirmation dialog. It is shown exactly once and cannot be retrieved later.
| Constraint | Value |
|---|---|
| Maximum keys per account | 10 (revoked keys are deleted and free a slot; disabled keys still occupy one) |
| Key prefix | brk_, followed by 64 random letters (68 characters total) |
| Scopes | read or write (write implies read) |
| Expiry | Keys do not expire |
Your first request
Every endpoint except GET /openapi requires a bearer key. Send it on the Authorization header:
curl -sS "https://{YOUR_BROOK_HOST}/api/v1/number-monitors?limit=2" \
-H "Authorization: Bearer $BROOK_API_KEY"Quickstart: monitor → status page → publish
The happy path, in dependency order:
1. Create a write-scoped API key in the dashboard.
2. POST /number-monitors -> monitor ids
3. POST /status-pages -> page id (groups declared inline)
4. POST /notification-channels -> channel id
5. PUT /notification-channels/{channelId}/status-pages/{pageId}
6. PATCH /status-pages/{pageId} -> set customDomain (status -> pending)
7. GET /status-pages/{pageId}/custom-domain -> read dnsRecords
8. -- publish the DNS records, wait for propagation --
9. POST /status-pages/{pageId}/custom-domain/validate -> until "verified"
10. PATCH /status-pages/{pageId} -> {"active": true}; check activated == true#!/usr/bin/env bash
set -euo pipefail
BASE="https://{YOUR_BROOK_HOST}/api/v1"
AUTH="Authorization: Bearer $BROOK_API_KEY"
JSON="Content-Type: application/json"
# 2. Create a monitor
MONITOR_ID=$(curl -sS -X POST "$BASE/number-monitors" -H "$AUTH" -H "$JSON" -d '{
"label": "Support line — US",
"country": "US",
"localNumber": "(201) 555-0123",
"greeting": "Thanks for calling Northwind Support",
"greetingRule": "contains",
"scheduleType": "preset",
"intervalSeconds": 3600,
"timeoutSeconds": 8,
"retryCount": 2,
"active": true
}' | jq -r '.data.id')
# 3. Create the status page with the monitor already grouped
PAGE_ID=$(curl -sS -X POST "$BASE/status-pages" -H "$AUTH" -H "$JSON" -d "{
\"title\": \"Northwind Voice Status\",
\"slug\": \"northwind-voice\",
\"groups\": [{ \"name\": \"Customer support\", \"numberIds\": [\"$MONITOR_ID\"] }]
}" | jq -r '.data.id')
# 10. Publish (no custom domain here, so it can activate immediately)
curl -sS -X PATCH "$BASE/status-pages/$PAGE_ID" -H "$AUTH" -H "$JSON" \
-d '{"active": true}' | jq '{activated, publicationBlockedReason}'{ "activated": true, "publicationBlockedReason": null }API fundamentals
Base URL and versioning
All paths are relative to the versioned base:
https://{YOUR_BROOK_HOST}/api/v1The API is served over HTTPS only. The version lives in the path. v1 is stable; breaking changes get a deprecation window. Additive changes (new endpoints, new optional request fields, new response fields) may ship without notice — clients must ignore unknown response fields rather than fail on them.
Authentication
Authorization: Bearer brk_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX- The scheme keyword is case-insensitive (Bearer, bearer) and must be followed by whitespace and a non-empty, whitespace-free key.
- x-api-key does not authenticate an /api/v1 request. Only Authorization: Bearer is accepted on this surface.
- Session cookies never authenticate this API. A browser logged into the dashboard cannot call /api/v1 on that basis.
- Keys are stored hashed and shown in full only once. Rotation means: create a new key, deploy it, then revoke the old one.
- A revoked, disabled, deleted, expired, or unknown key all return the same 401 unauthorized / "Invalid API key". The API does not disclose which condition failed.
Scopes (read vs write)
A key is created read or write. read permits every GET. write permits every POST, PATCH, PUT, DELETE and implies read. Because every key carries at least read, a scope failure cannot happen on a GET.
Rate limits and Retry-After
100 requests per key, released by a 60-second idle gap — not by a fixed or rolling window. The counter is per key, not per account, so spreading traffic across keys multiplies throughput. The counter resets to zero only when a request arrives more than 60 seconds after the previous successful verification on that key. A client that keeps a key continuously busy exhausts its 100 and stays exhausted until it goes quiet for a full 60 seconds.
Quota is consumed during key verification, which runs before scope checks and resource lookup — a request that ends in 403 or 404 still consumes quota.
HTTP/1.1 429 Too Many Requests
Retry-After: 37
Content-Type: application/json
{"error":{"code":"rate_limited","message":"Rate limit exceeded"}}Errors
Error envelope
{
"error": {
"code": "bad_request",
"message": "Invalid request body",
"details": { "label": "Enter a friendly name." }
}
}| Field | Notes |
|---|---|
| error.code | Machine-readable. Branch on this, never on message. |
| error.message | Human-readable English. May change without notice. |
| error.details | Present only when code is bad_request. Omitted entirely otherwise. |
details takes one of three shapes, all under bad_request: a field-error map {"<fieldName>": "<message>"} (on the two reorder endpoints the body is an array, so keys are stringified indices, e.g. {"0": "..."}); an envelope error such as {"body": "must be an object"} or {"cursor": "invalid"}; or a sub-code {"code": "<sub_code>"}.
Error code reference (top-level codes)
A 403 is not always code: "forbidden". Five reachable codes share HTTP 403. Always branch on error.code.
| code | HTTP | When | Fix |
|---|---|---|---|
| unauthorized | 401 | Missing/malformed Authorization, or an unknown/revoked/disabled key. | Send Authorization: Bearer brk_...; mint a new key if revoked; check for a proxy stripping the header or a 308 redirect. |
| forbidden | 403 | The key's scope is insufficient ("Missing required scope: write"). | Use a write-scoped key for any write method. |
| not_found | 404 | Object does not exist, is soft-deleted, or belongs to another account. Cross-account access is always 404, never 403. (Message text is not stable — do not quote it.) | Verify the id and that it belongs to this account. |
| rate_limited | 429 | The key spent its 100 requests and has not been idle 60s. | Sleep Retry-After seconds and retry. |
| bad_request | 400 | Malformed JSON, wrong body type, invalid query param, or field validation. Inspect details. | Fix per details. |
| internal_error | 500 | Unexpected server-side failure; reachable through several ordinary-looking requests (see the 500 triggers noted on the endpoints). Message text is not stable. | Retry once; if it persists, check the known 500 triggers. |
| monitor_limit_reached | 403 | Creating a monitor would exceed the plan's cap. Counts all non-deleted monitors, active or not. Raised on create only. | Delete a monitor or upgrade. |
| status_page_limit_reached | 403 | Creating a page over the active-page cap — even with active: false. On PATCH the same condition is not a 403; it surfaces as publicationBlockedReason. | Delete a page or upgrade. |
| external_notifications_not_entitled | 403 | Non-empty notificationChannelIds on a page write, on a plan without external notifications. | Upgrade, or omit notificationChannelIds. |
| custom_domain_not_entitled | 403 | A custom-domain POST/DELETE/validate on a plan without custom domains. Message wording differs by endpoint — do not string-match it. | Upgrade. To remove a domain after a downgrade, use PATCH /status-pages/{id} with "customDomain": "". |
Sub-codes in details.code
| details.code | When | Fix |
|---|---|---|
| no_domain_configured | A custom-domain POST or validate when the page has no customDomain. | PATCH /status-pages/{id} with a customDomain first. |
| invalid_number_membership | A numberIds/numberMonitorId value is unknown, soft-deleted, or owned by another account. The monitor need not be active. | Confirm each monitor id via GET /number-monitors/{id}. |
| invalid_channel_membership | A notificationChannelIds value is unknown, soft-deleted, or disabled. | Enable the channel or drop the id. |
| duplicate_membership | Adding a monitor to a group that already contains it. | Treat as already-done, or add it to a different group. |
publicationBlockedReason values
Returned in the body of a successful 200/201 from POST/PATCH /status-pages, never as an HTTP error:
| Value | Meaning |
|---|---|
| status_page_limit_reached | The plan's active-status-page allowance is exhausted. |
| slug_conflict | Another active, non-deleted page — belonging to any account — already holds this slug. |
| custom_domain_not_entitled | The plan does not include custom domains. |
| custom_domain_not_verified | The page has a custom domain whose status is not verified. On a create this is always the case. |
| custom_domain_conflict | Another active, non-deleted page already holds this custom domain. |
Pagination
Only three endpoints paginate: GET /number-monitors, GET /status-pages, and GET /notification-channels. All group and group-number lists return the full set.
| Parameter | Default | Behaviour |
|---|---|---|
| limit | 25 | Positive integer. Values above 100 are silently clamped to 100 (still 200). Below 1, non-integer, or empty → 400 with {"limit": "must be a positive integer"}. |
| cursor | none | Opaque token taken verbatim from a previous nextCursor. Empty is treated as absent; undecodable → 400 with {"cursor": "invalid"}. |
{ "data": [ /* ... */ ], "nextCursor": "MTc1MzcwMDAwMDAwMDpjbHg4azJtNG4wMDAxcXczZjd6OWExYjJj" }Ordering is newest-first by creation time descending, id descending as a tiebreak. nextCursor is null exactly when there are no further pages — loop until it is null. Treat the cursor as opaque; do not construct one yourself.
Request conventions
- Bodies are JSON. Content-Type is not validated, but send application/json. Unknown fields are silently ignored.
- Most endpoints need a JSON object body. The two reorder endpoints need a top-level JSON array. The wrong container returns 400 with {"body": "must be an object"} or {"body": "must be an array"}.
Number monitors
List monitors
/number-monitorsList every non-deleted monitor on the account, newest first. Paginated (limit, cursor). Responses: 200, 400, 401, 429, 500.
curl -sS "https://{YOUR_BROOK_HOST}/api/v1/number-monitors?limit=2" \
-H "Authorization: Bearer $BROOK_API_KEY"{
"data": [
{
"id": "clx8k2m4n0001qw3f7z9a1b2c",
"label": "Support line — US",
"country": "US",
"countryCallingCode": "1",
"localNumber": "2015550123",
"phoneNumber": "+12015550123",
"greeting": "Thanks for calling Northwind Support",
"greetingRule": "contains",
"scheduleType": "preset",
"intervalSeconds": 3600,
"cronExpression": null,
"timeoutSeconds": 8,
"retryCount": 2,
"active": true,
"projectedMonthlyPings": 744,
"averageResponseLatencyMs": 1830,
"createdAt": "2026-07-14T09:12:44.512Z",
"updatedAt": "2026-07-28T17:03:09.221Z"
}
],
"nextCursor": "MTc1MTQ1MTIwMzAwODpjbHg4azJtNG4wMDAwcXczZjV5MXg4dzd2"
}Create a monitor
/number-monitorsCreate a monitor. A body id is discarded — this endpoint can never update. Responses: 201, 400, 401, 403 (forbidden, monitor_limit_reached), 429, 500.
| Field | Req. | Constraints |
|---|---|---|
| label | yes | Trimmed, 1–80 chars |
| country | yes | Uppercase ISO 3166-1 alpha-2 ("US", not "us") |
| localNumber | yes | National ("(201) 555-0123") or E.164; must be valid for country |
| greeting | yes | Trimmed, 1–500 chars |
| greetingRule | yes | "exactly" | "contains" |
| scheduleType | yes | "preset" | "cron". Not "interval". |
| intervalSeconds | yes | Required even for cron. Hard floor 300. Preset: one of 300, 600, 1800, 3600, 21600, 43200, 86400 and ≥ plan minimum. For cron the sent value is discarded but must still clear 300. |
| cronExpression | cron only | UTC. 5-field, 6-field, and @hourly/@daily aliases accepted. Rejected if two occurrences are closer than the plan minimum or it yields > 20,000 occurrences per period. |
| timeoutSeconds | yes | Integer 5–10 |
| retryCount | yes | Integer 1–5 |
| active | no | See warning. Default false. |
curl -sS -X POST "https://{YOUR_BROOK_HOST}/api/v1/number-monitors" \
-H "Authorization: Bearer $BROOK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"label": "Support line — US",
"country": "US",
"localNumber": "(201) 555-0123",
"greeting": "Thanks for calling Northwind Support",
"greetingRule": "contains",
"scheduleType": "preset",
"intervalSeconds": 3600,
"timeoutSeconds": 8,
"retryCount": 2,
"active": true
}'{
"data": {
"id": "clx8k2m4n0001qw3f7z9a1b2c",
"label": "Support line — US",
"country": "US",
"countryCallingCode": "1",
"localNumber": "2015550123",
"phoneNumber": "+12015550123",
"greeting": "Thanks for calling Northwind Support",
"greetingRule": "contains",
"scheduleType": "preset",
"intervalSeconds": 3600,
"cronExpression": null,
"timeoutSeconds": 8,
"retryCount": 2,
"active": true,
"projectedMonthlyPings": 744,
"averageResponseLatencyMs": null,
"createdAt": "2026-07-14T09:12:44.512Z",
"updatedAt": "2026-07-14T09:12:44.512Z"
}
}Normalisation: localNumber comes back as digits only, phoneNumber is E.164, and countryCallingCode is the calling code without +. The three numeric fields also accept numeric strings ("3600"), but send numbers.
Cron: send scheduleType: "cron", a cronExpression, and an intervalSeconds of at least 300 (its value is discarded). The stored intervalSeconds is derived: max(plan minimum, shortest gap between consecutive occurrences this period).
{
"error": {
"code": "monitor_limit_reached",
"message": "Plan limit of 1 monitored number reached."
}
}Get a monitor
/number-monitors/{monitorId}Responses: 200, 401, 404, 429, 500. No 400, no 403.
curl -sS "https://{YOUR_BROOK_HOST}/api/v1/number-monitors/clx8k2m4n0001qw3f7z9a1b2c" \
-H "Authorization: Bearer $BROOK_API_KEY"{
"data": {
"id": "clx8k2m4n0001qw3f7z9a1b2c",
"label": "Support line — US",
"country": "US",
"countryCallingCode": "1",
"localNumber": "2015550123",
"phoneNumber": "+12015550123",
"greeting": "Thanks for calling Northwind Support",
"greetingRule": "contains",
"scheduleType": "preset",
"intervalSeconds": 3600,
"cronExpression": null,
"timeoutSeconds": 8,
"retryCount": 2,
"active": true,
"projectedMonthlyPings": 744,
"averageResponseLatencyMs": 1830,
"createdAt": "2026-07-14T09:12:44.512Z",
"updatedAt": "2026-07-28T17:03:09.221Z"
}
}Update a monitor
/number-monitors/{monitorId}Merge the fields you send into the current monitor, then re-validate the whole object. Same field set as create, all optional. Responses: 200, 400, 401, 403, 404, 429, 500. Never returns monitor_limit_reached.
curl -sS -X PATCH "https://{YOUR_BROOK_HOST}/api/v1/number-monitors/clx8k2m4n0001qw3f7z9a1b2c" \
-H "Authorization: Bearer $BROOK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"greeting": "Thanks for calling Northwind Customer Support", "retryCount": 3}'{
"data": {
"id": "clx8k2m4n0001qw3f7z9a1b2c",
"label": "Support line — US",
"country": "US",
"countryCallingCode": "1",
"localNumber": "2015550123",
"phoneNumber": "+12015550123",
"greeting": "Thanks for calling Northwind Customer Support",
"greetingRule": "contains",
"scheduleType": "preset",
"intervalSeconds": 3600,
"cronExpression": null,
"timeoutSeconds": 8,
"retryCount": 3,
"active": true,
"projectedMonthlyPings": 744,
"averageResponseLatencyMs": 1830,
"createdAt": "2026-07-14T09:12:44.512Z",
"updatedAt": "2026-07-30T08:21:55.104Z"
}
}Delete a monitor
/number-monitors/{monitorId}Soft-delete: the monitor stops being called, listed, and read. It is not removed from status-page groups it belongs to — a later page-level write that re-validates those groups then fails with invalid_number_membership, so remove it from its groups first. Not idempotent (a second DELETE → 404). Responses: 204, 401, 403, 404, 429, 500.
curl -sS -i -X DELETE "https://{YOUR_BROOK_HOST}/api/v1/number-monitors/clx8k2m4n0001qw3f7z9a1b2c" \
-H "Authorization: Bearer $BROOK_API_KEY"HTTP/1.1 204 No ContentStatus pages
List status pages
/status-pagesPaginated. Returns page attributes only — not embedded groups or channels. Responses: 200, 400, 401, 429, 500.
curl -sS "https://{YOUR_BROOK_HOST}/api/v1/status-pages?limit=1" \
-H "Authorization: Bearer $BROOK_API_KEY"{
"data": [
{
"id": "clx8k5p1q0002qw3f2h8d4e6f",
"title": "Northwind Voice Status",
"description": "Live health for our customer support phone lines.",
"slug": "northwind-voice",
"customDomain": "status.northwind.example",
"customDomainStatus": "pending",
"customDomainMessage": null,
"brandColor": "#0f8f7c",
"active": false,
"activeSlug": null,
"activeCustomDomain": null,
"createdAt": "2026-07-15T10:02:18.900Z",
"updatedAt": "2026-07-29T13:44:51.377Z"
}
],
"nextCursor": null
}Create a status page
/status-pages| Field | Req. | Constraints |
|---|---|---|
| title | yes | Trimmed, 1–90 chars |
| description | no | ≤280 chars. Empty string stores null. |
| slug | cond. | Required unless customDomain is supplied. Normalised: lowercased, non-[a-z0-9-] runs → -, hyphens trimmed/collapsed. The stored value is often not what you sent. |
| customDomain | no | Normalised: lowercased, scheme + path stripped, one trailing . stripped. Empty string clears it. |
| brandColor | no | Six-digit hex only, ^#[0-9a-fA-F]{6}$. Empty string stores null. |
| active | no | Requests publication. Default false. |
| groups | yes | ≥ 1 element. Each: name (1–80) and numberIds (≥ 1 owned, non-deleted monitor id). Do not repeat an id within one group (→ 500). |
| notificationChannelIds | no | Owned, non-deleted, enabled channel ids. A non-empty value needs a plan with external notifications. |
curl -sS -X POST "https://{YOUR_BROOK_HOST}/api/v1/status-pages" \
-H "Authorization: Bearer $BROOK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Northwind Voice Status",
"description": "Live health for our customer support phone lines.",
"slug": "Northwind Voice",
"brandColor": "#0f8f7c",
"active": true,
"groups": [
{ "name": "Customer support", "numberIds": ["clx8k2m4n0001qw3f7z9a1b2c"] },
{ "name": "Sales", "numberIds": ["clx8k2m4n0000qw3f5y1x8w7v"] }
]
}'{
"data": {
"id": "clx8k5p1q0002qw3f2h8d4e6f",
"title": "Northwind Voice Status",
"description": "Live health for our customer support phone lines.",
"slug": "northwind-voice",
"customDomain": null,
"customDomainStatus": "none",
"customDomainMessage": null,
"brandColor": "#0f8f7c",
"active": true,
"activeSlug": "slug:northwind-voice",
"activeCustomDomain": null,
"createdAt": "2026-07-15T10:02:18.900Z",
"updatedAt": "2026-07-15T10:02:18.900Z"
},
"activated": true,
"publicationBlockedReason": null
}Get a status page
/status-pages/{statusPageId}Returns the 13-field page object. activated and publicationBlockedReason are not present on GET. Responses: 200, 401, 404, 429, 500.
curl -sS "https://{YOUR_BROOK_HOST}/api/v1/status-pages/clx8k5p1q0002qw3f2h8d4e6f" \
-H "Authorization: Bearer $BROOK_API_KEY"{
"data": {
"id": "clx8k5p1q0002qw3f2h8d4e6f",
"title": "Northwind Voice Status",
"description": "Live health for our customer support phone lines.",
"slug": "northwind-voice",
"customDomain": null,
"customDomainStatus": "none",
"customDomainMessage": null,
"brandColor": "#0f8f7c",
"active": true,
"activeSlug": "slug:northwind-voice",
"activeCustomDomain": null,
"createdAt": "2026-07-15T10:02:18.900Z",
"updatedAt": "2026-07-15T10:02:18.900Z"
}
}Update a status page
/status-pages/{statusPageId}Merge and re-validate the whole page. Same field set as create, all optional; a body id is ignored. Response body is { data, activated, publicationBlockedReason }, same semantics as POST.
curl -sS -X PATCH "https://{YOUR_BROOK_HOST}/api/v1/status-pages/clx8k5p1q0002qw3f2h8d4e6f" \
-H "Authorization: Bearer $BROOK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"customDomain": "status.northwind.example", "active": true}'{
"data": {
"id": "clx8k5p1q0002qw3f2h8d4e6f",
"title": "Northwind Voice Status",
"description": "Live health for our customer support phone lines.",
"slug": "northwind-voice",
"customDomain": "status.northwind.example",
"customDomainStatus": "pending",
"customDomainMessage": null,
"brandColor": "#0f8f7c",
"active": false,
"activeSlug": null,
"activeCustomDomain": null,
"createdAt": "2026-07-15T10:02:18.900Z",
"updatedAt": "2026-07-29T13:44:51.377Z"
},
"activated": false,
"publicationBlockedReason": "custom_domain_not_verified"
}Setting or changing customDomain always resets customDomainStatus to pending and clears customDomainMessage. The active-page cap on PATCH is a publication gate, not a 403: it returns 200 with publicationBlockedReason: "status_page_limit_reached".
Delete a status page
/status-pages/{statusPageId}Soft-delete. The page stops being served and readable, and its slug and custom domain are released for reuse. Groups, memberships, and channel attachments remain attached to the hidden page. Not idempotent. Responses: 204, 401, 403, 404, 429, 500.
HTTP/1.1 204 No ContentPublication: activated and publicationBlockedReason
activated is true only when you asked for active: true and every publication check passed. When a check fails you still get 201/200, the page is stored with active: false, and publicationBlockedReason names the failing check. See the publicationBlockedReason values table for the five reasons.
Groups
All group endpoints return 404 when the page is unknown/deleted/foreign, or when the group does not belong to the named page.
List groups
/status-pages/{statusPageId}/groupsOrdered by sortOrder ascending. Not paginated. Each group includes a numbers array. Responses: 200, 401, 404, 429, 500.
curl -sS "https://{YOUR_BROOK_HOST}/api/v1/status-pages/clx8k5p1q0002qw3f2h8d4e6f/groups" \
-H "Authorization: Bearer $BROOK_API_KEY"{
"data": [
{
"id": "clx8k7t2r0003qw3f9m5n2p1q",
"statusPageId": "clx8k5p1q0002qw3f2h8d4e6f",
"name": "Customer support",
"sortOrder": 0,
"createdAt": "2026-07-15T10:02:18.912Z",
"updatedAt": "2026-07-15T10:02:18.912Z",
"numbers": [
{
"id": "clx8k7t2r0005qw3f1a2b3c4d",
"statusPageGroupId": "clx8k7t2r0003qw3f9m5n2p1q",
"numberMonitorId": "clx8k2m4n0001qw3f7z9a1b2c",
"sortOrder": 0,
"createdAt": "2026-07-15T10:02:18.918Z",
"updatedAt": "2026-07-15T10:02:18.918Z"
}
]
}
]
}Create a group
/status-pages/{statusPageId}/groupsname (1–80) is the only accepted field. A sortOrder in the body is ignored — the group is appended at previous max + 1 (or 0). The 201 object omits the numbers key entirely. Responses: 201, 400, 401, 403, 404, 429, 500.
curl -sS -X POST "https://{YOUR_BROOK_HOST}/api/v1/status-pages/clx8k5p1q0002qw3f2h8d4e6f/groups" \
-H "Authorization: Bearer $BROOK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Billing hotline"}'{
"data": {
"id": "clx8k9u3s0006qw3f4i5j6k7l",
"statusPageId": "clx8k5p1q0002qw3f2h8d4e6f",
"name": "Billing hotline",
"sortOrder": 2,
"createdAt": "2026-07-29T14:10:02.441Z",
"updatedAt": "2026-07-29T14:10:02.441Z"
}
}Reorder groups
/status-pages/{statusPageId}/groupscurl -sS -X PATCH "https://{YOUR_BROOK_HOST}/api/v1/status-pages/clx8k5p1q0002qw3f2h8d4e6f/groups" \
-H "Authorization: Bearer $BROOK_API_KEY" \
-H "Content-Type: application/json" \
-d '[
{"id": "clx8k9u3s0006qw3f4i5j6k7l", "sortOrder": 0},
{"id": "clx8k7t2r0003qw3f9m5n2p1q", "sortOrder": 1},
{"id": "clx8k7t2r0004qw3f6e7f8g9h", "sortOrder": 2}
]'Get a group
/status-pages/{statusPageId}/groups/{groupId}Returns one group with its numbers array, ordered by sortOrder. Responses: 200, 401, 404, 429, 500.
curl -sS "https://{YOUR_BROOK_HOST}/api/v1/status-pages/clx8k5p1q0002qw3f2h8d4e6f/groups/clx8k7t2r0003qw3f9m5n2p1q" \
-H "Authorization: Bearer $BROOK_API_KEY"{
"data": {
"id": "clx8k7t2r0003qw3f9m5n2p1q",
"statusPageId": "clx8k5p1q0002qw3f2h8d4e6f",
"name": "Customer support",
"sortOrder": 1,
"createdAt": "2026-07-15T10:02:18.912Z",
"updatedAt": "2026-07-30T08:55:10.205Z",
"numbers": [
{
"id": "clx8k7t2r0005qw3f1a2b3c4d",
"statusPageGroupId": "clx8k7t2r0003qw3f9m5n2p1q",
"numberMonitorId": "clx8k2m4n0001qw3f7z9a1b2c",
"sortOrder": 0,
"createdAt": "2026-07-15T10:02:18.918Z",
"updatedAt": "2026-07-15T10:02:18.918Z"
}
]
}
}Rename a group
/status-pages/{statusPageId}/groups/{groupId}A rename, not a partial patch. name is required; omitting it → 400 {"name": "Invalid input: expected string, received undefined"}. sortOrder cannot change here. The 200 object omits numbers.
curl -sS -X PATCH "https://{YOUR_BROOK_HOST}/api/v1/status-pages/clx8k5p1q0002qw3f2h8d4e6f/groups/clx8k7t2r0003qw3f9m5n2p1q" \
-H "Authorization: Bearer $BROOK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Customer support (24/7)"}'{
"data": {
"id": "clx8k7t2r0003qw3f9m5n2p1q",
"statusPageId": "clx8k5p1q0002qw3f2h8d4e6f",
"name": "Customer support (24/7)",
"sortOrder": 1,
"createdAt": "2026-07-15T10:02:18.912Z",
"updatedAt": "2026-07-30T09:01:33.777Z"
}
}Delete a group
/status-pages/{statusPageId}/groups/{groupId}HTTP/1.1 204 No ContentGroup numbers
A membership links one monitor to one group. Its id is the membership id — distinct from numberMonitorId. Reorder and delete take the membership id.
List group numbers
/status-pages/{statusPageId}/groups/{groupId}/numbersNot paginated. Ordered by sortOrder. Responses: 200, 401, 404, 429, 500.
{
"data": [
{
"id": "clx8k7t2r0005qw3f1a2b3c4d",
"statusPageGroupId": "clx8k7t2r0003qw3f9m5n2p1q",
"numberMonitorId": "clx8k2m4n0001qw3f7z9a1b2c",
"sortOrder": 0,
"createdAt": "2026-07-15T10:02:18.918Z",
"updatedAt": "2026-07-15T10:02:18.918Z"
}
]
}Add a number to a group
/status-pages/{statusPageId}/groups/{groupId}/numbersnumberMonitorId (owned, non-deleted; need not be active) is required. Omit sortOrder — an explicit value colliding with an existing entry returns 500. Validation order: page/group ownership (404) → monitor ownership (invalid_number_membership) → duplicate (duplicate_membership). Responses: 201, 400, 401, 403, 404, 429, 500.
curl -sS -X POST "https://{YOUR_BROOK_HOST}/api/v1/status-pages/clx8k5p1q0002qw3f2h8d4e6f/groups/clx8k7t2r0003qw3f9m5n2p1q/numbers" \
-H "Authorization: Bearer $BROOK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"numberMonitorId": "clx8k2m4n0000qw3f5y1x8w7v"}'{
"data": {
"id": "clx8kb4v70007qw3f8m9n0o1p",
"statusPageGroupId": "clx8k7t2r0003qw3f9m5n2p1q",
"numberMonitorId": "clx8k2m4n0000qw3f5y1x8w7v",
"sortOrder": 1,
"createdAt": "2026-07-30T09:14:47.002Z",
"updatedAt": "2026-07-30T09:14:47.002Z"
}
}{
"error": {
"code": "bad_request",
"message": "This number is already in the group.",
"details": { "code": "duplicate_membership" }
}
}Reorder group numbers
/status-pages/{statusPageId}/groups/{groupId}/numbersIdentical mechanics to the group reorder, scoped to one group. Top-level JSON array; each id is a membership id. Same hazards: unknown/foreign/duplicate ids → 404; index-keyed 400; partial reorders or repeated sortOrder → 500; empty array → 200 {"data": []}.
curl -sS -X PATCH "https://{YOUR_BROOK_HOST}/api/v1/status-pages/clx8k5p1q0002qw3f2h8d4e6f/groups/clx8k7t2r0003qw3f9m5n2p1q/numbers" \
-H "Authorization: Bearer $BROOK_API_KEY" \
-H "Content-Type: application/json" \
-d '[
{"id": "clx8kb4v70007qw3f8m9n0o1p", "sortOrder": 0},
{"id": "clx8k7t2r0005qw3f1a2b3c4d", "sortOrder": 1}
]'Remove a number from a group
/status-pages/{statusPageId}/groups/{groupId}/numbers/{numberId}numberId is the membership id, not the monitor id. Hard delete of the membership row; the monitor is untouched. Remaining entries are not renumbered, leaving a harmless gap in sortOrder. Not idempotent. Responses: 204, 401, 403, 404, 429, 500.
HTTP/1.1 204 No ContentCustom domains
Domain lifecycle
The custom domain is an attribute-set on the status page, not a standalone object.
none ──(PATCH /status-pages with customDomain)──► pending
▲ │
│ DELETE /custom-domain │ POST /custom-domain
│ PATCH /status-pages with customDomain:"" │ (only from "none")
│ ▼
│ POST /custom-domain/validate
│ │
└────────────────────────────── verified ◄──────┴──────► failedStatus values: none, pending, verified, failed. Only verified unlocks publication. pending is advisory; POST /custom-domain is a no-op unless the current status is exactly none, so you cannot use it to reset a failed domain — just call validate again.
Get domain status and DNS records
/status-pages/{statusPageId}/custom-domainReads the domain, status, check timestamps, and DNS records to publish. No entitlement check — readable on any plan. Responses: 200, 401, 404, 429, 500. No 403.
{
"data": {
"domain": "status.northwind.example",
"status": "pending",
"message": null,
"lastCheckedAt": null,
"verifiedAt": null,
"dnsRecords": [
{ "type": "CNAME", "host": "status.northwind.example", "value": "app.brookai.co" }
]
}
}Begin verification
/status-pages/{statusPageId}/custom-domainMove a domain from none to pending. No request body. Requires a plan with custom domains. If already pending/verified/failed, it is a no-op. If the page has no customDomain, returns 400 {"code": "no_domain_configured"}.
curl -sS -X POST "https://{YOUR_BROOK_HOST}/api/v1/status-pages/clx8k5p1q0002qw3f2h8d4e6f/custom-domain" \
-H "Authorization: Bearer $BROOK_API_KEY"Validate DNS
/status-pages/{statusPageId}/custom-domain/validateAny single matching record of any type (CNAME/A/AAAA/TXT) verifies the domain; there is no TXT ownership proof. Response fields: status, reason (present only on failure), message, expected, found, checkedAt. Responses: 200, 400 (no_domain_configured), 401, 403, 404, 429, 500.
{
"data": {
"status": "failed",
"reason": "DNS records do not point to Brook yet.",
"message": "DNS records do not point to Brook yet.",
"expected": ["app.brookai.co"],
"found": ["northwind-lb.example.net"],
"checkedAt": "2026-07-30T09:31:02.914Z"
}
}{
"data": {
"status": "verified",
"message": "Domain DNS points to Brook.",
"expected": ["app.brookai.co"],
"found": ["app.brookai.co"],
"checkedAt": "2026-07-30T09:44:20.183Z"
}
}Detach a domain
/status-pages/{statusPageId}/custom-domainHTTP/1.1 204 No ContentNotification channels
List channels
/notification-channelsPaginated. Responses: 200, 400, 401, 429, 500.
{
"data": [
{
"id": "clx8kd6x90008qw3f2q3r4s5t",
"type": "slack",
"name": "#voice-alerts",
"destination": "https://hooks.slack.com/services/T0000000/B0000000/XXXXXXXXXXXXXXXXXXXXXXXX",
"enabled": true,
"createdAt": "2026-07-20T15:22:07.310Z",
"updatedAt": "2026-07-20T15:22:07.310Z",
"statusPageIds": ["clx8k5p1q0002qw3f2h8d4e6f"]
}
],
"nextCursor": null
}Create a channel
/notification-channelsA body id is discarded. Responses: 201, 400, 401, 403 (scope only — no plan cap on channel creation), 429, 500.
| Field | Req. | Constraints |
|---|---|---|
| type | yes | "email" | "slack" | "discord" | "custom_http". No webhook, no sms. |
| name | yes | Trimmed, 1–80 chars |
| destination | yes | Per-type rules below |
| secret | cond. | Required and non-empty when type is custom_http; forcibly stored as null for every other type. Never returned. |
| enabled | no | Default true |
| type | Destination rule |
|---|---|
| Matches ^[^\s@]+@[^\s@]+\.[^\s@]+$ — else "Enter a valid email address." | |
| slack / discord | Syntactically valid https: URL; host not checked — else "Enter a valid HTTPS webhook URL." |
| custom_http | https: and a publicly-routable host (no localhost, loopback, link-local, or RFC-1918 ranges) — else "Enter a public HTTPS URL." / "Use a public HTTPS host." |
curl -sS -X POST "https://{YOUR_BROOK_HOST}/api/v1/notification-channels" \
-H "Authorization: Bearer $BROOK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "custom_http",
"name": "PagerDuty bridge",
"destination": "https://hooks.northwind.example/brook",
"secret": "whsec_3f9a1c7d2b6e4805",
"enabled": true
}'{
"data": {
"id": "clx8kf8za000aqw3f0y1z2a3b",
"type": "custom_http",
"name": "PagerDuty bridge",
"destination": "https://hooks.northwind.example/brook",
"enabled": true,
"createdAt": "2026-07-30T10:02:31.775Z",
"updatedAt": "2026-07-30T10:02:31.775Z",
"statusPageIds": []
}
}Get a channel
/notification-channels/{channelId}Responses: 200, 401, 404, 429, 500.
{
"data": {
"id": "clx8kf8za000aqw3f0y1z2a3b",
"type": "custom_http",
"name": "PagerDuty bridge",
"destination": "https://hooks.northwind.example/brook",
"enabled": true,
"createdAt": "2026-07-30T10:02:31.775Z",
"updatedAt": "2026-07-30T10:02:31.775Z",
"statusPageIds": ["clx8k5p1q0002qw3f2h8d4e6f"]
}
}Update a channel
/notification-channels/{channelId}Merge and re-validate. A body id is ignored. Responses: 200, 400, 401, 403, 404, 429, 500.
curl -sS -X PATCH "https://{YOUR_BROOK_HOST}/api/v1/notification-channels/clx8kf8za000aqw3f0y1z2a3b" \
-H "Authorization: Bearer $BROOK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"enabled": false}'{
"data": {
"id": "clx8kf8za000aqw3f0y1z2a3b",
"type": "custom_http",
"name": "PagerDuty bridge",
"destination": "https://hooks.northwind.example/brook",
"enabled": false,
"createdAt": "2026-07-30T10:02:31.775Z",
"updatedAt": "2026-07-30T10:02:31.775Z",
"statusPageIds": ["clx8k5p1q0002qw3f2h8d4e6f"]
}
}Delete a channel
/notification-channels/{channelId}Soft-delete: marked deleted, set enabled: false, and detached from every status page. Not idempotent. Responses: 204, 401, 403, 404, 429, 500.
HTTP/1.1 204 No ContentAttach a channel to a status page
/notification-channels/{channelId}/status-pages/{statusPageId}Idempotent — repeated calls return 200 and change nothing. No request body. Both ids must belong to your account and be non-deleted, else 404 (which id is missing is not disclosed). The response echoes the path (no timestamps). Responses: 200, 401, 403, 404, 429, 500.
{
"data": {
"channelId": "clx8kd6x90008qw3f2q3r4s5t",
"statusPageId": "clx8k5p1q0002qw3f2h8d4e6f"
}
}Detach a channel from a status page
/notification-channels/{channelId}/status-pages/{statusPageId}Not idempotent — unlike its PUT twin, a repeated call returns 404, which does not distinguish "unknown ids" from "already detached". Responses: 204, 401, 403, 404, 429, 500.
HTTP/1.1 204 No ContentOpenAPI document
/openapiServe the machine-readable OpenAPI description. Unauthenticated by design — no Authorization header is required or consulted. A format query param of json returns parsed JSON; anything else returns YAML. The YAML response is cacheable (Cache-Control: public, max-age=300); the JSON response sends no Cache-Control. Responses: 200.
curl -sS "https://{YOUR_BROOK_HOST}/api/v1/openapi" | head -4openapi: 3.1.0
info:
title: Brook Public API
version: 1.0.0Schemas
NumberMonitor
Returned by all /number-monitors reads and writes. There is no status field — this API does not expose live monitor health.
| Field | Type | Nullable |
|---|---|---|
| id | string | no |
| label | string (1–80) | no |
| country | string, uppercase ISO alpha-2 | no |
| countryCallingCode | string, digits no + | no |
| localNumber | string, digits only | no |
| phoneNumber | string, E.164 (not e164) | no |
| greeting | string (1–500) | no |
| greetingRule | "exactly" | "contains" | no |
| scheduleType | "preset" | "cron" | no |
| intervalSeconds | number (derived for cron) | no |
| cronExpression | string | yes (null for preset) |
| timeoutSeconds | number (5–10) | no |
| retryCount | number (1–5) | no |
| active | boolean | no |
| projectedMonthlyPings | number | no |
| averageResponseLatencyMs | number | yes (null until enough checks) |
| createdAt / updatedAt | string, ISO 8601 UTC | no |
StatusPage
Returned by all /status-pages reads and writes (writes wrap it in StatusPageWriteResult).
| Field | Type | Nullable |
|---|---|---|
| id, title | string (title 1–90) | no |
| description | string (≤280) | yes |
| slug | string, [a-z0-9-] | yes |
| customDomain | string | yes |
| customDomainStatus | none|pending|verified|failed | no |
| customDomainMessage | string | yes |
| brandColor | string #rrggbb | yes |
| active | boolean | no |
| activeSlug | string slug:<slug> | yes (non-null only while published) |
| activeCustomDomain | string domain:<host> | yes |
| createdAt / updatedAt | string, ISO 8601 UTC | no |
StatusPageWriteResult
The body of POST /status-pages and PATCH /status-pages/{id}: { data: StatusPage, activated: boolean, publicationBlockedReason: string | null }.
StatusPageGroup
Returned by the group endpoints. id is invalidated by any page-level write. numbers (a StatusPageGroupNumber[]) is present only on group list and single-group reads — entirely absent (not null, not []) on create, rename, and reorder responses. Other fields: statusPageId, name (1–80), sortOrder (unique within page), createdAt, updatedAt.
StatusPageGroupNumber
A membership row. id is the membership id (pass it to reorder/delete). The group reference field is statusPageGroupId (not groupId). Also: numberMonitorId, sortOrder (unique within group), createdAt, updatedAt.
NotificationChannel
Returned by the channel endpoints. destination is returned unredacted; secret is never present. Fields: id, type (email|slack|discord|custom_http), name (1–80), destination, enabled, createdAt, updatedAt, statusPageIds (read-only, order unspecified).
ChannelAttachment
Body of a successful attach (PUT): { channelId: string, statusPageId: string }. No timestamps.
DnsRecord
Inside CustomDomainState.dnsRecords: type (CNAME|A|AAAA|TXT), host (your domain verbatim — the field is host, not name), value (the target to publish).
CustomDomainState
From GET /custom-domain: domain (nullable), status, message (nullable), lastCheckedAt (nullable), verifiedAt (nullable), dnsRecords ([] when no domain). POST /custom-domain returns the same object minus lastCheckedAt and verifiedAt.
DomainValidationResult
From POST /custom-domain/validate: status (verified|failed only), reason (key absent on success; present on failure, never null), message, expected (string[]), found (string[]; [] when the hostname was rejected pre-lookup), checkedAt.
ErrorEnvelope
{ error: { code, message, details? } }. details present only when code is bad_request. See Errors.
PaginatedList
{ data: T[], nextCursor: string | null }. nextCursor is null on the final page.
Guides
End-to-end setup
See Quickstart for the full monitor → page → publish script. Always check activated — a 200 with "activated": false means the page saved but is not live.
Paginating a list
BASE="https://{YOUR_BROOK_HOST}/api/v1"
CURSOR=""
while :; do
URL="$BASE/number-monitors?limit=100"
[ -n "$CURSOR" ] && URL="$URL&cursor=$CURSOR"
BODY=$(curl -sS "$URL" -H "Authorization: Bearer $BROOK_API_KEY")
echo "$BODY" | jq -c '.data[]'
CURSOR=$(echo "$BODY" | jq -r '.nextCursor // empty')
[ -z "$CURSOR" ] && break
donetype Page<T> = { data: T[]; nextCursor: string | null };
async function* paginate<T>(path: string, apiKey: string, base: string): AsyncGenerator<T> {
let cursor: string | null = null;
do {
const url = new URL(`${base}${path}`);
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const page = (await res.json()) as Page<T>;
yield* page.data;
cursor = page.nextCursor;
} while (cursor !== null);
}Handling 429 with backoff
Read Retry-After (seconds), sleep, retry; fall back to a fixed delay when the header is absent; cap retries; add jitter for concurrent workers. Do not retry 400/401/403/404 — they are deterministic. Retry 500 at most once.
async function brookFetch(url: string, init: RequestInit, apiKey: string, maxRetries = 5): Promise<Response> {
for (let attempt = 0; ; attempt++) {
const res = await fetch(url, {
...init,
headers: { ...init.headers, Authorization: `Bearer ${apiKey}` },
});
if (res.status !== 429 || attempt >= maxRetries) return res;
const header = res.headers.get("Retry-After");
const waitSeconds = header ? Number(header) : 5;
await new Promise((r) => setTimeout(r, waitSeconds * 1000 + Math.random() * 500));
}
}curl -sS --retry 5 --retry-all-errors --retry-delay 5 \
"https://{YOUR_BROOK_HOST}/api/v1/number-monitors" \
-H "Authorization: Bearer $BROOK_API_KEY"Setting up a custom domain
BASE="https://{YOUR_BROOK_HOST}/api/v1"
AUTH="Authorization: Bearer $BROOK_API_KEY"
JSON="Content-Type: application/json"
PAGE_ID="clx8k5p1q0002qw3f2h8d4e6f"
# 1. Attach the domain (this alone sets status to "pending")
curl -sS -X PATCH "$BASE/status-pages/$PAGE_ID" -H "$AUTH" -H "$JSON" \
-d '{"customDomain": "status.northwind.example"}' | jq '.data.customDomainStatus'
# 2. Read the records you must publish
curl -sS "$BASE/status-pages/$PAGE_ID/custom-domain" -H "$AUTH" \
| jq -r '.data.dnsRecords[] | "\(.type)\t\(.host)\t\(.value)"'
# --- publish those records at your registrar, then wait for propagation ---
# 3. Poll validation until verified (10 attempts, 60s apart)
for _ in $(seq 1 10); do
STATUS=$(curl -sS -X POST "$BASE/status-pages/$PAGE_ID/custom-domain/validate" -H "$AUTH" \
| jq -r '.data.status')
echo "validate -> $STATUS"
[ "$STATUS" = "verified" ] && break
sleep 60
done
# 4. Publish
curl -sS -X PATCH "$BASE/status-pages/$PAGE_ID" -H "$AUTH" -H "$JSON" \
-d '{"active": true}' | jq '{activated, publicationBlockedReason}'Wiring a notification channel
Notifications route through status pages, not monitors: a channel fires for a monitor only if that monitor appears on a status page the channel is attached to. Two ways to attach:
| Path | Entitlement check | Requires enabled | Semantics |
|---|---|---|---|
| PUT .../status-pages/{pageId} | No | No | Additive, idempotent |
| notificationChannelIds on a page write | Yes (403) | Yes (400) | Full replace — omitted ids are detached |
The additive PUT is usually what you want; the page-body path replaces the whole set, so read the current attachments first if you mean to add.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyBrookSignature(
rawBody: string,
timestampHeader: string,
signatureHeader: string,
secret: string,
): boolean {
const expected = createHmac("sha256", secret)
.update(`${timestampHeader}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signatureHeader, "utf8");
return a.length === b.length && timingSafeEqual(a, b);
}Safely editing group structure
Because a page-level PATCH destroys and recreates every group, use the group sub-endpoints for structural edits. Never issue a partial reorder — a sortOrder colliding with an unlisted sibling returns 500.
BASE="https://{YOUR_BROOK_HOST}/api/v1"
AUTH="Authorization: Bearer $BROOK_API_KEY"
JSON="Content-Type: application/json"
PAGE_ID="clx8k5p1q0002qw3f2h8d4e6f"
MONITOR_ID="clx8k2m4n0001qw3f7z9a1b2c"
# Add a group and a monitor without disturbing existing group ids
GROUP_ID=$(curl -sS -X POST "$BASE/status-pages/$PAGE_ID/groups" -H "$AUTH" -H "$JSON" \
-d '{"name": "Billing hotline"}' | jq -r '.data.id')
curl -sS -X POST "$BASE/status-pages/$PAGE_ID/groups/$GROUP_ID/numbers" -H "$AUTH" -H "$JSON" \
-d "{\"numberMonitorId\": \"$MONITOR_ID\"}" | jq
# Reorder — always send the COMPLETE, contiguous orderingFailure playbook
| Symptom | Likely cause | Action |
|---|---|---|
| 400 {"scheduleType": ...} | You sent "interval" | Send "preset" |
| 400 {"localNumber": "Choose a supported country."} | country is lowercase or not an ISO code | Send "US", not "us" |
| 400 {"intervalSeconds": ...} on an unrelated PATCH | Merged pre-existing value re-validated; or cron→preset | Send scheduleType and intervalSeconds together |
| 200 but "activated": false | A publication check failed | Read publicationBlockedReason |
| invalid_channel_membership on a page PATCH | An attached channel was disabled/deleted | Enable it, or detach and retry |
| 400 {"groups": "Too small..."} on every page PATCH | The page has zero groups | POST a group first |
| Group id suddenly 404s | A page-level write recreated all groups | Re-read the group list; avoid page-level writes for structure |
| Status page went offline by itself | A validate returned failed and set active: false | Fix DNS, re-validate, re-PATCH active: true |
| 403 custom_domain_not_entitled removing a domain | Plan downgraded | PATCH /status-pages/{id} with {"customDomain": ""} |
| 401 from one client only | Trailing-slash 308 dropping the header | Remove the trailing slash |
Reference
Constraint cheat sheet
| Field | Constraint |
|---|---|
| API key name | ≤ 32 chars (form accepts 80, store rejects > 32) |
| Keys per account | 10 |
| Rate limit | 100 requests / key, reset by a 60s idle gap |
| label, group/channel name | 1–80 chars |
| page title | 1–90 chars |
| page description | ≤ 280 chars |
| slug (raw) | ≤ 80 chars |
| customDomain | ≤ 253 chars |
| greeting | 1–500 chars |
| intervalSeconds | ≥ 300 floor; preset set below; ≥ plan minimum |
| timeoutSeconds | 5–10 |
| retryCount | 1–5 |
| cron occurrences | ≤ 20,000 per billing period |
| brandColor | ^#[0-9a-fA-F]{6}$ (six-digit hex only) |
| limit | 1–100 (above 100 clamped) |
Enum values
| Set | Values |
|---|---|
| greetingRule | exactly, contains |
| scheduleType | preset, cron |
| preset intervalSeconds | 300, 600, 1800, 3600, 21600, 43200, 86400 |
| customDomainStatus | none, pending, verified, failed |
| channel type | email, slack, discord, custom_http |
| DNS record type | CNAME, A, AAAA, TXT |
| scopes | read, write |
Changelog
| Version | Date | Notes |
|---|---|---|
| v1 | TODO: release date | Initial public release. |