The spec is the SDK.
Pure HTTPS. Bearer token. JSON in, JSON out. No client library to keep in step with our release cadence, no version skew, no four-step OAuth dance to copy off Stack Overflow at 2 a.m.
TL;DR
Pure HTTPS. Bearer token. JSON in, JSON out. No client library to keep in step with our release cadence, no version skew, no four-step OAuth dance to copy off Stack Overflow at 2 a.m.
The spec is the SDK.
Pure HTTPS. Bearer token. JSON in, JSON out. No client library to keep in step with our release cadence, no version skew, no four-step OAuth dance to copy off Stack Overflow at 2 a.m.
Everything in the dashboard maps to one HTTP call you can make from curl,
an LLM tool call, a shell script, or a cron job. PAT scopes for least-privilege,
daily caps for agent blast-radius, Idempotency-Key for safe retries,
and per-PAT attribution in the audit log so a human can always answer "what did the
agent actually do?"
Base URL
https://api.24observe.com
All endpoints are under /api/v1/. Self-hosters substitute their own host.
Machine-readable spec
Interactive reference at /docs/openapi — every endpoint with schemas + try-it-now, rendered live from the same OpenAPI spec the API publishes. Always in sync with what's deployed.
openapi.json
— raw spec for generating a typed client in any language. Response shapes are declared
for the core agent-facing endpoints (PAT mint, monitor CRUD, monitor
secrets) so generators like openapi-typescript produce
real types, not any. Browsable Swagger UI at
api.24observe.com/docs.
Authentication
Every request carries a bearer token in the Authorization header:
Authorization: Bearer obs_AbCdEf...
Tokens are personal access tokens (PATs): prefix obs_
followed by 43 url-safe base64 chars (256 bits of entropy). Stored as
SHA-256 hash; the plaintext is returned only once at creation.
Mint a PAT (with optional scopes + daily caps)
# Minimal — wildcard scope, no caps (backward-compat default).
curl -X POST https://api.24observe.com/api/v1/me/tokens \
-H 'Authorization: Bearer obs_existing...' \
-H 'Content-Type: application/json' \
-d '{"name":"aeoniti-bot","expiresAt":"2027-01-01T00:00:00Z"}'
# Narrowly scoped + capped — recommended for agents.
curl -X POST https://api.24observe.com/api/v1/me/tokens \
-H 'Authorization: Bearer obs_existing...' \
-H 'Content-Type: application/json' \
-d '{
"name": "aeoniti-bot",
"scopes": ["monitors:write", "logs:write"],
"dailyMutationLimit": 500,
"dailyLogBytesLimit": 104857600
}'
# Response (201)
{
"id": 42,
"name": "aeoniti-bot",
"prefix": "obs_AbCdEfGh",
"token": "obs_AbCdEfGh...<43 chars total>",
"expiresAt": "2027-01-01T00:00:00Z",
"createdAt": "2026-05-13T18:00:00Z"
}
# List your tokens (no plaintext returned; includes scopes + caps)
curl https://api.24observe.com/api/v1/me/tokens -H 'Authorization: Bearer obs_...'
# Revoke (soft-delete; auth fails immediately for that token)
curl -X DELETE https://api.24observe.com/api/v1/me/tokens/42 \
-H 'Authorization: Bearer obs_...' PAT scopes
Scopes are an additional narrowing on top of the role check — the
request must pass both (role allows it AND scope allows it).
Default scopes: ["*"] = wildcard, inherits the role fully
(backward-compat). Resource wildcards work too: monitors:*
grants monitors:read + monitors:write.
The 24 valid narrow scopes (plus the wildcard):
*— wildcard (full role inheritance)monitors:read— list / read monitors (public projection)monitors:write— create / update / delete monitors, test alertssecrets:read— gates/monitors/:id/secrets(alert URLs, heartbeat token)incidents:read— list / read incidentsincidents:write— acknowledge / resolve / post updates / write postmortemsstatus-pages:read— list / read status pages + componentsstatus-pages:write— create / update / delete status pages, components, subscriberslogs:read— search + live-tail log eventslogs:write— ingest log events (HTTP + OTLP)metrics:read— query OTLP metric names + bucketed time seriesmetrics:write— ingest OTLP metricsaudit:read— read the org audit logtokens:write— mint additional PATs (for agent-bootstraps-agent flows)maintenance:read— list maintenance windowsmaintenance:write— schedule / cancel maintenance windowswebhooks:read— list event-webhook subscriptions + their delivery historywebhooks:write— create / update / delete event-webhook subscriptionssaved-searches:read— list saved log searchessaved-searches:write— create / update / delete saved searcheson-call:read— read on-call schedules + rotationson-call:write— manage on-call schedules + rotationsescalations:read— read escalation policiesescalations:write— manage escalation policiescontext:read— read the operational context graph (entities, neighborhoods, incident blast-radius)
A request that hits a route requiring a scope the PAT doesn't have
returns 403 with code: PAT_SCOPE_INSUFFICIENT.
Stable error codes (the contract)
Every 4xx response carries a stable code string your agent can
branch on. The message is for humans; the code is the API surface.
PAT_SCOPE_INSUFFICIENT— token doesn't have the required scope. Don't retry; mint a new one with the right scope.PAT_DAILY_LIMIT_EXCEEDED— per-token daily cap exhausted. Wait untilX-PAT-Mut-Reset(unix seconds) or page a human. Response carriesRetry-After.PLAN_LIMIT_LOGS_VOLUME— org's plan-level monthly log byte cap exhausted. Upgrade or stop ingesting until next billing period.CH_QUOTA_EXCEEDED— search or pattern query exceeded your plan's per-query CH memory or execution-time cap. Plan-tier guardrail; narrow the window, add filters, or upgrade.PLAN_LIMIT_MONITORS/PLAN_INTERVAL_TOO_LOW— plan-tier guardrails on monitor count / minimum interval.IDEMPOTENCY_KEY_REPLAY_CONFLICT— same key + different body. Your retry loop has a bug.IDEMPOTENCY_IN_PROGRESS— a request with this key is still running; back off and try again in a few seconds.WEBHOOK_URL_UNSAFE— the URL you tried to register resolves to a private / loopback / link-local / metadata address.WEBHOOK_URL_UNRESOLVABLE— the URL's hostname doesn't resolve (DNS NXDOMAIN, timeout, etc.). Distinct from SSRF — might come back later.MONITOR_TARGET_UNSAFE— same SSRF guard, applied to monitor target URLs at write time.REGISTRATION_BLOCKED— account-creation guardrail (rate limit + duplicate-email).BAD_JSON— the request body was not valid JSON. Returned in place of the raw parser message so no internal detail leaks.
Codes are stable across deploys: we will never repurpose an existing
code, only add new ones. An agent that branches on
PAT_DAILY_LIMIT_EXCEEDED today will still match it next year.
Per-PAT daily caps
Optional fields on PAT creation; null / omitted = no cap.
dailyMutationLimit(int) — max POST / PATCH / DELETE / PUT calls per UTC day. Counter resets at midnight UTC. Hitting the cap returns429withcode: PAT_DAILY_LIMIT_EXCEEDED. The counter only bumps on 2xx responses, so failed validation doesn't burn budget.dailyLogBytesLimit(int, bytes) — max log ingest volume per UTC day for this PAT. Same 429 + code. Slight over-shoot tolerated (bumped after a successful ingest; the over-shoot is bounded by one request's worth of bytes ≤ 1 MB).
Roles (still apply)
- owner / admin — every endpoint, including secret-bearing reads
- member — create / edit monitors + status pages, view incidents
- viewer — read-only
A PAT inherits the role its owner has in the org. If the owner is removed from the org, the PAT stops working immediately — no separate revoke needed.
Idempotency-Key (safe retries)
Pass a client-generated UUID via the Idempotency-Key header
on any mutating request (POST / PATCH / DELETE / PUT). Server caches the
response keyed by (PAT, key) for 24h — retries return the
same response without re-executing. Standard Stripe-style semantics.
KEY=$(uuidgen)
curl -X POST https://api.24observe.com/api/v1/monitors \
-H 'Authorization: Bearer obs_...' \
-H "Idempotency-Key: $KEY" \
-H 'Content-Type: application/json' \
-d '{...}'
# On replay, response header: Idempotent-Replayed: true
Two concurrent requests with the same key get 409 with
code: IDEMPOTENCY_IN_PROGRESS — back off + retry. Keys
capped at 255 chars (IDEMPOTENCY_KEY_TOO_LONG on overflow).
5xx responses are NOT cached — retries genuinely re-run.
Rate limits
- Global: 100 requests / minute / IP across the API.
- Logs ingest: 300 / minute / IP (separate bucket).
- Logs search: 60 / minute / IP.
- Logs tail (SSE): 30 / minute / IP.
- Heartbeat receive (
/heartbeats/:token): 60 / minute / IP. - Status subscribe (
/status-pages/public/:slug/subscribe): 5 / 10 min / IP. - Hitting any limit returns
429withRetry-After.
Rate-limit counters are shared across API instances (not per-machine). Plus per-org plan caps (monitors, monthly checks, monthly log bytes) and per-PAT daily caps (see above).
Error envelope
All non-2xx responses follow this shape:
{
"error": "human-readable message",
"code": "MACHINE_READABLE_CODE", // optional but present on most 4xx
"fields": [ // present when code = VALIDATION_FAILED
{
"path": "intervalSec",
"code": "invalid_literal",
"message": "Expected one of 30, 60, 300, 900, 1800, 3600"
}
]
} Codes worth handling explicitly:
VALIDATION_FAILED(400) — request body or query failed schema validation. Inspectfields[]for the specific paths.PAT_SCOPE_INSUFFICIENT(403) — your PAT'sscopesdon't allow this route.PAT_DAILY_LIMIT_EXCEEDED(429) — daily mutation or log-bytes cap reached for this PAT. Resets UTC midnight.IDEMPOTENCY_IN_PROGRESS(409) — another request with the same key is in flight. Back off + retry.IDEMPOTENCY_KEY_TOO_LONG(400) — key > 255 chars.PLAN_LIMIT_MONITORS(429) — over your plan's monitor count cap.PLAN_INTERVAL_TOO_LOW(400) — interval below your plan's minimum.MONITOR_NAME_DUPLICATE(409) — a monitor with that exact name already exists in your org; pick a distinct name.PLAN_LIMIT_LOGS_VOLUME(429) — org over monthly log byte cap.CH_QUOTA_EXCEEDED(429) — your search or pattern query exceeded the per-plan query memory or execution-time cap. Free: 256 MiB / 3 s. Startup: 1 GiB / 10 s. Pro: 2 GiB / 30 s. Narrow the time range, add a service/level filter, or upgrade the plan.LOG_ENTROPY_REJECTED(202 withrejected[]) — single event exceeded the 5.7 bits/char entropy threshold (looks like base64, encrypted, or packed binary). Re-encode as structured JSON or ship to object storage instead.QUERY_LOG_READ_FAILED(500) — the/admin/query-statsendpoint couldn't read its telemetry table. Internal; transient.DUPLICATE(202 withrejected[]) — sameevent_idsubmitted within the 60s idempotency window. Safe to ignore; the original was kept. Response headerLogs-Dedupedcounts how many in the batch were duped.ARCHIVE_NOT_CONFIGURED(503) — search window includes archived (>7d) data but the cold-tier object-storage credentials aren't set on the server. Operator action only.BAD_COMPRESSED_BODY(400) — request body had aContent-Encodingheader (gzip/deflate/br) but the compressed payload was truncated, corrupt, or used the wrong format. Re-encode and retry. The API decompresses gzip/deflate/br request bodies natively for ingest endpoints — log batches arrive at a small fraction of their raw size.ALERT_URL_BLOCKED(400) — alert URL resolves to a private / loopback / metadata IP (SSRF defense).ALERT_URL_INVALID(400) — alert URL malformed or non-http(s).BAD_TIME_RANGE(400) —from > toon search.
Endpoint index
Full reference is in the OpenAPI spec; this is a sufficient map for most agent integrations. Each bullet is one route group.
Monitors
GET /api/v1/monitors # public projection (no secrets). ?tag=foo&tag=bar filters by tag (AND)
POST /api/v1/monitors # scope: monitors:write
GET /api/v1/monitors/:id
PATCH /api/v1/monitors/:id # scope: monitors:write
DELETE /api/v1/monitors/:id # scope: monitors:write
GET /api/v1/monitors/:id/secrets # scope: secrets:read (owner/admin)
POST /api/v1/monitors/:id/test-alert # send a sample alert through every configured channel
POST /api/v1/monitors/:id/rotate-heartbeat-token
# Bulk operations (max 100 per request)
POST /api/v1/monitors/bulk # { "monitors": [MonitorCreate, ...] }
PATCH /api/v1/monitors/bulk # { "ids": [1,2,3], "patch": MonitorUpdate }
DELETE /api/v1/monitors/bulk # { "ids": [1,2,3] } (owner/admin only)
# Stats (time-series)
GET /api/v1/monitors/:id/checks # recent check results
GET /api/v1/monitors/:id/stats # uptime % / response-time stats
GET /api/v1/monitors/:id/timeline # bucketed timeline for the dashboard
# Multi-region (D8 MVP): set monitor.regions[] to opt-in
# Default ['local']; remote regions ['weur','enam','apac','oc','sam','afr']
# only HTTP/HTTPS today, more check types queued Incidents
GET /api/v1/incidents GET /api/v1/incidents/:id POST /api/v1/incidents/:id/updates # post a public timeline update POST /api/v1/incidents/:id/acknowledge POST /api/v1/incidents/:id/resolve PUT /api/v1/incidents/:id/postmortem # rich-text markdown
Status pages
# Authed (owner/admin) GET /api/v1/status-pages POST /api/v1/status-pages # scope: status-pages:write GET /api/v1/status-pages/:id PATCH /api/v1/status-pages/:id # scope: status-pages:write DELETE /api/v1/status-pages/:id # scope: status-pages:write PUT /api/v1/status-pages/:id/password # set / clear password (Argon2id) GET /api/v1/status-pages/:id/subscribers DELETE /api/v1/status-pages/:id/subscribers/:sid # Public (no auth) GET /api/v1/status-pages/public/:slug # JSON for the renderer GET /api/v1/status-pages/public/:slug/feed.xml # Atom feed GET /api/v1/status-pages/public/by-host/:host # custom-domain lookup POST /api/v1/status-pages/public/:slug/unlock # password unlock POST /api/v1/status-pages/public/:slug/subscribe # email subscribe GET /api/v1/status-pages/public/:slug/subscribe/confirm GET /api/v1/status-pages/public/unsubscribe
Maintenance windows
GET /api/v1/maintenance-windows POST /api/v1/maintenance-windows # scope: maintenance:write GET /api/v1/maintenance-windows/:id DELETE /api/v1/maintenance-windows/:id # scope: maintenance:write
Account / org
GET /api/v1/me # current user + plan + quota GET /api/v1/me/members PATCH /api/v1/me/members/:userId # change role (owner only; can't demote sole owner) GET /api/v1/me/webhook-secret # per-org HMAC secret for outbound webhook signatures POST /api/v1/me/webhook-secret/rotate POST /api/v1/me/change-password # OAuth linking (Google + GitHub OIDC) GET /api/v1/me/oauth # which providers are linked GET /api/v1/me/oauth/google/start DELETE /api/v1/me/oauth/google GET /api/v1/me/oauth/github/start DELETE /api/v1/me/oauth/github # Invites GET /api/v1/me/invites POST /api/v1/me/invites # email invitee a join link DELETE /api/v1/me/invites/:id # PATs (see above for shape) GET /api/v1/me/tokens POST /api/v1/me/tokens # scope: tokens:write DELETE /api/v1/me/tokens/:id
Audit log (scope: audit:read)
GET /api/v1/audit-logs
?action=CREATE_MONITOR # exact action verb
&actor=42 # user id
&pat_id=17 # which PAT (Tier D fruit 1)
&from=2026-05-01T00:00:00Z
&to=2026-05-13T23:59:59Z
&limit=200
&format=csv # download as CSV
# Row shape
{
"id": 9123,
"createdAt": "2026-05-13T18:00:00Z",
"actorUserId": 42,
"actorPatId": 17, # null = session login; non-null = PAT
"actorEmail": "alice@yourco.com",
"action": "UPDATE_MONITOR",
"resource": "monitor:88",
"meta": {
"changedFields": ["intervalSec","timeoutMs"],
"diff": {
"intervalSec": { "from": 60, "to": 300 },
"timeoutMs": { "from": 5000, "to": 10000 }
},
"ip": "203.0.113.7", # captured automatically (Tier E)
"ua": "AeonitiBot/1.0"
}
}
For sensitive-field PATCHes (alert URLs, telegram tokens, headers) the
diff entries are { "redacted": true } — values never
leak into audit. DELETE actions include a snapshot of key
identifying fields before the row was gone.
Logs (separate doc page)
See /docs/logs for ingest, search, live tail, log-based alerts, OTLP/HTTP, and Vector config.
OpenTelemetry (OTLP/HTTP)
POST /api/v1/otlp/v1/logs # Standard OTLP/HTTP receiver. Set OTEL_EXPORTER_OTLP_ENDPOINT=https://api.24observe.com/api/v1/otlp # and any OTel SDK ships logs here. JSON encoding only in v1 (use the 24observe # collector image for http/protobuf). See /docs/logs#opentelemetry-otlphttp.
For non-OTel sources (Syslog, Fluent Bit), see the 24observe collector.
Heartbeats (separate doc page)
See /docs/heartbeats for receive URL, two-step token retrieval, and end-to-end examples.
Public / unauthenticated
GET /api/v1/badge/monitors/:id.svg # status badge (shields.io-style) POST /api/v1/heartbeats/:token # heartbeat receive HEAD /api/v1/heartbeats/:token # same, no body # Auth (no bearer required) POST /api/v1/auth/register POST /api/v1/auth/login POST /api/v1/auth/forgot-password POST /api/v1/auth/reset-password GET /api/v1/auth/google/start # OAuth GET /api/v1/auth/google/callback GET /api/v1/auth/github/start GET /api/v1/auth/github/callback
Operational
GET /health/live # cheap liveness (k8s pattern) GET /health/ready # all dependencies reachable GET /metrics # Prometheus GET /openapi.json # OpenAPI 3 spec GET /docs # Swagger UI
Walkthrough: create a monitor
Programmatic equivalent of clicking "New monitor" in the dashboard.
60-second HTTPS check on https://example.com; alerts go to
Slack via webhook.
curl
curl -X POST https://api.24observe.com/api/v1/monitors \
-H 'Authorization: Bearer obs_...' \
-H "Idempotency-Key: $(uuidgen)" \
-H 'Content-Type: application/json' \
-d '{
"name": "example.com homepage",
"url": "https://example.com",
"type": "https",
"intervalSec": 60,
"timeoutMs": 10000,
"alertSlackUrl": "https://hooks.slack.com/services/..."
}' TypeScript (Node 22 fetch)
import { randomUUID } from 'node:crypto';
const PAT = process.env.OBSERVE24_PAT!;
const res = await fetch('https://api.24observe.com/api/v1/monitors', {
method: 'POST',
headers: {
authorization: `Bearer ${PAT}`,
'idempotency-key': randomUUID(),
'content-type': 'application/json',
},
body: JSON.stringify({
name: 'example.com homepage',
url: 'https://example.com',
type: 'https',
intervalSec: 60,
timeoutMs: 10000,
alertEmail: 'alerts@yourco.com',
}),
});
if (!res.ok) {
const err = await res.json();
// err.code: VALIDATION_FAILED / PLAN_LIMIT_MONITORS / PAT_SCOPE_INSUFFICIENT / ...
// err.fields: [{ path, code, message }] when VALIDATION_FAILED
throw new Error(`${res.status} ${err.code}: ${err.error}`);
}
const monitor = await res.json();
console.log('created monitor', monitor.id); Python (httpx)
import os, uuid, httpx
PAT = os.environ['OBSERVE24_PAT']
r = httpx.post(
'https://api.24observe.com/api/v1/monitors',
headers={
'authorization': f'Bearer {PAT}',
'idempotency-key': str(uuid.uuid4()),
'content-type': 'application/json',
},
json={
'name': 'example.com homepage',
'url': 'https://example.com',
'type': 'https',
'intervalSec': 60,
'timeoutMs': 10000,
'alertEmail': 'alerts@yourco.com',
},
)
r.raise_for_status()
print('created monitor', r.json()['id']) Monitor request body — full shape
Strict-validated; unknown fields are rejected. Full schema lives in the OpenAPI spec; the table below is the practical subset.
name(string, 1-255) — display name.url(string, 1-2048) — what to check.type— one ofhttp,https,tcp,ping,port,ssl,keyword,heartbeat,dns,smtp.intervalSec— one of30, 60, 300, 900, 1800, 3600. Plan tier sets the floor.timeoutMs— 1000-30000.headers(optional, ≤ 20 keys, encrypted at rest)expectedStatusCode,degradedResponseTimeMs,port,keyword,keywordMatchType- Alerts:
alertEmail,alertWebhookUrl,alertSlackUrl,alertDiscordUrl,alertMsteamsUrl,alertTelegramBotToken,alertTelegramChatId,alertPagerdutyRoutingKey,alertOpsgenieApiKey. URLs are SSRF-validated at write time and all secret-bearing fields are stored encrypted at rest. - PagerDuty: set
alertPagerdutyRoutingKeyto your "Integration Routing Key" from a PagerDuty Events API v2 integration. We POST toevents.pagerduty.com; the resolve event fires automatically when the monitor goes back up, so PD auto-closes the incident. - Opsgenie: set
alertOpsgenieApiKeyto a GenieKey from an Opsgenie API integration. Down → P1 alert with deterministic alias; up → close-by-alias so the original alert resolves cleanly. - Tags: set
tagsto an array of lowercase slugs (e.g.["prod","team-payments","critical"]) to group monitors. Max 20 per monitor. Filter by tag withGET /monitors?tag=prod&tag=team-payments(AND across multiple). - Bulk migrations:
POST /monitors/bulktakes up to 100 monitors in a single transaction (all-or-nothing). Bulk PATCH applies the same patch to many ids (useful for "add tag X to these 50 monitors"). Bulk DELETE removes up to 100 at once. alertThreshold— consecutive failures before an incident opens (default 1).regions(string array, default["local"]) — multi-region opt-in. Local always runs; add any ofweur / enam / apac / oc / sam / afrto dispatch additional checks from CF Workers. HTTP/HTTPS only in MVP.uptimeTargetBp(int, default 999 = 99.9%) — SLO target in basis points. Breach triggers an auto-incident on the scheduler tick.uptimeWindowDays(int, default 30) — SLO evaluation window.
Reading secret-bearing fields
GET /api/v1/monitors and GET /api/v1/monitors/:id
return a public projection only — alert URLs, telegram tokens,
encrypted headers, and the plaintext heartbeat token are
not in the response. Boolean indicators (hasAlertWebhook,
hasAlertSlack, etc.) tell you which channels are configured.
To read the secrets:
GET /api/v1/monitors/:id/secrets Authorization: Bearer obs_<owner-or-admin PAT with secrets:read scope>
Every call is recorded in the audit log
(action: READ_MONITOR_SECRETS) with IP + user-agent +
actorPatId so the admin can prove which integration fetched what.
Other resources
- Heartbeat / cron-job monitors — the inverted check pattern, including the two-step token retrieval flow.
- Logs ingest + search + tail — log management surface with curl + TypeScript + Python + Vector examples.
- Verifying alert webhooks — HMAC-SHA256 on incoming alerts, Node / Python / Go.
- Incident response runbook — what to check when something breaks.
- Interactive OpenAPI reference — every endpoint, every schema, in one searchable page.
- openapi.json — raw spec for generating a typed client in any language.