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
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
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."}}
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.