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