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:
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.
# 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.
# 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:
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:
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:
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.
{
"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 #
/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
Human-readable monitor name, 1–120 characters. Also the identity key for sync.
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
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
Extra allowance past the expected time before a heartbeat counts as missed.
default 300 · 0–86400
Free-form grouping label, up to 60 characters; blank clears it.
Your "what to do when this breaks" note. Ships with every alert, webhook payload, and agent call.
Response time above which the monitor reads as degraded (amber, not down).
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
Re-page cadence while an incident stays unacknowledged.
5–60
Upstream vendors this monitor depends on (e.g. stripe),
used for incident correlation. Unknown slugs are rejected.
max 20 · catalog slugs
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)
Full http(s):// address to probe. Private and
link-local hosts are refused.
Probe method. HEAD brings back no body, so body rules
(keyword, json_asserts) are rejected with
it.
get (default) · post · put · patch · head
Comma-separated status codes and ranges that count as alive. Absent: anything below 400 is alive.
e.g. "200" · "200-299,301"
The response body must contain this string.
The response body must not contain this string.
Headers to send with the probe, e.g. an auth header. Encrypted, never returned by any read.
max 5 headers
Body for POST/PUT/PATCH probes. Encrypted, never returned.
max 10 KB
Assertions on response headers.
max 5 · {"name","op","value"} · ops: exists · equals · contains
Assertions on the JSON response body.
max 5 · {"path","op","value"} · ops: exists · equals · contains · gt · lt
Heartbeats (kind: heartbeat)
Expect pings on this cron schedule instead of a fixed interval.
five fields, like "0 3 * * *"
The wall clock the cron schedule runs on. Only valid together with cron_schedule.
IANA zone · default UTC
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.
Alert when a ping's payload contains this (case-insensitive) — e.g. ERROR.
max 200 chars
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)
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
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
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.
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.
An absolute path on the machine the relay runs on. The relay refuses the file outright if it holds a private key.
900 or more — a certificate is read every fifteen minutes at most.
Days before expiry that turn the certificate's health to warning. Default 30.
Days before expiry that turn it critical. Default 7.
A hostname the certificate must cover, for sources with no host of their own.
TCP and DNS (kind: tcp · dns)
For TCP: host:port (port 1–65535). For DNS: a public
hostname — private suffixes like .internal are refused.
Record type to resolve.
A · AAAA · CNAME · MX · TXT · NS
A value the answer set must contain.
Browser & Lighthouse (not available yet — execution unavailable)
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
Reserved plain-language instruction field; it is not executed.
10–2000 chars
Reserved script field. Treated as a credential — never returned or executed.
max 64 KB
Reserved Lighthouse field for HTTP monitors; audits are unavailable.
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:
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"}'
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 #
/api/v1/monitors/:id/stats
bearer
Query parameters
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
curl "https://quietbeat.dev/api/v1/monitors/42/stats?window=24h" \
-H "Authorization: Bearer qb_YOUR_TOKEN"
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 #
/api/v1/monitors/:id/checks
bearer
Query parameters
Most recent N probe results, returned oldest-first.
default 60 · max 200
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"}
]}
/api/v1/monitors/:id/beats
bearer
Query parameters
Most recent heartbeat pings, newest-first. payload is
the stored evidence body, capped at 10 KB.
default 10 · max 50
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.
/api/v1/incidents
bearer
Query parameters
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.
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.
/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).
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
}
}
/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.
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"}}
/api/v1/incidents/:id/updates
bearer
The stage timeline the status page shows, newest first: the opening, every posted update, and the resolution.
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}
]}
/api/v1/incidents/:id/updates
bearer · writable · owner or admin
Parameters
The note. On a monitor a status page shows, it is published there and emailed to the page's confirmed subscribers.
1–2000 chars
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.
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.
/api/v1/status_pages
bearer · writable
Parameters
The page's public name.
1–120 chars
The public address. Omit it and one is minted for you.
max 60 · lowercase letters, digits, hyphens · globally unique
Which monitors the page shows: everything, one group, or a hand-picked set.
all · group · selected
The group to show. Cleared automatically in other modes.
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.
Shown under the title.
max 500
The page's accent.
six-digit hex, like "#0a7f45"
Header links back to your own site.
max 5 · {"label" ≤30, "url" full http(s)://}
Whether a day in the uptime history expands into its timeline.
default true
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]}'
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.
/api/v1/alert_channels
bearer · writable
Parameters
One of the kinds below.
Your label for the channel.
1–80 chars
The kind-specific destination (see table). Encrypted at rest and never returned by any read.
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 |
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"}}'
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:
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.
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.
/api/v1/escalation_steps
bearer · writable
Parameters
The monitor this rung belongs to.
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).
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.
Wait this long after the incident opens before firing.
0–1440 · default 0
Rung order.
default 0
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.
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.
/api/v1/maintenance
bearer · writable
Parameters
When the window opens.
How long it stays open.
1–1440
One monitor, or null for the whole account.
Repeat at the same wall-clock time, across clock changes.
none (default) · daily · weekly
The zone a recurring window repeats in. Defaults to your own; every window answers with the zone it repeats in.
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".
/api/v1/deploys
bearer · writable
Parameters
A SHA, a tag, a build number — whatever names the deploy.
1–200 chars
What shipped, e.g. api.
max 100
Where it shipped.
max 50
Free-form note. Echoed by the list endpoint (not by create).
max 2000
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.
# 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 #
/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.
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}
]}
/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.
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.
/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.
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}
/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 #
/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.
# 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
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
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 #
/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.
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
Your run id, on any ping. With run ids, start/finish pairs match exactly even when runs overlap.
max 64 chars
The shell's $?. Out-of-range values are dropped, not
errored.
0–255
The run's own duration measurement — trusted over ours when present.
Used as the evidence payload only when the request body is empty.
Wiring a 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
# 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 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"
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.
Event intake #
/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
firing opens, resolved closes, deduplicated
by fingerprint.
Unified alerting and legacy payloads both accepted.
Issue created/unresolved and an issue alert firing open the issue; resolved, ignored, archived close it.
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.
# 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 |
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.
claude mcp add --transport http quietbeat https://quietbeat.dev/mcp --header "Authorization: Bearer qb_YOUR_TOKEN"
{
"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.
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 |
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.