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. API access is included from the Starter plan up.

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. A token whose plan no longer includes API access returns 402. Requests are rate-limited per account (shared across all of your tokens, regardless of scope); every response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset.

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.

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).

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 alerting.
POST …/snoozeMute alerts for a window: {"duration":"1h|4h|1d|1w"}. …/unsnooze clears it.
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 — loopback and private addresses 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; 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.
402Your plan doesn't include API access.
403Monitor limit reached, or account email unverified.
404Resource not found (or not yours).
409That channel kind is temporarily disabled.
422Validation failed — see the errors map.
429Rate limit exceeded — see Retry-After.

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.