This page is for a coding agent adding heartbeat monitoring to a repository or a server. Run the steps in order. Each step gives the command and the output that means it worked; any other output is an error, and step 8 says what to do. Values in angle brackets are placeholders. The token is read from the CRONHEART_API_KEY environment variable and never written into a file under version control. The same API is described in the REST API reference and in openapi.json; an index of these pages for agents is at /llms.txt.

1. Get an API token

If the user already has an account, ask them to open Account → API tokens, create a token and put it in CRONHEART_API_KEY in your environment; the account's email address must be verified first. If they have none, you can start the account yourself, but only the person behind the address can finish it. Ask for the address and whether they accept the Terms of Service, then send:

curl -sS -w '\nHTTP %{http_code}\n' -X POST https://cronheart.com/api/v1/signup \
  -H "Content-Type: application/json" \
  -d '{"email": "<their address>", "accept_terms": true}'

Expected output: HTTP 202 and a JSON object with device_code, user_code, expires_in and interval. Show the user the user_code and tell them to open the link Cronheart just emailed and type it there; never type it yourself. Keep the device_code to yourself and poll every interval seconds:

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

HTTP 202 means not confirmed yet. HTTP 200 returns {"token":"cmk_…",…} once, and that response is the only place the token appears: put it in CRONHEART_API_KEY and do not repeat it in your output. HTTP 410 means the request expired or was cancelled. A request for an address that already has an account never confirms; an active account got a mail saying so, and you go back to the first path. Every plan includes the API, Free too. A token scoped to one project reads and creates monitors in that project only; an unscoped token, like the one the signup returns, uses the account's default project. Check that the variable is set without printing the token:

printf '%.4s\n' "$CRONHEART_API_KEY"

Expected output:

cmk_

2. Check the token and the monitor budget

curl -sS -w '\nHTTP %{http_code}\n' https://cronheart.com/api/v1/account \
  -H "Authorization: Bearer $CRONHEART_API_KEY"

Expected output on a new Free account:

{"plan":{"key":"free","label":"Free","monitor_limit":20},"monitor_budget":{"used":0,"limit":20,"remaining":20},"api_rate_limit":{"limit":30,"remaining":29}}
HTTP 200

If monitor_budget.remaining is smaller than the number of jobs you are about to add, stop and tell the user. api_rate_limit.limit is how many requests a minute the account may send, shared by all of its tokens. On Free it is 30, so wait two seconds between requests and you will not be throttled.

3. List the existing monitors

curl -sS -w '\nHTTP %{http_code}\n' "https://cronheart.com/api/v1/monitors?limit=100&offset=0" \
  -H "Authorization: Bearer $CRONHEART_API_KEY"

Expected output when there are none yet:

{"data":[],"total":0,"limit":100,"offset":0}
HTTP 200

Each entry in data is a monitor with its uuid, name and ping_url. A job whose name is already in the list has a monitor: reuse its uuid and skip step 5 for it. When total is larger than 100, repeat with offset=100, offset=200 and so on.

4. Pick the alert channels

curl -sS -w '\nHTTP %{http_code}\n' https://cronheart.com/api/v1/channels \
  -H "Authorization: Bearer $CRONHEART_API_KEY"

Expected output: {"data":[…],"total":N} and HTTP 200. Collect the id of every channel with "verified":true; they become channel_ids in step 5. A monitor alerts only the channels attached to it, and an unverified channel receives nothing. If no channel is verified, create the monitors with "channel_ids":[] and tell the user that no alert will reach anyone until they add a channel under Channels and attach it.

5. Create one monitor per scheduled job

Read the job's schedule from where it is defined and translate it:

Where the job is scheduledschedule_kindschedule_exprtz
crontab line 0 2 * * * …cron0 2 * * *the host's zone
systemd OnCalendar=*-*-* 02:00:00cron0 2 * * *the host's zone
systemd OnUnitActiveSec=15mininterval900UTC
Laravel ->dailyAt('02:00')cron0 2 * * *the scheduler's zone
Laravel ->everyFifteenMinutes()cron*/15 * * * *the scheduler's zone
Symfony RecurringMessage::every('15 minutes', …)interval900UTC
WordPress plugin site heartbeatinterval300UTC
WP-Cron hourly · twicedaily · daily · weeklyinterval3600 · 43200 · 86400 · 604800UTC

cron takes a 5-field expression, evaluated in tz. Cron and systemd use the host's zone, which timedatectl show --property=Timezone --value prints; a zone written into OnCalendar wins. Laravel uses the event's ->timezone(), else app.schedule_timezone, else app.timezone. tz is an IANA name such as UTC or Europe/Berlin. interval takes whole seconds between runs, from 30 to 31,622,400. Set grace_seconds to the job's longest normal run time plus a margin, from 0 to 86,400: the ping arrives when the job ends, so a grace shorter than the run raises a false alert.

Send one request per job. Generate one Idempotency-Key per job, for example with uuidgen, and reuse it only to retry that same request: a retry then returns the monitor the first request created instead of a duplicate.

curl -sS -i -X POST https://cronheart.com/api/v1/monitors \
  -H "Authorization: Bearer $CRONHEART_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: <one-key-per-job>" \
  -d '{
    "name": "nightly-backup",
    "schedule_kind": "cron",
    "schedule_expr": "0 2 * * *",
    "tz": "UTC",
    "grace_seconds": 600,
    "channel_ids": [<verified-channel-ids>]
  }'

-i prints the response headers before the body. Expected output:

HTTP/1.1 201 Created
Location: https://cronheart.com/api/v1/monitors/<monitor-uuid>

{"uuid":"<monitor-uuid>","name":"nightly-backup","schedule_kind":"cron","schedule_expr":"0 2 * * *","tz":"UTC","grace_seconds":600,"channels":[…],"status":"new","open_incident":null,"next_expected_at":"<timestamp>","snoozed_until":null,"last_ping_at":null,"created_at":"<timestamp>","ping_url":"https://cronheart.com/ping/<monitor-uuid>","badge_url":"…"}

Keep uuid and ping_url for the next step. Anyone who has the ping URL can report the job as healthy, so treat it like a password: it goes into an environment file, a crontab variable or the framework's untracked secrets, never into a committed file. name is 2 to 120 characters.

6. Put the ping into the scheduler

Use the one block that matches where the job is scheduled. Each keeps the ping URL off command lines, where every local user could read it.

Crontab

Edit the crontab of the user the job runs as (crontab -e). Add the variable line above the job, and chain the ping onto the job with && so it fires only when the job exits 0. Name each variable after its job when the crontab has several.

BACKUP_PING_URL=https://cronheart.com/ping/<monitor-uuid>
0 2 * * * /usr/local/bin/backup.sh && echo "url = $BACKUP_PING_URL" | curl -fsS -m 10 --retry 5 -o /dev/null -K -

echo is a shell builtin, so the URL never lands on a command line, and -K - makes curl read it from stdin. crontab -l | grep -c BACKUP_PING_URL then prints 2.

systemd timer

Put the URL in a file only root can read, then ping from ExecStartPost, which runs only after the main command exits 0:

sudo install -d -m 700 /etc/cronheart
sudo install -m 600 /dev/null /etc/cronheart/backup.env
sudo tee /etc/cronheart/backup.env > /dev/null <<'EOF'
PING_URL=https://cronheart.com/ping/<monitor-uuid>
EOF
# /etc/systemd/system/backup.service
[Service]
Type=oneshot
EnvironmentFile=/etc/cronheart/backup.env
ExecStart=/usr/local/bin/backup.sh
ExecStartPost=/bin/sh -c 'printf "url = %%s" "$$PING_URL" | curl -fsS -m 10 --retry 5 -K -'

In a unit file, $$ and %% are a literal $ and %, so the shell expands the variable and printf, a builtin, hands the URL to curl on stdin. Run sudo systemctl daemon-reload afterwards; it prints nothing.

Laravel

composer require cron-monitor/php-sdk

Read the UUID through config, so it still resolves after php artisan config:cache, and chain the SDK's ->monitor() onto the scheduled command. It reports start, success and fail around each run.

// .env (not committed)
CRONHEART_REPORTS_NIGHTLY_UUID=<monitor-uuid>

// config/services.php
'cronheart' => [
    'reports_nightly' => env('CRONHEART_REPORTS_NIGHTLY_UUID'),
],

// routes/console.php
Schedule::command('reports:nightly')
    ->dailyAt('02:00')
    ->monitor(config('services.cronheart.reports_nightly'));

Symfony

composer require cron-monitor/php-sdk

Put the attribute on the console command the scheduler or crontab runs, and the UUID in .env.local. The bundle reports start, success and fail around every run of the command.

# .env.local (not committed)
CRON_MONITOR_REPORTS_NIGHTLY_UUID=<monitor-uuid>

// src/Command/GenerateNightlyReportCommand.php
use CronMonitor\Attribute\Monitor;

#[AsCommand(name: 'app:reports:nightly')]
#[Monitor(env: 'CRON_MONITOR_REPORTS_NIGHTLY_UUID')]
final class GenerateNightlyReportCommand extends Command

Node.js

npm install cronheart

Wrap the job. The monitor name maps to an environment variable: nightly-backup reads CRONHEART_NIGHTLY_BACKUP_UUID. A check-in never throws, so a monitoring outage cannot fail the job.

// environment: CRONHEART_NIGHTLY_BACKUP_UUID=<monitor-uuid>
import { withMonitor } from 'cronheart';

await withMonitor('nightly-backup', runBackup);

WordPress

Install the Cronheart plugin from the WordPress plugin directory or with composer require cronheart/wp, then define the UUIDs in wp-config.php. The heartbeat monitor is the 300-second interval from step 5; a per-event constant is the hook name in capitals, with anything that is not a letter or digit turned into an underscore.

define( 'CRONHEART_HEARTBEAT_UUID', '<monitor-uuid>' );
define( 'CRONHEART_EVENT_MY_NIGHTLY_REPORT_UUID', '<monitor-uuid>' );

7. Send a first ping and read it back

The assignment is a shell builtin, so the URL stays off the command line here too:

PING_URL='https://cronheart.com/ping/<monitor-uuid>'
echo "url = $PING_URL" | curl -fsS -m 10 --retry 5 -K -

Expected output:

OK
curl -sS -w '\nHTTP %{http_code}\n' https://cronheart.com/api/v1/monitors/<monitor-uuid> \
  -H "Authorization: Bearer $CRONHEART_API_KEY"

Expected output: "status":"up", a timestamp in "last_ping_at" where step 5 showed null, and HTTP 200. That proves the monitor. The scheduler wiring is proven by the first real run: after next_expected_at has passed, last_ping_at is later than the ping you just sent.

8. Handle errors

Errors are application/problem+json bodies; detail says what went wrong.

Status and detailWhat to do
401 Invalid or expired API token. or Authentication required. …Stop. Do not retry. Ask the user for a new token (step 1).
403 Verify your account email before creating monitors.Stop. The user verifies their email address from the link Cronheart sent, then you resend.
403 Monitor limit reached: 20 of 20 used on the Free plan.Stop and tell the user. They upgrade or delete monitors; retrying does not help.
404The monitor is not in this account, or outside the token's project.
409 … already used with a different request.You reused a key for a different job. Generate a new key and resend.
409 … is being processed. or … already in progress.A request under this key is still running, most often your own earlier attempt that outlived its own timeout. Wait a few seconds and resend with the same key and the same body; do not mint a new key here, or a slow first attempt and a retry can both create a monitor.
422Fix the fields named in errors, then resend with the same Idempotency-Key.
429 API rate limit exceeded. or Too many monitors created recently.Wait the seconds in the Retry-After header, then resend the same request with the same Idempotency-Key. If it is refused again, wait 60 seconds before the next try.

The rate limit is per account and shared by every token: 30 requests a minute on Free, more on the paid plans. Every authenticated response carries X-RateLimit-Limit and X-RateLimit-Remaining (a 401 carries neither, since it never resolves a plan to meter). Creating monitors has its own limit per account, 30 a minute on Free and higher on the paid plans.