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:

StatusMeaning
newCreated or resumed, no ping received since.
upLast expected ping arrived on time.
latePast next_expected_at + grace. Alert fires unless an incident is already open.
downThe last run reported a failure — /fail or a non-zero exit code.
pausedYou paused the monitor from the dashboard. Scanner skips, no alerts.

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:

SegmentCounts as
none, or runHeartbeat
startRun started
success, ok, or the exit code 0Run finished cleanly
fail, or any other exit codeRun errored

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 own CRON_TZ follows 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 /start with 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 with hash_hmac('sha256', $rawBody, $yourSecret) and hash_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.

![status](https://cronheart.com/badge/<uuid>.svg)

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.