Create, manage, and inspect monitors and notification channels programmatically — the same operations as the dashboard, scriptable from CI or an agent. Every write runs through the same validation and plan checks as the web app, so an API-created monitor behaves exactly like one made in the UI. Every plan includes the API, Free too; the plan sets the rate limit.

Authentication

Create a personal access token under Account → API tokens (it's shown once). Send it as a bearer token on every request:

curl -H "Authorization: Bearer cmk_…" \
  https://cronheart.com/api/v1/monitors

A missing, invalid, expired, or revoked token returns 401. Requests are rate-limited per account (shared across all of your tokens, regardless of scope): 30 a minute on Free, 120 a minute on Starter, 300 a minute on Growth and 600 a minute on Scale. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. Creating monitors is also limited per account, shared with the web app, and every attempt counts, including one that fails validation: 30 creates a minute on Free, 120 creates a minute on Starter, 300 creates a minute on Growth and 600 creates a minute on Scale. Creating channels is limited to 30 a minute per account on every plan, also shared with the web app. Those 429s carry Retry-After; wait that long, because their X-RateLimit-* headers describe the request limit.

Project scope

A token can be scoped to a single project when you create it. A scoped token reads and creates monitors only within that project — a leaked token can't enumerate other projects' monitors. A token with no scope resolves to your account's default project (the first one). Fetching a monitor outside the token's scope returns 404, the same mask used for another account's monitors.

Channels are account-level (shared across all your projects), so GET /api/v1/channels returns every channel on the account regardless of the token's project scope.

Sign up from a terminal

Without an account yet, a client can ask for one and receive its first token. Two calls take no token. The first sends the address and returns a device_code for the client and a user_code to show the person; a mail with one link goes to the address:

curl -sS -X POST https://cronheart.com/api/v1/signup \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com", "accept_terms": true}'
{"device_code":"…","user_code":"BCDF-GHJK","expires_in":1800,"interval":5,"hint":"…"}

The person opens the link from the mail and types the user_code there; the mail never contains the code. Meanwhile the client polls with the device code, no faster than interval seconds:

curl -sS -X POST https://cronheart.com/api/v1/signup/token \
  -H "Content-Type: application/json" \
  -d '{"device_code": "<device_code>"}'

The poll answers 202 with {"status":"authorization_pending"} until the code is confirmed, then 200 once with {"token":"cmk_…","token_prefix":"cmk_…","project":"default"}: the account, its default project and that token exist from this moment, and the token is not shown again. Polling too fast is a 429 with "error":"slow_down"; an unknown, expired, cancelled or already claimed request is a 410 with "error":"expired_token". Both codes expire after 30 minutes.

accept_terms must be true; the consent on record is the click of whoever confirms on the page. Every address gets the same answer; a request for an address that already has an account never confirms, and an active account gets a mail saying nothing changed. Signups are limited to 3 a day per inbox and to 5 an hour per network, a limit the sign-up form shares. The new account has no password: set one with Forgot password to sign in on the web.

Create a monitor

POST /api/v1/monitors — returns 201 with a Location header and the monitor (including its ping_url).

curl -X POST https://cronheart.com/api/v1/monitors \
  -H "Authorization: Bearer cmk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "nightly-backup",
    "schedule_kind": "interval",
    "schedule_expr": "3600",
    "tz": "UTC",
    "grace_seconds": 60,
    "channel_ids": []
  }'

schedule_kind is cron (a 5-field expression), interval (integer seconds, 30–31,622,400), or simple (e.g. "daily at 03:00"). channel_ids lists the account channels this monitor alerts; leave it empty and the monitor won't notify anyone (there is no fan-out).

List & fetch monitors

GET /api/v1/monitors?limit=50&offset=0 returns { "data": [...], "total": N }. GET /api/v1/monitors/{uuid} returns a single monitor (or 404 if it isn't yours).

Every monitor payload carries its routing as channels: [{"id", "kind", "label"}], ordered by id — so you can confirm what a write actually attached, diff a monitor against a desired spec, or copy one monitor's routing onto another by passing those ids straight back as channel_ids. An empty array means the monitor alerts nobody.

It lists what is attached, not what will be delivered: an attached channel that isn't verified is skipped at send time. If you're reconciling routing and want to be sure alerts land, check verified on GET /api/v1/channels as well.

Every monitor payload also carries open_incident: null, or {"kind", "since"} while an incident is open. kind is the alert that opened it (late or fail), and since is when that alert was raised (null if it can't be found). An incident closes only on a successful run, a success or heartbeat ping; pause, resume, snooze and /start leave it open, and no new alert fires until it closes. A monitor that reads new after a resume can still be inside an incident, and a second failure with a different cause stays quiet, so give each independent job its own monitor.

Manage a monitor

RequestEffect
PATCH /api/v1/monitors/{uuid}Partial update — send only the fields you want to change (name, schedule_kind, schedule_expr, tz, grace_seconds, channel_ids).
DELETE /api/v1/monitors/{uuid}204; cascades its pings and alerts.
POST …/pause · …/resumeStop / restart monitoring. Resume keeps any open incident; the next successful run closes it.
POST …/snoozeMute alerts for a window: {"duration":"1h|4h|1d|1w"}. …/unsnooze clears it. A recovery during the snooze still resolves PagerDuty incidents.
POST …/rotate-uuidNew ping URL. Requires {"confirm":"<current-uuid>"} — the old URL dies immediately.

Ping & alert history

GET /api/v1/monitors/{uuid}/pings is cursor-paginated: { "data": [...], "next_cursor": "…" } — pass ?cursor= (and optional ?limit=) to walk back; a null cursor is the last page. GET /api/v1/monitors/{uuid}/alerts is offset-paginated like the monitor list.

Account & usage

GET /api/v1/account returns your plan, your monitor_budget (used / limit / remaining), and your current api_rate_limit standing.

Create a channel

POST /api/v1/channels — the body is keyed by kind:

{"kind":"telegram","label":"ops","chat_id":"-100123456789"}
{"kind":"slack","label":"alerts","webhook_url":"https://hooks.slack.com/services/…"}
{"kind":"webhook","label":"my-hook","webhook_url":"https://example.com/hook","secret":"<16+ chars>"}
{"kind":"email","label":"on-call","address":"ops@example.com"}

Channels are created unverified. An email channel triggers a verification link to the address; the others verify on their first successful test send. Webhook URLs must be public HTTPS endpoints on a host name — IP addresses and localhost are rejected. GET /api/v1/channels lists them (secrets are redacted).

Manage a channel

RequestEffect
GET /api/v1/channels/{id}Fetch one channel (config redacted).
PATCH /api/v1/channels/{id}Rename: {"label":"…"} (config is immutable — delete + recreate to change a destination).
DELETE /api/v1/channels/{id}204.
POST …/rotate-secretWebhook only — returns the new secret once.
POST …/testSend a test alert (verifies the channel on first success). Rate-limited per account; 409 while an operator has that kind's delivery turned off, with nothing sent; 502 if the destination rejects it.

OpenAPI

A machine-readable OpenAPI 3.1 description of every endpoint is served at /api/v1/openapi.json (no token required) — point your SDK generator or openapi-aware client at it.

Errors

Errors are application/problem+json (RFC 7807):

{"type":"about:blank","title":"Unprocessable Entity","status":422,
 "detail":"One or more fields are invalid.","errors":{"name":"This value is too short."}}
StatusWhen
400Body is not valid JSON.
401Missing / invalid / expired / revoked token.
403Monitor limit reached, or account email unverified.
404Resource not found (or not yours).
409That channel kind is temporarily disabled, or its delivery is turned off (a test send, or creating an Email channel).
422Validation failed — see the errors map.
429Rate limit exceeded; the detail names the limit: API rate limit exceeded., Too many monitors created recently., Too many channels created recently., Too many verification emails recently. or Too many test alerts recently. Wait the Retry-After seconds.

Idempotency

Send an Idempotency-Key header on a POST create and a retried request with the same key replays the first response instead of creating a duplicate — safe to retry a timed-out create from CI. Reusing a key with a different request body returns 409; a replay carries Idempotency-Replayed: true. Keys are remembered for at least 24 hours.

curl -X POST https://cronheart.com/api/v1/monitors \
  -H "Authorization: Bearer cmk_…" \
  -H "Idempotency-Key: 9f1c…-your-unique-key" \
  -H "Content-Type: application/json" \
  -d '{"name":"nightly","schedule_kind":"interval","schedule_expr":"3600"}'

PHP integration

If you're on Symfony or Laravel, the cron-monitor/php-sdk package wraps the ping endpoint with first-class scheduler integrations — composer require cron-monitor/php-sdk ( source on GitHub). It also ships a typed client for this REST API.

Node integration

On Node or TypeScript, the cronheart package covers both surfaces — cronheart for check-ins, cronheart/api for this REST API with typed monitors, channels and account clients, and page-walking iterators over monitors, pings and alerts. npm install cronheart, zero runtime dependencies ( cronheart-node on GitHub). The cronheart sync CLI reconciles a project's monitors against a file you keep in version control, and cronheart/sync exposes the same plan/apply from code.