Docs · REST API

API reference

Everything in the UI, over JSON. The reference below is written from the implementation — if an endpoint is not here, it does not exist.

On this page

Introduction #

The API lives under https://quietbeat.dev/api/v1. Requests and responses are JSON; a response is keyed by its resource (monitor, monitors, incidents …) rather than wrapped in a generic envelope. IDs are integers. Timestamps are ISO 8601 in UTC, second precision, with a Z suffix.

Two machine-readable companions to this page: /api/openapi.json — an OpenAPI 3.1 document pinned to the router by tests, so it cannot drift — and /llms.txt, a plain-text overview written for agents arriving cold.

Authentication #

Create tokens under Settings → API tokens in the app. A token is shown once at creation, looks like qb_…, and is stored only as a hash — treat it like a password, keep it in an environment variable, and never put it in a URL. Every request carries it as a bearer header:

request
curl https://quietbeat.dev/api/v1/status \
  -H "Authorization: Bearer qb_YOUR_TOKEN"

A token can be minted read-only. Read-only tokens can call every GET endpoint but no writes, and the MCP server withholds its write tools from them. There are no finer-grained scopes. Revoking a token (in the same screen) takes effect on the next request.

authentication errors
# missing, malformed, or revoked token
HTTP 401
www-authenticate: Bearer
{"error":{"message":"missing or invalid API token","code":"unauthorized"}}

# read-only token on any POST/PATCH/DELETE under /api/v1
HTTP 403
{"error":{"message":"read-only token — writes are not allowed","code":"read_only_token"}}

Acting on a team workspace #

A token belongs to your user. To act on a team workspace you hold a seat on, send the workspace owner's id in the x-quietbeat-workspace header. A header naming a workspace you have no seat on — or one that is not an id at all — is refused with a 404 workspace not found, never silently moved onto your own account. GET /api/v1/workspaces still answers under a stale header, and its workspaces[].current marks the workspace the header selects.

request
# list the workspaces your token can act on
curl https://quietbeat.dev/api/v1/workspaces \
  -H "Authorization: Bearer qb_YOUR_TOKEN"

# then aim any request at one of them
curl https://quietbeat.dev/api/v1/monitors \
  -H "Authorization: Bearer qb_YOUR_TOKEN" \
  -H "x-quietbeat-workspace: 8"

Quickstart #

Zero to a monitored endpoint in five minutes.

1 · Mint a token. Settings → API tokens → "New token". Copy it now; it is shown once.

2 · Make an authenticated call. The account summary is a good smoke test:

request · response
curl https://quietbeat.dev/api/v1/status \
  -H "Authorization: Bearer qb_YOUR_TOKEN"

HTTP 200
{"counts": {}, "monitors": [], "summary": "No monitors yet."}

3 · Create a monitor. An HTTP check on a five-minute interval:

request · response
curl -X POST https://quietbeat.dev/api/v1/monitors \
  -H "Authorization: Bearer qb_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "prod site", "kind": "http",
       "url": "https://example.com/health", "interval_seconds": 300}'

HTTP 201
{"monitor": {"id": 42, "name": "prod site", "kind": "http",
             "status": "pending", "display_status": "pending", ...}}

4 · Read it back. The first probe runs immediately; status moves from pending to up or down within the first interval:

request
curl https://quietbeat.dev/api/v1/monitors/42 \
  -H "Authorization: Bearer qb_YOUR_TOKEN"

curl "https://quietbeat.dev/api/v1/monitors/42/stats?window=24h" \
  -H "Authorization: Bearer qb_YOUR_TOKEN"

5 · Know where results land. Failures open incidents — list them at GET /api/v1/incidents, watch them on the Incidents screen, and alerts go to your account email out of the box (alert channels and escalation are below).

Monitors #

The central resource: one monitor is one thing being watched — an HTTP endpoint, a TCP port, a DNS name, or a heartbeat a job pings. The browser kind remains in the reserved API schema, but browser execution is unavailable and no run is queued. All monitor endpoints take the bearer token; writes need a writable one.

Endpoint What it does
GET /api/v1/monitors List every monitor of the workspace, name-ascending. No pagination.
POST /api/v1/monitors Create a monitor. 201 with the monitor, or 422.
GET /api/v1/monitors/:id One monitor.
PATCH /api/v1/monitors/:id Partial update — same fields as create. kind never moves into or out of event, certificate or browser.
DELETE /api/v1/monitors/:id Delete the monitor and its history. 204.
POST /api/v1/monitors/:id/pause Stop checks and alerts. Idempotent.
POST /api/v1/monitors/:id/resume Start again — probes immediately. Idempotent.
GET /api/v1/monitors/:id/stats Uptime and response-time stats for a window.
GET /api/v1/monitors/:id/checks Recent probe results.
GET /api/v1/monitors/:id/beats Recent heartbeat pings.

The monitor object #

status is the stored state (pending · up · down · paused); display_status adds a derived partial while a failure waits for cross-vantage confirmation — read display_status if you are painting a dashboard. ping_url is returned only for heartbeat monitors to a writable REST token, and only on the creation response — afterwards the key stays present as null, because we keep a hash of the address rather than the address itself. It is omitted entirely for read-only REST and MCP reads because it is itself a write credential. Lost it? Mint a new address from the monitor page. request_headers, request_body, and browser-flow secrets are write-only credentials: accepted on writes, encrypted at rest, never echoed back.

the monitor object
{
  "monitor": {
    "id": 42,
    "name": "prod site",
    "kind": "http",
    "url": "https://example.com/health",
    "keyword": null, "keyword_absent": null,
    "http_method": "get",
    "header_asserts": [], "json_asserts": [],
    "interval_seconds": 300, "grace_seconds": 300,
    "group": null,
    "cron_schedule": null, "schedule_timezone": null,
    "expected_status": null,
    "max_duration_ms": null,
    "payload_pattern": null, "payload_expect": null,
    "dns_record_type": null, "dns_expected": null,
    "degraded_threshold_ms": null, "timeout_ms": null, "repage_minutes": null,
    "vendor_dependencies": [],
    "status": "up", "display_status": "up",
    "public": false,
    "last_beat_at": null,
    "last_checked_at": "2026-08-10T09:29:12Z",
    "ssl_expires_at": "2026-11-02T00:00:00Z",
    "runbook": "check the load balancer first",
    "maintenance_ends_at": null, "maintenance_ends_at_capped": false,
    "maintenance_next_starts_at": null, "maintenance_window_ids": [],
    "ping_url": null,
    "created_at": "2026-07-01T08:00:00Z"
  }
}

Create a monitor #

POST /api/v1/monitors bearer · writable

Unknown fields are ignored. Plan gates surface as ordinary 422 validation errors: monitor count (10 on Hobby, 50 Solo, 200 Team, 500 Business, 1000 Scale). Browser monitor creation is currently refused with 422 on every plan because execution is unavailable; no run is queued.

Common parameters

name string required

Human-readable monitor name, 1–120 characters. Also the identity key for sync.

kind string required

What is being watched. browser is a reserved, unavailable schema value and cannot currently be created. event monitors exist but are created by event sources, not over this endpoint.

http · heartbeat · tcp · dns · browser · certificate

interval_seconds integer optional

How often the monitor is checked — or, for heartbeats, how often a ping is expected. The UI offers presets; the API accepts any integer in range.

default 60 · any integer from your plan's floor (60 s on Hobby/Solo, 30 s on Team and up) to 86400

grace_seconds integer optional

Extra allowance past the expected time before a heartbeat counts as missed.

default 300 · 0–86400

group string optional

Free-form grouping label, up to 60 characters; blank clears it.

runbook string optional

Your "what to do when this breaks" note. Ships with every alert, webhook payload, and agent call.

degraded_threshold_ms integer optional

Response time above which the monitor reads as degraded (amber, not down).

timeout_ms integer optional

How long one check waits for a reply before it counts as failed. Defaults to 10000. Capped at 30000, and always below the gap to the next check, so probes never stack on a struggling target.

1000–30000

repage_minutes integer optional

Re-page cadence while an incident stays unacknowledged.

5–60

vendor_dependencies array of strings optional

Upstream vendors this monitor depends on (e.g. stripe), used for incident correlation. Unknown slugs are rejected.

max 20 · catalog slugs

relay_id integer optional

Pin the checks to one of your private relays. Not available for heartbeat, DNS, or browser kinds, and not combinable with custom methods, headers, bodies, or asserts.

HTTP checks (kind: http)

url string required

Full http(s):// address to probe. Private and link-local hosts are refused.

http_method string optional

Probe method. HEAD brings back no body, so body rules (keyword, json_asserts) are rejected with it.

get (default) · post · put · patch · head

expected_status string optional

Comma-separated status codes and ranges that count as alive. Absent: anything below 400 is alive.

e.g. "200" · "200-299,301"

keyword string optional

The response body must contain this string.

keyword_absent string optional

The response body must not contain this string.

request_headers object optional · write-only

Headers to send with the probe, e.g. an auth header. Encrypted, never returned by any read.

max 5 headers

request_body string optional · write-only

Body for POST/PUT/PATCH probes. Encrypted, never returned.

max 10 KB

header_asserts array of objects optional

Assertions on response headers.

max 5 · {"name","op","value"} · ops: exists · equals · contains

json_asserts array of objects optional

Assertions on the JSON response body.

max 5 · {"path","op","value"} · ops: exists · equals · contains · gt · lt

Heartbeats (kind: heartbeat)

cron_schedule string optional

Expect pings on this cron schedule instead of a fixed interval.

five fields, like "0 3 * * *"

schedule_timezone string optional

The wall clock the cron schedule runs on. Only valid together with cron_schedule.

IANA zone · default UTC

max_duration_ms integer optional

A timed run (see /start) that finishes past this raises a slow-run alert; one still running past this plus the grace is reported missed.

payload_pattern string optional

Alert when a ping's payload contains this (case-insensitive) — e.g. ERROR.

max 200 chars

payload_expect string optional

Alert when a finish ping's payload does not contain this — catches the job that ran, exited 0, and wrote nothing.

max 200 chars

Certificates (kind: certificate)

cert_source string required

Where the certificate is. upload stores the public certificate you send; endpoint reads what a host:port serves; agent is read off a workload by a relay you run.

upload · endpoint · agent

cert_profile string required

What the certificate is for, and therefore what is asked of it. The profile and its version are stored with every result.

generic · tls_server · mtls_client · psd2_qwac · psd2_qsealc

cert_material_text string required for upload

PEM certificates, a PKCS#7 bundle, or base64 DER. Private keys and PKCS#12 / PFX bundles are refused by name — never send one. Nothing in Quietbeat stores a private key.

url string required for endpoint

host:port. For the agent source it is optional and means something else: a workload-local host:port the agent may handshake against to prove the private key is usable, without ever touching it.

cert_agent_path string required for agent

An absolute path on the machine the relay runs on. The relay refuses the file outright if it holds a private key.

interval_seconds integer optional

900 or more — a certificate is read every fifteen minutes at most.

cert_warn_days integer optional

Days before expiry that turn the certificate's health to warning. Default 30.

cert_critical_days integer optional

Days before expiry that turn it critical. Default 7.

cert_expected_hostname string optional

A hostname the certificate must cover, for sources with no host of their own.

TCP and DNS (kind: tcp · dns)

url string required

For TCP: host:port (port 1–65535). For DNS: a public hostname — private suffixes like .internal are refused.

dns_record_type string required for dns

Record type to resolve.

A · AAAA · CNAME · MX · TXT · NS

dns_expected string optional

A value the answer set must contain.

Browser & Lighthouse (not available yet — execution unavailable)

browser_mode string optional

Reserved field only. flow and script are documented for schema compatibility; browser execution is unavailable, so POST returns 422 and no run is queued.

flow (default) · script

flow_instructions string required for flow

Reserved plain-language instruction field; it is not executed.

10–2000 chars

browser_script string required for script

Reserved script field. Treated as a credential — never returned or executed.

max 64 KB

lighthouse_enabled boolean optional

Reserved Lighthouse field for HTTP monitors; audits are unavailable.

lighthouse_min_performance integer optional

Reserved thresholds; no audit or alert is currently scheduled.

0–100 · likewise _accessibility, _best_practices, _seo

Worked example: a heartbeat #

Heartbeat monitors take no URL — the response hands you the ping_url your job will call:

request
curl -X POST https://quietbeat.dev/api/v1/monitors \
  -H "Authorization: Bearer qb_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "nightly backup", "kind": "heartbeat",
       "interval_seconds": 86400, "grace_seconds": 3600,
       "runbook": "check restic logs first"}'
response
HTTP 201
{
  "monitor": {
    "id": 43, "name": "nightly backup", "kind": "heartbeat",
    "status": "pending", "display_status": "pending",
    "interval_seconds": 86400, "grace_seconds": 3600,
    "ping_url": "https://quietbeat.dev/b/EXAMPLETOKEN",
    "runbook": "check restic logs first",
    "url": null, "last_beat_at": null,
    "created_at": "2026-08-10T09:30:00Z", ...
  }
}

Stats #

GET /api/v1/monitors/:id/stats bearer

Query parameters

window string optional

Any other value is a 400. Percentile history is clamped to your plan's retention (capped: true when shortened).

1h · 24h · 7d · 30d · default 24h

request
curl "https://quietbeat.dev/api/v1/monitors/42/stats?window=24h" \
  -H "Authorization: Bearer qb_YOUR_TOKEN"
response
HTTP 200
{
  "window": "24h",
  "uptime": {"percent": 99.93, "observed_minutes": 1440,
             "covers_full_window": true},
  "response_time_ms": {"min": 84, "avg": 132, "max": 611,
                       "p95": 288, "count": 1391},
  "percentiles": {
    "range": "day", "days": 1,
    "since": "2026-08-09T09:30:00Z", "capped": false,
    "overall": {"min": 84, "avg": 132, "p50": 118, "p95": 288,
                "p99": 402, "max": 611, "count": 1391},
    "regions": []
  }
}

Uptime is span-honest: covers_full_window stays false until the monitor has lived the whole window. percentiles.regions fills only when at least two named regions have data.

Checks and beats #

GET /api/v1/monitors/:id/checks bearer

Query parameters

limit integer optional

Most recent N probe results, returned oldest-first.

default 60 · max 200

response
HTTP 200
{"checks": [
  {"id": 88121, "at": "2026-08-10T09:27:00Z", "status": "ok",
   "response_time_ms": 118, "region": "fra"},
  {"id": 88122, "at": "2026-08-10T09:28:00Z", "status": "fail",
   "response_time_ms": null, "region": "fra"}
]}
GET /api/v1/monitors/:id/beats bearer

Query parameters

limit integer optional

Most recent heartbeat pings, newest-first. payload is the stored evidence body, capped at 10 KB.

default 10 · max 50

response
HTTP 200
{"beats": [
  {"at": "2026-08-10T04:00:09Z", "kind": "finish",
   "duration_ms": 9200, "exit_code": null, "run_id": "ci-8841",
   "payload": "backup OK — 412 files, 1.2 GB"},
  {"at": "2026-08-10T04:00:00Z", "kind": "start",
   "duration_ms": null, "exit_code": null, "run_id": "ci-8841",
   "payload": null}
]}

Errors on this resource: 404 monitor not found for a missing or malformed id, 422 validation failed with per-field details, and 422 with a plain message when resuming a monitor suspended over your plan limit.

Incidents #

An incident opens when a monitor goes down (down), misses its heartbeat window (missed), or a job reports its own failure (failed). Evidence — the response snapshot, DNS, TLS, and response-time run-up captured at the moment of failure — is attached automatically and shown on the incident page in the app.

GET /api/v1/incidents bearer

Query parameters

monitor_id integer optional

Narrow to one monitor. An unknown id is a 404.

Returns the last 14 days (plus still-open older incidents), ongoing first, capped at 30 rows.

response
HTTP 200
{"incidents": [
  {"id": 311, "monitor_id": 42, "monitor_name": "prod site",
   "monitor_kind": "http", "kind": "down", "title": "Outage — prod site",
   "custom_title": null, "stage": "identified", "summary": null,
   "started_at": "2026-08-10T04:12:31Z", "resolved_at": null,
   "resolution": null, "resolved_by": null,
   "acked_at": null, "acked_by": null, "ongoing": true}
]}

Every incident carries its title and stage (investigating, identified, monitoring, resolved) exactly as the status page shows them. resolved_by names whoever resolved it by hand; resolution is paused when monitoring stopped mid-outage.

GET /api/v1/incidents/:id bearer

One incident with its evidence. monitor_kind is its monitor's kind, readable from any seat. forensics is the captured evidence snapshot with volatile headers (cookies, auth) stripped — its exact shape follows the capture and is not a stable contract. gap_texts says what the capture deliberately left out, and delivery is the flight log as the incident page prints it — who was alerted or paged, when, and whether it landed, with destinations named by kind only. can_post_updates says whether this token may post to its updates. The full evidence bundle (timeline, before/during/after states, region matrix) downloads from the incident page in the app as Evidence (JSON).

response
HTTP 200
{
  "incident": {
    "id": 311, "monitor_id": 42, "monitor_name": "prod site",
    "monitor_kind": "http", "kind": "down", "title": "Outage — prod site",
    "custom_title": null, "stage": "identified", "summary": null,
    "started_at": "2026-08-10T04:12:31Z", "resolved_at": null,
    "resolution": null, "resolved_by": null,
    "ongoing": true, "acked_at": null, "acked_by": null,
    "regions": ["fra"],
    "forensics": {"last_error": "connect timeout", ...},
    "gap_texts": [],
    "delivery": [
      {"kind": "opened", "at": "2026-08-10T04:12:40Z",
       "label": "incident opened — failure detected, seen from fra",
       "meta": null, "channel": null},
      {"kind": "paged", "at": "2026-08-10T04:12:41Z",
       "label": "telegram page sent", "meta": null, "channel": "telegram"}
    ],
    "runbook": "check the load balancer first",
    "can_post_updates": true
  }
}
POST /api/v1/incidents/:id/ack bearer · writable

Acknowledge the incident — stops re-paging. First ack wins; acknowledging again is a no-op that returns the original ack. Every incident-by-id call finds the incident on any workspace you hold a seat on, so a page opens, acknowledges and gets its update without switching first.

request · response
curl -X POST https://quietbeat.dev/api/v1/incidents/311/ack \
  -H "Authorization: Bearer qb_YOUR_TOKEN"

HTTP 200
{"incident": {"id": 311, "acked_at": "2026-08-10T04:14:02Z",
              "acked_by": "you@example.com"}}
GET /api/v1/incidents/:id/updates bearer

The stage timeline the status page shows, newest first: the opening, every posted update, and the resolution.

request · response
curl https://quietbeat.dev/api/v1/incidents/311/updates \
  -H "Authorization: Bearer qb_YOUR_TOKEN"

HTTP 200
{"updates": [
  {"id": "update-17", "stage": "identified", "label": "Identified",
   "body": "A bad deploy; rolling back.",
   "at": "2026-08-10T04:20:02Z", "automatic": false},
  {"id": "opened", "stage": "investigating", "label": "Investigating",
   "body": "We're investigating a problem with prod site.",
   "at": "2026-08-10T04:12:40Z", "automatic": true}
]}
POST /api/v1/incidents/:id/updates bearer · writable · owner or admin

Parameters

body string required

The note. On a monitor a status page shows, it is published there and emailed to the page's confirmed subscribers.

1–2000 chars

stage string optional

The stage this update moves the incident to; omit to leave it. resolved on an open incident resolves it by hand, even while the check still fails. Once resolved, only resolved or no stage is accepted.

investigating · identified · monitoring · resolved

Members can read and acknowledge; posting is for workspace owners and admins — anyone else gets a 403.

request · response
curl -X POST https://quietbeat.dev/api/v1/incidents/311/updates \
  -H "Authorization: Bearer qb_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"stage": "resolved", "body": "Rolled back; all clear."}'

HTTP 201
{"update": {"id": "update-18", "stage": "resolved", "label": "Resolved",
            "body": "Rolled back; all clear.",
            "at": "2026-08-10T04:31:15Z", "automatic": false},
 "incident": {"id": 311, "stage": "resolved", "ongoing": false,
              "resolved_by": "you@example.com", ...}}

Status pages #

Public status pages at https://quietbeat.dev/s/<slug>, on every plan. Full CRUD: GET · POST /api/v1/status_pages and GET · PATCH · DELETE /api/v1/status_pages/:id.

POST /api/v1/status_pages bearer · writable

Parameters

title string required

The page's public name.

1–120 chars

slug string optional

The public address. Omit it and one is minted for you.

max 60 · lowercase letters, digits, hyphens · globally unique

mode string required

Which monitors the page shows: everything, one group, or a hand-picked set.

all · group · selected

group string required when mode=group

The group to show. Cleared automatically in other modes.

monitor_ids array of integers optional

The picks when mode is selected. IDs you don't own are silently dropped. Omitting the key on PATCH leaves the selection untouched; an explicit null clears it.

description string optional

Shown under the title.

max 500

accent_color string optional

The page's accent.

six-digit hex, like "#0a7f45"

nav_links array of objects optional

Header links back to your own site.

max 5 · {"label" ≤30, "url" full http(s)://}

day_zoom boolean optional

Whether a day in the uptime history expands into its timeline.

default true

Custom domains and page passwords are plan-gated and managed in the app only — they are neither settable nor readable over this API.
request
curl -X POST https://quietbeat.dev/api/v1/status_pages \
  -H "Authorization: Bearer qb_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title": "Acme status", "slug": "acme",
       "mode": "selected", "monitor_ids": [42, 43]}'
response
HTTP 201
{
  "status_page": {
    "id": 5, "slug": "acme", "title": "Acme status",
    "description": null, "mode": "selected", "group": null,
    "day_zoom": true, "accent_color": null,
    "monitor_ids": [42, 43], "nav_links": [],
    "created_at": "2026-08-10T09:31:00Z"
  }
}
# the page is live at https://quietbeat.dev/s/acme

Alert channels #

Where alerts go beyond your account email (email alerting is always on and is not a channel). Full CRUD: GET · POST /api/v1/alert_channels, GET · PATCH · DELETE /api/v1/alert_channels/:id. Alert channels are part of the paid plans; on Hobby, writes return a 422 saying exactly that.

POST /api/v1/alert_channels bearer · writable

Parameters

kind string required

One of the kinds below.

name string required

Your label for the channel.

1–80 chars

config object required · write-only

The kind-specific destination (see table). Encrypted at rest and never returned by any read.

enabled boolean optional

Re-enabling a channel clears disabled_reason — the message the destination gave when it last refused delivery.

default true

kind config keys
slack webhook_url — a Slack incoming webhook (hooks.slack.com)
discord webhook_url — a Discord webhook
telegram chat_id — from the Quietbeat bot
webhook url — your own HTTPS endpoint to POST incident JSON to
mattermost · google_chat · msteams webhook_url — the incoming-webhook URL
pagerduty routing_key — an Events v2 integration key
opsgenie api_key
splunk_hec url + token
pushover app_token + user_key
pushbullet access_token
request
curl -X POST https://quietbeat.dev/api/v1/alert_channels \
  -H "Authorization: Bearer qb_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"kind": "slack", "name": "ops channel",
       "config": {"webhook_url": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"}}'
response
HTTP 201
{"alert_channel": {"id": 7, "kind": "slack", "name": "ops channel",
                   "enabled": true, "disabled_reason": null,
                   "created_at": "2026-08-10T09:32:00Z"}}
# config is accepted on writes and never echoed back

Webhook signing #

A webhook channel can carry a signing secret (Channel → Edit → Signing). With one set, every POST we send includes two headers your receiver can check before trusting the payload:

headers
X-Quietbeat-Timestamp: 1724900000
X-Quietbeat-Signature: v1=6ffab0…

The signature is HMAC-SHA256(secret, "<timestamp>.<raw body>"), hex-encoded, computed over the exact bytes of the request body — verify against the raw body, before any JSON parsing. Reject requests whose timestamp is older than a few minutes (we allow 5), and compare with a constant-time equality check. After a rotation the header carries two v1= values for 24 hours; accept the request if any of them matches your secret.

verify (python)
import hashlib, hmac, time

def verified(body_bytes, timestamp, signature_header, secret):
    if abs(time.time() - int(timestamp)) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body_bytes,
                        hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(part.strip()[3:], expected)
               for part in signature_header.split(",")
               if part.strip().startswith("v1="))

Channels without a secret keep POSTing exactly as before — signing is opt-in per endpoint.

Escalation steps #

An escalation ladder is built one rung at a time; each rung is its own resource. Rungs fire in position order, then delay_minutes, until someone acknowledges. Full CRUD: GET · POST /api/v1/escalation_steps, GET · PATCH · DELETE /api/v1/escalation_steps/:id; GET ?monitor_id= narrows to one ladder. Building rungs is paid-plan (422 on Hobby); deleting is never gated, so a downgraded account can always tear a ladder down.

POST /api/v1/escalation_steps bearer · writable

Parameters

monitor_id integer required

The monitor this rung belongs to.

channel_id integer optional

The alert channel this rung pages. Mutually exclusive with schedule_id; both absent means the rung pages your own account alerts (email, and push on the beta mobile app).

schedule_id integer optional

An on-call schedule — the rung pages whoever currently holds the pager, through what their own paging profile turns on: email, push on the beta mobile app, or a Telegram chat they connected.

delay_minutes integer optional

Wait this long after the incident opens before firing.

0–1440 · default 0

position integer optional

Rung order.

default 0

request · response
curl -X POST https://quietbeat.dev/api/v1/escalation_steps \
  -H "Authorization: Bearer qb_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"monitor_id": 42, "channel_id": 7,
       "delay_minutes": 5, "position": 1}'

HTTP 201
{"escalation_step": {"id": 12, "monitor_id": 42, "channel_id": 7,
                     "schedule_id": null, "delay_minutes": 5,
                     "position": 1, "created_at": "2026-08-10T09:33:00Z"}}

On-call schedules #

Read-only: GET /api/v1/on_call_schedules (optional exact ?name= filter) and GET /api/v1/on_call_schedules/:id. A rota is people and turn order — edited where the people are, in the app. The response resolves the current pager-holder for you; a null holder is deliberate (a mute window or an unstaffed rota). holder_until is when that answer next changes, in UTC: a mute window's end while one holds the pager, and null only for an unstaffed or parked rota.

response
HTTP 200
{
  "on_call_schedule": {
    "id": 3, "name": "primary", "time_zone": "Europe/Bratislava",
    "enabled": true,
    "rotation": {"kind": "weekly", "handoff_time": "09:00:00",
                 "anchor_date": "2026-01-05", "position": 0,
                 "member_ids": [1, 8]},
    "overrides": [],
    "current_user_id": 8,
    "current_user_email": "teammate@example.com",
    "holder_until": "2026-08-17T07:00:00Z"
  }
}

Maintenance windows #

A window silences alerts while it is active — checks keep running and incidents still open, so the history stays honest. Endpoints: GET /api/v1/maintenance (response key windows), POST /api/v1/maintenance, DELETE /api/v1/maintenance/:id (204 on cancel; an id that no longer exists is a 404, like every other resource).

A monitor says whether it is silenced right now: maintenance_ends_at is when the quiet over it lifts, running on through any window that starts before or as the quiet so far ends (maintenance_ends_at_capped is true when that quiet still runs 7 days out, so the time is a horizon, not an end), and maintenance_window_ids names every active window holding it — its own and account-wide ones. The siren comes back only once each of them is cancelled or over. maintenance_next_starts_at is when the next window over it begins within the next 7 days, or null when none does.

Each window answers with next_starts_at: when it next begins, worked out in its timezone so a clock change keeps its wall-clock time, or null for a one-off that has already begun.

POST /api/v1/maintenance bearer · writable

Parameters

starts_at string (ISO 8601, UTC) required

When the window opens.

duration_minutes integer required

How long it stays open.

1–1440

monitor_id integer optional

One monitor, or null for the whole account.

recurrence string optional

Repeat at the same wall-clock time, across clock changes.

none (default) · daily · weekly

timezone string (IANA zone) optional

The zone a recurring window repeats in. Defaults to your own; every window answers with the zone it repeats in.

request · response
curl -X POST https://quietbeat.dev/api/v1/maintenance \
  -H "Authorization: Bearer qb_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"monitor_id": 42, "starts_at": "2026-08-16T22:00:00Z",
       "duration_minutes": 90, "recurrence": "weekly",
       "timezone": "Europe/Bratislava"}'

HTTP 201
{"window": {"id": 4, "monitor_id": 42,
            "starts_at": "2026-08-16T22:00:00Z",
            "duration_minutes": 90, "recurrence": "weekly",
            "timezone": "Europe/Bratislava",
            "next_starts_at": "2026-08-16T22:00:00Z"}}

Deploys #

A deploy is the most common cause of an outage and the one thing a monitor cannot see for itself. Post one at the end of a deploy job and an incident stops reading "the API went down at 14:32" and starts reading "the API went down 41 seconds after a3f19c2 shipped".

POST /api/v1/deploys bearer · writable

Parameters

ref string required

A SHA, a tag, a build number — whatever names the deploy.

1–200 chars

component string optional

What shipped, e.g. api.

max 100

environment string optional

Where it shipped.

max 50

description string optional

Free-form note. Echoed by the list endpoint (not by create).

max 2000

deployed_at string (ISO 8601) optional

Send it when CI reports after the fact, or the deploy lands after the outage it caused and correlates with nothing.

default now · at most 1 h in the future, 365 d in the past

GET /api/v1/deploys?limit= lists them back, newest first (default 100, max 500). A deploy's validation failure is spelled out whole in error.message, e.g. "ref: can't be blank" — this endpoint is built for CI one-liners — with the usual per-field error.details beside it.

request · response
# the last step of a deploy job (GitHub Actions shown)
curl -sf -X POST https://quietbeat.dev/api/v1/deploys \
  -H "Authorization: Bearer $QUIETBEAT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ref": "'"$GITHUB_SHA"'", "component": "api"}'

HTTP 201
{"deploy": {"id": 90, "ref": "a3f19c2", "component": "api",
            "environment": null, "deployed_at": "2026-08-10T09:34:00Z"}}

Workspaces & account #

GET /api/v1/workspaces bearer

The workspaces your token can act on — your own account first, then every team you hold a seat on. current reflects the x-quietbeat-workspace header in effect for this request.

response
HTTP 200
{"workspaces": [
  {"id": 1, "name": "You", "email": "you@example.com", "own": true, "current": true},
  {"id": 8, "name": "Acme", "email": "team-owner@example.com", "own": false, "current": false}
]}
GET /api/v1/status bearer

The one-call account summary: status counts, per-monitor 30-day uptime, and a sentence a human (or an agent) can read aloud.

response
HTTP 200
{
  "counts": {"up": 5, "down": 1},
  "monitors": [
    {"id": 42, "name": "prod site", "status": "up",
     "uptime_30d": {"percent": 99.98, "observed_minutes": 43200}}
  ],
  "summary": "1 down: checkout."
}

Devices & pairing #

The endpoints the Quietbeat mobile app (beta) signs in and registers push with. Documented for completeness — you only need them if you are building a client.

POST /api/v1/pair no bearer · 10 req / 10 min per IP

Exchanges a single-use pairing code (qbp-…, minted in the app) — or a pasted existing qb_… token — for a fresh device token on the same account. A device token minted from a pasted token inherits its read-only flag and is revoked along with the token it came from. Unknown, used, and expired codes are one indistinguishable 422.

request · response
curl -X POST https://quietbeat.dev/api/v1/pair \
  -H "Content-Type: application/json" \
  -d '{"code": "qbp-XXXX-XXXX", "device_name": "iPhone"}'

HTTP 200
{"token": "qb_YOUR_NEW_DEVICE_TOKEN", "email": "you@example.com", "user_id": 42,
 "read_only": false}
POST DELETE /api/v1/devices bearer · writable

Registers (or removes) a push device for the beta mobile app: fcm_token (required, 8–512 chars), platform (android default, or ios), optional name. Registration upserts on fcm_token; DELETE with the token always answers 204.

Monitoring as code #

POST /api/v1/sync bearer · writable

Declare your monitors in a manifest and sync it from CI. Monitors are keyed by name: the sync creates what is missing, updates what drifted, leaves the rest alone, and — only with prune: true — deletes monitors the manifest no longer names. Event-source, certificate and browser monitors are never pruned: a manifest cannot describe them. Send YAML with content-type text/yaml (up to 512 KB) or the same structure as JSON. ?dry_run=true plans without writing.

manifest
# quietbeat.yml
monitors:
  - name: prod site
    kind: http
    url: https://example.com/health
    interval_seconds: 300
  - name: nightly backup
    kind: heartbeat
    interval_seconds: 86400
    grace_seconds: 3600
prune: true
request
curl -sf -X POST "https://quietbeat.dev/api/v1/sync?dry_run=true" \
  -H "Authorization: Bearer qb_YOUR_TOKEN" \
  -H "Content-Type: text/yaml" \
  --data-binary @quietbeat.yml
response
HTTP 200
{"created": ["prod site"], "updated": [], "unchanged": ["nightly backup"],
 "deleted": [], "errors": {}, "dry_run": true}

# entries that fail validation come back under "errors" with HTTP 422 —
# the valid entries still apply (unless this was a dry run)

Manifest entries take the monitor create fields for an http, heartbeat, tcp or dns monitor — not group, relays, certificates or browser flows; any other key is ignored. A response with a non-empty errors map comes back as 422: the result object as always, with the error envelope (validation_failed, details equal to errors) beside its keys. A name listed more than once is one of those errors: the name is the monitor's identity, so none of its entries is applied, and the rest of the manifest still syncs.

Import #

POST /api/v1/import/uptimerobot bearer · writable

One-shot import from UptimeRobot using a read-only UptimeRobot API key. HTTP and keyword monitors convert to http, port monitors to tcp, heartbeats to heartbeat (you re-point your cron at the new ping URL); ping (ICMP) monitors are skipped. Existing http, heartbeat, tcp and dns monitors are matched by name and never pruned; one that shares a name with an event-source, certificate or browser monitor is imported beside it. Every lossy conversion is spelled out in notes. Other services import in the app under Import.

request · response
curl -X POST https://quietbeat.dev/api/v1/import/uptimerobot \
  -H "Authorization: Bearer qb_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"api_key": "ur_YOUR_READONLY_KEY", "dry_run": true}'

HTTP 200
{"created": ["prod site", "worker heartbeat"], "updated": [],
 "unchanged": [], "deleted": [], "errors": {}, "dry_run": true,
 "notes": ["skipped 1 ping monitor — Quietbeat does not run ICMP"], ...}

Relays #

Private relays check from inside your own network — a ~60-line outbound-only shell script (curl + jq) you run anywhere. Paid plans. Create one under Fleet, run the script with its qbr_… relay token, and pin monitors to it via relay_id. The pull protocol is two endpoints — GET /api/v1/relay/due and POST /api/v1/relay/results, bearer-authed with the relay token — so a custom client is an afternoon in any language; the full schema is in the OpenAPI document.

Heartbeats & evidence #

A heartbeat monitor watches something that must happen — a cron job, a backup, a queue worker. Your job pings a URL; silence past the expected time (plus grace_seconds) opens a missed incident. The ping token identifies one monitor and nothing else — it is not your API token, and the ping endpoints take no auth header, so they stay one curl in a crontab.

Endpoint What it does
GET · POST · HEAD /b/:token The success ping. A POST body (≤ 10 KB) is stored as the beat's evidence.
GET · POST /b/:token/start Marks a run's start; the next finish (or fail) ping records the run's duration. A start is not a success: a run that never finishes is reported missed when its window closes, or sooner once it runs past max_duration_ms plus the grace.
GET · POST /b/:token/fail The job reports its own failure — opens a failed incident immediately instead of waiting out the grace window.

Query parameters

run string optional

Your run id, on any ping. With run ids, start/finish pairs match exactly even when runs overlap.

max 64 chars

exit_code integer optional · /fail only

The shell's $?. Out-of-range values are dropped, not errored.

0–255

runtime_ms integer optional · /fail only

The run's own duration measurement — trusted over ours when present.

message string optional · /fail only

Used as the evidence payload only when the request body is empty.

Wiring a job #

successful job
# a cron job that reports in — GET or POST, body optional
0 4 * * *  backup.sh && curl -fsS https://quietbeat.dev/b/YOUR_PING_TOKEN
failed job, exit code
# report the failure actively instead of waiting out the grace window
if backup.sh; then rc=0; else rc=$?; fi
if [ "$rc" -ne 0 ]; then
  curl -fsS "https://quietbeat.dev/b/YOUR_PING_TOKEN/fail?exit_code=$rc" || true
fi
timed run with diagnostic tail
# timed run with a sanitized diagnostic tail on both outcomes; exits with the job's own status
curl -fsS https://quietbeat.dev/b/YOUR_PING_TOKEN/start || true
if backup.sh > /tmp/backup.log 2>&1; then rc=0; else rc=$?; fi
if [ "$rc" -eq 0 ]; then
  curl -fsS https://quietbeat.dev/b/YOUR_PING_TOKEN --data-raw "$(tail -20 /tmp/backup.log)" || true
else
  curl -fsS "https://quietbeat.dev/b/YOUR_PING_TOKEN/fail?exit_code=$rc" \
    --data-raw "$(tail -20 /tmp/backup.log)" || true
fi
exit "$rc"
responses
HTTP 200  ok                          # accepted (also while paused)
HTTP 404  unknown token               # no monitor owns this token
HTTP 429  rate limited — slow down    # over 120 requests/min for this token

# all three are text/plain, sized for a shell script to read

What Quietbeat stores and shows #

The POST body is stored with the beat, capped at 10 KB (a truncation marker is appended past the cap). It appears on the monitor's Latest beats panel, is quoted in missed alerts, and a capped copy rides in the incident's evidence. When any incident opens, evidence is captured automatically — for heartbeats that is the last payload, last and average run duration, and the last-seen time; for HTTP monitors a response snapshot, DNS, TLS expiry, and the response-time run-up. Optional beat rules (max_duration_ms, payload_pattern, payload_expect) send notifications without opening incidents — a slow backup is not downtime.

Sanitize what you send. The ping body is stored and shown wherever the incident is — alerts, the incident page, exports. Send a filtered tail, never a full log, and never secrets: strip tokens, connection strings, and personal data before they leave the job.

Event intake #

POST /api/v1/events/:token source token in URL

Route alerts from systems that already know when they are unhealthy — Alertmanager, Grafana, Sentry, or anything that can POST JSON — into Quietbeat incidents, so escalation, ack, and status pages apply to them too. Create a source under Alerting → Sources; the source's kind picks the payload format (never sniffed) and its token goes in the URL you paste into the sending system.

Source kinds

alertmanager Alertmanager v4 webhook

firing opens, resolved closes, deduplicated by fingerprint.

grafana Grafana alerting webhook

Unified alerting and legacy payloads both accepted.

sentry Sentry issue and issue-alert webhooks

Issue created/unresolved and an issue alert firing open the issue; resolved, ignored, archived close it.

generic your own JSON

action (trigger or resolve) plus a dedup_key; optional summary.

Each trigger opens (at most) one incident per dedup_key; resolve closes it. Up to 50 events per POST are taken; keys and summaries are truncated at 255 characters. Accepted events answer 202 with received, also while you have paused the source's monitor: then a trigger opens nothing and pages no one, and a resolve still closes its key quietly. An unknown or disabled source token, or one your plan no longer covers, is a 404 not_found in the error envelope; a payload the source's kind cannot parse is a 400 bad_request.

request · response
# source kind "generic"
curl -X POST https://quietbeat.dev/api/v1/events/YOUR_SOURCE_TOKEN \
  -H "Content-Type: application/json" \
  -d '{"action": "trigger", "dedup_key": "db-primary-disk",
       "summary": "disk at 92% on db-primary"}'

HTTP 202
{"received": 1}

# later, from the same system:
#   {"action": "resolve", "dedup_key": "db-primary-disk"}
# closes the incident that key opened

Status page API & badges #

Every public status page also answers as JSON, in the Statuspage-compatible /api/v2 envelope — a client that already reads one can read yours on day one. Quietbeat-native readings ride under a quietbeat key. No auth; 60 requests/min per IP; responses cache for 60 seconds. A password-locked page answers 401 (plain text) on these paths; an unknown or empty page is a 404.

Endpoint Returns
GET /s/:slug/api/v2/status.json page + overall status indicator
GET /s/:slug/api/v2/summary.json page, status, components, unresolved incidents
GET /s/:slug/api/v2/incidents/unresolved.json open incidents with their updates, newest first, each carrying its stage — investigating, identified, monitoring
GET /s/:slug/api/v2/components.json one component per monitor, in page order
request · response
curl https://quietbeat.dev/s/acme/api/v2/status.json

HTTP 200
{
  "page": {"id": "5", "name": "Acme status",
           "url": "https://quietbeat.dev/s/acme", "time_zone": "Etc/UTC",
           "updated_at": "2026-08-10T09:40:00Z"},
  "status": {"indicator": "none",
             "description": "All Systems Operational"}
}

Badges are SVGs on the same slug: /s/<slug>/badge.svg for the page and /s/<slug>/<monitor_id>/badge.svg per monitor — paste them into a README.

Every incident has its own public timeline at /s/<slug>/incidents/<id> — the shortlink in the JSON, and the link in every subscriber email.

MCP for agents #

Prefer talking to it? POST /mcp is a first-party MCP server on the same bearer token — JSON-RPC 2.0 over streamable HTTP, protocol version 2025-06-18. Write-capable from day one, so the agent that just wrote your cron job can wire its own dead-man switch without leaving the terminal.

connect · claude code
claude mcp add --transport http quietbeat https://quietbeat.dev/mcp --header "Authorization: Bearer qb_YOUR_TOKEN"
connect · any client that reads a config file
{
  "mcpServers": {
    "quietbeat": {
      "type": "http",
      "url": "https://quietbeat.dev/mcp",
      "headers": { "Authorization": "Bearer ${QUIETBEAT_TOKEN}" }
    }
  }
}

read: get_status_summary · list_monitors · get_monitor · get_incident_evidence · list_incidents · get_diagnosis

write: trigger_check · ack_incident · post_incident_update · create_monitor · pause_monitor · resume_monitor · delete_monitor

The read tools hand back the same record the dashboard draws — uptime over an honest observed span, response-time stats, and an incident's evidence: the capture from the moment of failure, the runbook, the last beats, and the AI hypothesis if one was made. A read-only token never sees the write half at all: it is withheld from tools/list. Client-by-client setup lives on the MCP page.

Errors #

API errors are JSON with a single error object: error.message for people, error.code for programs, and per-field error.details (a map of field → list of messages) on validation failures. Match on code, never on the message, which may be reworded; treat a code you do not know like a missing one and fall back to the status.

validation error
HTTP 422
{
  "error": {
    "message": "validation failed",
    "code": "validation_failed",
    "details": {
      "url": ["an HTTP monitor needs a URL to check"],
      "interval_seconds": ["between 60 seconds and 24 hours on your plan"]
    }
  }
}
Status When
400 A malformed request outside validation — a body that is not valid JSON, a bad window value, a missing required top-level field, an unparsable sync manifest.
401 Missing, malformed, or revoked token. Carries www-authenticate: Bearer.
403 A read-only token on any write (read_only_token), or a seat whose role may not take the action (forbidden) — including a seat removed, or an admin demoted, while the write was in flight: it is refused and nothing changes.
404 A resource that does not exist or is not yours — deliberately the same answer (not_found). A malformed id (e.g. /monitors/abc) is also a 404, not a 400. An x-quietbeat-workspace header naming a desk you no longer sit at is workspace_not_found.
422 Validation failed (validation_failed, with details), or a plan gate (e.g. monitor_suspended when resuming a plan-suspended monitor).
429 Over a rate limit (rate_limited) — see below.

Every error under /api and from /mcp uses this envelope whatever Accept header you send — including a body that is not valid JSON (400), a path no endpoint answers (404) and an Accept that rules out JSON (406). Two deliberate deviations, so your error handling does not meet them as surprises: sync and import keep their result object's keys next to the envelope when entries fail; the status page API uses a flat error string.

Retrying a write safely #

A create that timed out may or may not have landed. Send an Idempotency-Key header (16–100 characters of A–Z a–z 0–9 _ -) on POST to monitors, status pages, alert channels, escalation steps, deploys, maintenance windows and incident updates, and reuse it for every retry of the same content. A retry with the same key, body and query string answers what the first request answered, with idempotent-replayed: true, and does nothing else — unless that answer was a 401, 403, 429 or a 5xx, which is not kept, so the retry runs again if the first request changed nothing. The same key with a different body or query string is a 422 idempotency_key_reused; the same key after its day is a 422 idempotency_key_expired — the first request may have made its change, so check before sending it again (the key is remembered for a week after that, then free); the same key while the first request is still running is a 409 idempotency_in_progress — wait and retry. A 503 storage_unavailable means the first request made its change but its answer cannot be read back: fetch the result rather than retrying. Keys are per token, method and path, and are kept for 24 hours. Without the header every request runs.

Rate limits #

Fixed one-minute windows unless stated. Every 429 under /api is JSON, in the error envelope; the heartbeat inbox (/b/…) answers plain text, sized for a shell script. No Retry-After header is sent — on a 429, back off and retry after the window. These counters live in per-node ETS. A multi-node deployment must enforce a shared edge/distributed limiter; adding nodes does not turn the figures below into a cluster-wide promise.

Surface Limit Keyed on
/api/v1 + /mcp 240 req/min authenticated token record; plus a coarse 1,200 req/min trusted-IP pre-auth ceiling
/b/:token pings 120 req/min ping token
/api/v1/events/:token 120 req/min source token
/api/v1/pair 10 req / 10 min IP
status pages + /api/v2 JSON 60 req/min IP
These are the current operating limits, not a contractual guarantee — during the beta they may be tuned. Build clients to treat 429 as a signal, not a surprise.

Pagination & limits #

There is no cursor or offset pagination. Configuration resources (monitors, status pages, channels, steps, schedules) return the complete set — they are sized to be listed whole. Telemetry endpoints are bounded instead:

Endpoint Bound
GET /api/v1/incidents last 14 days + still-open older ones, max 30 rows, ongoing first
GET /api/v1/deploys limit — default 100, max 500, newest first
GET /api/v1/monitors/:id/checks limit — default 60, max 200, oldest first
GET /api/v1/monitors/:id/beats limit — default 10, max 50, newest first

For dashboards, prefer /api/v1/status and /monitors/:id/stats over scraping raw checks — they are one call and already aggregated.