Everything you need to wire your first cron job in under five minutes. The HTTP API is intentionally tiny — one ping URL with three lifecycle actions (and a heartbeat shorthand) — so you can integrate from any language without an SDK. Node, PHP and WordPress get first-class wrappers either way: the cronheart npm package (six scheduler adapters plus a CLI that wraps any command), the cron-monitor/php-sdk package (Symfony Scheduler + Laravel), and the Cronheart WordPress plugin (WP-Cron).
Prefer to set things up programmatically? Beyond this ping endpoint there's a token-authenticated REST API to create and list monitors and channels — see the REST API reference. Setting this up with a coding agent? Add Cronheart to a project is written for one — numbered steps, exact commands, expected output.
Concepts
A monitor is one scheduled job you want to watch. Every monitor has a UUID — that UUID is also the secret in its ping URL. Treat it as a credential.
Monitors transition through four states:
Ping API
All endpoints share one ping URL, accept GET, POST, and HEAD, and
are idempotent within a 2-second window — a curl --retry
that briefly stumbles over a transient network blip won't
double-count. POST lets you attach up to 10KB of captured
output (stdout/stderr) — anything past 10KB is truncated, not
rejected.
POST /ping/<uuid> — heartbeat
The simplest case. Tells us "the job ran successfully right now".
Same as /ping/<uuid>/run. It marks the monitor
up and closes an open incident just as /success does,
but only a finished run (/success, /ok,
/fail or an exit code) pairs with a
/start to record a duration.
curl -fsS -m 10 --retry 5 --retry-connrefused https://cronheart.com/ping/<uuid>
POST /ping/<uuid>/start — run started
Send before the job runs. Lets us track duration and surface "job
stuck" warnings if a /start isn't followed by a
terminal event within the grace period.
POST /ping/<uuid>/success — run finished cleanly
Send when the job exits 0. Pair with /start to get a
duration column on the dashboard.
POST /ping/<uuid>/fail — run errored
Send when the job exits non-zero. Opens an incident and alerts immediately if one isn't already open — no need to wait for the next scheduled window.
Every accepted action
The segment after the UUID is a closed list, matched in any letter case:
An exit code is 1 to 16 digits (0–9 only), so
curl …/ping/<uuid>/$? reports the job's own status. Only the
single digit 0 is success: every other digit string is
a failure, 00 and 007 included.
Anything else is a 404 that records nothing — an
unknown word such as failed, a signed number such as
-1, 17 or more digits, a look-alike letter outside
ASCII. A typo loses that check-in rather than recording a wrong
one, and curl -f exits non-zero on the 404 so you
notice.
Leave the trailing slash off. A GET or HEAD to
/ping/<uuid>/ or /ping/<uuid>/fail/
gets a 301 to the path without it and records nothing
until a client follows the redirect (curl needs -L);
a POST there is a 404.
Bash recipe with start + result reporting
UUID=<your-monitor-uuid>
BASE=https://cronheart.com/ping/$UUID
curl -fsS -m 10 --retry 5 --retry-connrefused "$BASE/start"
if /usr/bin/php /app/bin/console hourly:rollup; then
curl -fsS -m 10 --retry 5 --retry-connrefused --data-binary @<(tail -c 8000 ./run.log) "$BASE/success"
else
curl -fsS -m 10 --retry 5 --retry-connrefused --data-binary @<(tail -c 8000 ./run.log) "$BASE/fail"
fi
Schedule formats
Three formats; the form's preview shows the next 5 runs for all of them as you type — sanity-check before saving.
- Cron — standard 5-field expression
(
*/5 * * * *), interpreted in the monitor's timezone. Around a daylight-saving change Cronheart expects what cron does on a host set to the monitor's timezone: a job at a fixed time in a repeated hour runs once, and one in a skipped hour runs right after the jump. A job with its ownCRON_TZfollows the wall clock instead. - Interval — integer seconds between
runs:
300= 5 min,3600= 1 h. Range 30 s to 366 days. Timezone-agnostic. - Simple — one of a fixed allowlist:
every_minute,every_5_minutes,every_10_minutes,every_15_minutes,every_30_minutes,hourly,every_2_hours,every_6_hours,daily(00:00),daily_morning(09:00),weekly(Mon 00:00),monthly(1st 00:00). For arbitrary times use Cron.
Alerts & channels
An alert fires the moment a monitor goes late or
down with no incident already open — no fixed-window
batching. A missed run goes late
within 90 seconds after the grace period ends,
while no more than 200 monitors fall due in the same half-minute,
so the time-to-page is that bound plus the channel transport latency.
If our own service is disrupted,
late alerts wait until it has caught up, at most about 11 minutes,
rather than fire falsely. The alert opens an incident, and only the next successful run — a
/success or heartbeat ping — closes it with a recovery
alert. Pausing, resuming, snoozing and a /start ping
leave it open, and another failure or missed run while it's open
doesn't page you again.
Channels are configured once per account and shared across all your projects. Currently supported:
- Email (default)
- Telegram bot (set up via
/startwith the bot) - Slack incoming webhook
- Discord incoming webhook
- Microsoft Teams, through a Workflows flow that posts a webhook request to a channel (a URL on
environment.api.powerplatform.com) - Google Chat incoming webhook (a space's webhook URL on
chat.googleapis.com) - PagerDuty, through the Events API v2 integration key of a service
- Generic webhook with HMAC-SHA256 signature
Microsoft Teams needs a Microsoft 365 work or school
account. Alerts arrive as an Adaptive Card. Retired Office 365
connector URLs and logic.azure.com URLs are refused.
Microsoft accepts the request before the flow runs, so "Send test"
reports it as accepted: check that the card reached the channel.
PagerDuty takes the 32-character Events API v2
integration key of a service. A late run opens an incident at
severity error, a failed run at critical,
and the recovery resolves it, one PagerDuty incident per Cronheart
incident; a recovery during a snooze still resolves it. "Send test"
opens an info incident and resolves it. If you detach
the channel or delete the monitor while an incident is open,
that incident stays open in PagerDuty: resolve it there.
Google Chat incoming webhooks need a Business or Enterprise Google Workspace account, and your Workspace admin must allow incoming webhooks; a personal Gmail account can't use them. Alerts arrive as a message plus a card.
Email alerts ship a one-click List-Unsubscribe header (RFC 8058) and an "Unsubscribe from these alerts" link at the foot of every email. Recipients who don't want incident emails any more can unsubscribe from their inbox, or open the link and confirm — Cronheart marks the channel as unverified and the channel owner sees that in their dashboard.
Outgoing webhook contract
If you configured a generic webhook channel, alerts arrive as
POST requests with a JSON body and an HMAC-SHA256
signature over the raw body:
X-Cronheart-Signature: sha256=<hex>— verify withhash_hmac('sha256', $rawBody, $yourSecret)andhash_equals. Reject the request if it doesn't match.User-Agent: Cronheart-Webhook/1.0— useful as a receiver-side log filter.Content-Type: application/json
Body shape (stable schema, additive changes only):
{
"schema": 1,
"event": "alert",
"sent_at": "2026-05-06T08:00:00+00:00",
"alert": {
"id": 1234,
"kind": "late",
"urgent": true,
"color": "#f59e0b",
"created_at": "2026-05-06T07:59:30+00:00"
},
"monitor": {
"uuid": "f4a1...",
"name": "morning-batch",
"status": "late"
},
"subject": "[Late] morning-batch · Cronheart",
"preview": "\"morning-batch\" missed its expected ping window (due 07:55 UTC).",
"facts": [
{"label": "Status", "value": "late"},
{"label": "Expected by", "value": "2026-05-06 07:55 UTC"},
{"label": "Late by", "value": "4 min"},
{"label": "Last ping", "value": "2026-05-06 07:50 UTC"},
{"label": "Schedule", "value": "cron \"*/5 * * * *\" (UTC)"},
{"label": "Grace", "value": "4 min"}
],
"body": "<email-flavoured plain-text body>"
}
kind is one of late /
fail / recovered. urgent is
false for recovered, true for late and
fail. color is the same hex Cronheart paints in the
email badge / Slack stripe / Discord embed, so receivers that
forward to another surface stay visually consistent.
Alerts carry facts chosen per kind (for a late alert
Expected by, Late by, Last ping, Schedule and Grace), with times in
the monitor's time zone next to UTC; Status stays the first fact.
facts, preview and body are
display text: route on alert.kind,
alert.created_at and monitor.status,
which have not changed. The payload stays schema: 1.
Status badges
Every monitor exposes a public read-only SVG at
/badge/<uuid>.svg. Drop it into your README to
show the world your nightly-rollup is healthy.

The badge URL is the only public surface that exposes the monitor UUID. We deliberately don't expose ping metadata in the badge — only the colour-coded status — so a stolen badge URL can't be replayed as a ping.
Security model
Monitor UUIDs are treated as per-monitor secrets and never logged at INFO+ on the request path. Audit logs (login, failed logins, impersonation, admin actions) live in a dedicated channel and rotate daily. Email and pings transit over TLS only.
Looking for our Privacy Policy, Cookie Policy, or Terms of Service? Those live on dedicated pages — they are part of the contract you have with Cronheart, and we keep them out of the fast-moving developer docs.