Heartbeat monitoring for cron jobs

A heartbeat monitor flips the model: instead of Happy Uptime calling out, your job calls in. If the call doesn't arrive within the expected window, the monitor goes down and alerts fire.

When to use it

  • Cron jobs that should run every N minutes/hours/days.
  • ETL pipelines.
  • Background workers that should be processing items.
  • Anything internal that Happy Uptime's edge can't reach.

Setup

1

Create the monitor

Pick type: heartbeat. Set:

  • heartbeat_url — a unique slug, e.g. nightly-etl-prod. This becomes your ping URL.
  • heartbeat_interval (seconds) — how often you expect the job to ping.
  • heartbeat_grace (seconds) — how long after a missed ping before going down. Cover normal job duration.
bash
happy monitors create \ --type heartbeat \ --name "Nightly ETL" \ --heartbeat-url nightly-etl-prod \ --heartbeat-interval 86400 \ --heartbeat-grace 1800
2

Ping from your job

At the end of every successful run, POST to:

text
https://happyuptime.com/api/heartbeat/{your-heartbeat-url}

No auth required (the slug is the secret).

bash
# Last line of your cron scriptcurl -fsS -X POST https://happyuptime.com/api/heartbeat/nightly-etl-prod

Or with a status payload:

bash
curl -X POST https://happyuptime.com/api/heartbeat/nightly-etl-prod \ -H "Content-Type: application/json" \ -d '{ "status": "ok", "message": "Processed 1247 records" }'

Send "status": "fail" when the job ran but failed — the monitor goes down immediately and the message shows in the alert.

3

Verify with a test ping

Run the curl manually. Check the dashboard within 30s — you should see a green up status and the message.

Patterns

Append the curl to the cron line:

cron
0 2 * * * /usr/local/bin/run-etl && curl -fsS -X POST https://happyuptime.com/api/heartbeat/nightly-etl-prod

&& ensures the ping only fires on success. Use ; curl ... to ping regardless of exit code.

js
async function main() { try { await runJob(); await fetch(`https://happyuptime.com/api/heartbeat/${process.env.HEARTBEAT_SLUG}`, { method: "POST", body: JSON.stringify({ status: "ok" }), headers: { "Content-Type": "application/json" }, }); } catch (err) { await fetch(`https://happyuptime.com/api/heartbeat/${process.env.HEARTBEAT_SLUG}`, { method: "POST", body: JSON.stringify({ status: "fail", message: String(err) }), headers: { "Content-Type": "application/json" }, }); process.exit(1); }}main();
yaml
- name: Run job run: ./script.sh id: job- name: Ping heartbeat (success) if: success() run: curl -fsS -X POST https://happyuptime.com/api/heartbeat/${{ secrets.HB_SLUG }}- name: Ping heartbeat (failure) if: failure() run: | curl -X POST https://happyuptime.com/api/heartbeat/${{ secrets.HB_SLUG }} \ -H "Content-Type: application/json" \ -d '{"status":"fail","message":"GitHub Actions job failed"}'

How "down" is detected

The cron scheduler checks every minute: for any heartbeat monitor whose last_checked_at is older than heartbeat_interval + heartbeat_grace, mark it down. Same alert pipeline as any other monitor — Slack page, escalation, status page incident.

Choosing the interval and grace

Job runs everyRecommended heartbeat_intervalRecommended heartbeat_grace
Hour3600600 (10 min)
Day864001800 (30 min)
Week6048007200 (2 hours)

Grace should comfortably cover the longest the job has ever taken. If your daily ETL usually finishes in 15 minutes but occasionally takes 45, set grace to at least 60 minutes.

Ask a question... ⌘I