Skip to content

Heartbeat (push)

This content is not available in your language yet.

The silent failure nobody notices: the nightly backup that stopped running after an update, the certificate renewal cron that lost its permissions, the Home Assistant automation that broke on a rename. Nothing complains, and you find out the day you need the backup.

A heartbeat monitor (also called a dead man’s switch, or a push monitor in Uptime Kuma) turns the logic around: DumbMonit contacts nothing. The job calls a secret URL each time it runs, and it is the absence of a call that raises the alert.

From the agent to the DumbMonit server:

PortProtocolPurpose
8080TCPHeartbeat call (default port, 443 behind a proxy)

Each heartbeat monitor gets a URL of the form https://<your DumbMonit>/api/push/<token>. The token is a 128-bit random value, stored hashed for lookup and encrypted for display: it is shown on the monitor page, with a copy button, for as long as the monitor exists. It is deliberately open — no session, no API token, no X-Requested-With header — because a curl line in a crontab has none of those. Holding the token only lets you say “the job ran”.

On every scheduler pass (the monitor’s interval, 60 s by default) the collector reads the time of the last call and writes:

Situationprobe_successEffect
No call received yet(nothing written)The monitor shows Waiting. No alert: the job is not in place yet.
Last call within the expected interval + grace1Reporting.
Last call older than the expected interval + grace0, reason="missed"Down; the “Heartbeat missed” rule fires after two minutes.
Last call said status=down0, reason="reported_down"Down at once, whatever the clock says.

A call also re-evaluates the monitor immediately, so a job that resumes clears its alert without waiting for the next scheduler pass.

The endpoint answers 204 with an empty body when the call is recorded, 404 for an unknown or regenerated token, 400 for a status that is neither up nor down, and 429 (with Retry-After) beyond sixty calls per minute for one token. Both GET and POST are accepted; HEAD works too.

ParameterMeaning
statusup (default) or down: the job reports a failure itself.
msgA free word of explanation, kept (truncated to 500 characters) and shown on the monitor page.
exit_codeThe job’s exit code (see below). Anything other than 0 is a failure.

status and msg are the parameters of Uptime Kuma’s push monitor: a script written for it works unchanged.

The plain URL is enough to know that a job ran. To also know how long it took, whether it failed and what it printed, call the same URL with a suffix:

URLMeaning
/api/push/<token>A run that succeeded, of unknown duration (the original call, unchanged).
/api/push/<token>/startThe job starts. Opens a run; the next end call gives its duration.
/api/push/<token>/successThe job succeeded. Same as the plain call, but closes the open run.
/api/push/<token>/failThe job failed.
/api/push/<token>/<code>The job ended with this exit code, for example /2. 0 is a success, anything else a failure.

The exit code can also be given as ?exit_code=2 on /success or /fail; a /success that carries a non-zero code counts as a failure.

With POST, the request body is the output of the job. It is kept truncated to its last 10 KB (the error is almost always at the end; the text is cut on a character boundary, invalid bytes are replaced). A body larger than 256 KB is refused with 413: pipe the output through tail -c 10000. Without a body, msg is kept as the output.

The duration is the time between /start and the end call. A call without a preceding /start has an unknown duration. A new /start while a run is still open marks that run as interrupted. The monitor page lists the last runs (200 per monitor, 90 days at most) with their status, duration, exit code and output.

The same guards apply to every entry point: the token, sixty calls per minute per token, and a bounded body.

The steps below are the ones the notice next to the form shows.

  1. In the address, write a short label for the job, unique among your heartbeats: “nightly-backup”.

  2. Set the expected interval to the job’s schedule (“24h” for a nightly job), or give a cron expression and a time zone, then save the monitor.

  3. Copy the URL shown on the monitor page and call it at the end of the job.

    curl -fsS -m 10 --retry 3 https://monit.example.com/api/push/<token>
  4. To time the job, call …/start before it, then …/success or …/fail after it, with its output as the request body. Send an exit code as ?exit_code=2 or as …/2.

  5. To report a failure at once, append ?status=down&msg=… to the URL.

Nothing is contacted: the label only has to be unique. Until the first call arrives the monitor shows “Waiting” and nothing is alerted. Lost or leaked URL? “Regenerate” on the monitor page issues a new one; the old one stops answering immediately.

Credentials: none.

A crontab line, nightly at 03:00, that only calls in when the backup succeeded:

0 3 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 --retry 3 https://monit.example.com/api/push/<token> > /dev/null

A script that reports its own failure, with the exit code as the message:

#!/bin/sh
URL="https://monit.example.com/api/push/<token>"
if /usr/local/bin/backup.sh; then
curl -fsS -m 10 --retry 3 "$URL" > /dev/null
else
curl -fsS -m 10 --retry 3 "$URL?status=down&msg=backup+exit+$?" > /dev/null
fi

A wrapper that reports the start, the exit code and the output of any command (only the last 10 KB of the output are sent). Replace /usr/local/bin/backup.sh with the job:

#!/bin/sh
URL="https://monit.example.com/api/push/<token>"
curl -fsS -m 10 --retry 3 "$URL/start" > /dev/null
OUT=$(/usr/local/bin/backup.sh 2>&1); CODE=$?
printf '%s' "$OUT" | tail -c 10000 \
| curl -fsS -m 10 --retry 3 --data-binary @- "$URL/$CODE" > /dev/null
exit "$CODE"

$URL/$CODE is /0 after a success and /2, /23… after a failure: one call carries the result, the exit code and the output.

Home Assistant, a rest_command called at the end of an automation:

rest_command:
dumbmonit_heartbeat:
url: "https://monit.example.com/api/push/<token>"
method: post

Windows Task Scheduler (PowerShell):

Terminal window
Invoke-WebRequest -UseBasicParsing -TimeoutSec 10 "https://monit.example.com/api/push/<token>" | Out-Null
KeyLabelDefaultHelp
expected_intervalExpected interval24hHow often the job is supposed to call in: 30m, 1h, 6h, 24h, 7d (or a number of seconds). A missed call is declared once this interval plus the grace period has passed.
graceGrace period10%Extra time tolerated after the expected interval before the heartbeat counts as missed: a percentage of the interval (10%) or a fixed duration (15m). Never less than one minute.
scheduleCron schedule(empty)Instead of the interval: when the job is supposed to start, as a 5-field cron expression (minute hour day-of-month month day-of-week) or a shortcut such as @daily. Empty: use the interval.
timezoneTime zoneUTCIANA time zone in which the cron schedule is read, for example Europe/Paris. It follows daylight saving time.
max_durationMaximum duration(empty)A run that lasts longer than this raises the “Job too long” alert: 30m, 2h. Needs a /start at the beginning of the job. Empty: a run still open after one period plus the grace period counts as stuck.

Durations accept s, m, h, d, w suffixes and can be combined (1h30m); a bare number is a number of seconds. The expected interval must be between 10 seconds and a year.

A job that runs “every day at 02:30” or “on weekdays at 08:00” is better described by a cron expression than by an interval, because the expected time does not drift after a late run. Give schedule (and timezone, UTC by default); the interval is then ignored.

  • Five fields: minute (0-59), hour (0-23), day of month (1-31), month (1-12 or jan-dec), day of week (0-7 or sun-sat, 0 and 7 are Sunday).
  • Each field takes *, a value, a range (8-17), a list (1,15), and a step (*/15, 10-50/10). Names are case-insensitive.
  • Shortcuts: @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly.
  • As in cron, when both the day of month and the day of week are restricted, a date matches if either does.
  • Daylight saving time: when a local time does not exist (spring forward), the run is expected right after the gap; when it happens twice (fall back), it is expected once, at the first occurrence. A schedule on every hour (0 * * * *) follows real time.

The job is late when the last scheduled time plus the grace period has passed with no call (a /start, /success or plain call, even half a minute early, counts). A percentage grace is a percentage of the gap between two scheduled times. A schedule or a time zone that cannot be read is refused when the monitor is saved, with the reason.

All labelled probe="push" in addition to target, host and tag_*, all prefixed dumbmonit_.

MetricMeaning
probe_success1 if the last call is on time and did not report a failure, 0 otherwise. Absent until the first call.
probe_failure_infoPresence (1) with a reason label: missed or reported_down.
push_last_seen_secondsAge of the last call.
push_received_totalCalls received since the token was created (counter).
push_run_failed1 if the last finished run failed (/fail or a non-zero exit code), 0 otherwise. Absent until a run has finished.
push_run_too_long1 if a run is longer than the maximum duration (open or finished), 0 otherwise. Absent until a run is known.
push_last_run_duration_secondsDuration of the last finished run, when a /start preceded it.

Availability over thirty days, as for any service monitor: avg_over_time(dumbmonit_probe_success{probe="push"}[30d]).

Built-in rules that apply (all at warning level):

RuleFires when
Heartbeat missed (push_missed)The job did not call in before its expected interval or cron schedule plus grace period, or reported status=down. Two minutes after the deadline.
Job failed (push_failed)The job did call, but its last run failed: /fail, or a non-zero exit code. The message carries the exit code and the end of the output. It resolves with the next successful run, and speaks again for each new failure.
Job too long (push_too_long)A run lasts longer than max_duration, or, without it, a /start is still open after one period plus the grace period.

A silent job (nothing arrives) and a failing job (something arrives, and it is bad) are therefore two different alerts. A misconfigured schedule is shown on the monitor and refused at save time, never alerted as an outage. The “Service down” and “Service flapping” rules skip heartbeats, and “Monitor unreachable” never fires for them: the collector always runs, only its verdict changes.

RouteSessionPurpose
GET or POST /api/push/<token>noneThe call itself. 204, 404, 400, 413 or 429.
GET or POST /api/push/<token>/start, /success, /fail, /<code>noneStart or end of a run (see above). Same answers; an unknown suffix is a 404.
GET /api/targets/<id>/pushrequiredThe monitor: token, path, last_seen_at, last_seen_age_secs, last_status, last_message, received_total, expected_interval_secs, grace_secs, verdict (waiting, on_time, missed, reported_down), settings_error, schedule, timezone, next_expected_at, max_duration_secs, last_run. Created on first read if missing.
GET /api/targets/<id>/push/runs?limit=&before=requiredThe run history, newest first: runs (id, started_at, finished_at, duration_secs, status among running, success, failed, lost, exit_code, output) and next_before for the next page.
POST /api/targets/<id>/push/regenerateadminNew token; the previous URL answers 404 from then on. The counter and last call are kept.