# Cron job monitoring with Healthchecks.io: a push on your phone when a job fails or never runs

> Monitor cron jobs with Healthchecks.io, hosted or self-hosted, and get a phone push when a job fails, hangs or silently stops running. Ping URLs with /start and exit codes, curl flags that survive a bad network, runitor, and two ways to route alerts to Pocket Alert. Plus a small wrapper for when you don't want another service.

*27.10.2026*

<!-- source: https://pocketalert.app/blog/cron-job-monitoring-healthchecks -->

A cron job can break in two ways. It can run and fail: `pg_dump` can't reach the database, the disk is full. Or it can stop running at all: a crontab line gets deleted, the server is rebuilt without it, the cron daemon is dead. The first kind leaves an error somewhere. The second leaves nothing, and you find out the day you need last night's backup.

Alerting that waits for a job to say "I failed" never hears from a job that didn't start. To catch that you need the opposite setup: the job reports in every time it finishes, and something else raises the alarm when the report doesn't come.

This guide builds that with [Healthchecks.io](https://healthchecks.io), the open-source cron monitor, and sends its alerts to your phone with Pocket Alert. Failures arrive loud, recoveries arrive quiet. Everything below was tested against the self-hosted Healthchecks v5.0.3 Docker image, and works the same on the hosted service.

## What you need

- A Healthchecks account on healthchecks.io (the free Hobbyist plan monitors 20 jobs), or your own instance from the `healthchecks/healthchecks` Docker image.
- The Pocket Alert app on your [iPhone or Android](/get-app) phone and an API key from [API Keys](/account/apikeys). The Free plan is enough to start.
- `curl` on the machine that runs the cron jobs.

## The dead man's switch

Healthchecks calls this heartbeat monitoring, a kind of dead man's switch. Each job gets a **check** with a unique ping URL that the job requests when it finishes. If the next request doesn't arrive in time, the check goes **down** and alerts go out.

Two settings decide "in time":

- **Period** is how often you expect a ping: every hour, every day.
- **Grace time** is how long Healthchecks waits past the expected time before calling it down. Set it longer than the job usually takes, plus some slack.

For a cron job, pick the **Cron** schedule type instead and paste the expression from your crontab, plus the server's time zone. Healthchecks then expects pings at the right times of day, not just at the right intervals. The [cron expression editor](/tools/cron) spells out what an expression means.

In our test, a check with schedule `*/2 * * * *` and a one-minute grace time was pinged at 16:09:13. Nothing pinged it at 16:10, and the check went down at 16:11:00, exactly one grace period later.

The ping URL has a few variants:

<div class="cmp-table-wrap">
<table class="cmp-table">
<thead><tr><th>Request</th><th>Meaning</th></tr></thead>
<tbody>
<tr><th><code>https://hc-ping.com/&lt;uuid&gt;</code></th><td>Success. The job finished.</td></tr>
<tr><th><code>https://hc-ping.com/&lt;uuid&gt;/start</code></th><td>The job started. Healthchecks now expects a success within the grace time and records the run duration.</td></tr>
<tr><th><code>https://hc-ping.com/&lt;uuid&gt;/fail</code></th><td>The job failed. The check goes down immediately.</td></tr>
<tr><th><code>https://hc-ping.com/&lt;uuid&gt;/&lt;0-255&gt;</code></th><td>An exit status. <code>0</code> counts as success, anything else as failure.</td></tr>
</tbody>
</table>
</div>

On a self-hosted instance the base is your own domain plus `/ping/`, for example `https://hc.example.com/ping/<uuid>`.

## Wire up the cron job

Create a check, copy its ping URL, and change the crontab line. We ran this under the cron daemon on Debian 13:

```bash
PING=https://hc-ping.com/your-check-uuid
15 3 * * * curl -fsS -m 10 --retry 5 -o /dev/null $PING/start; /usr/local/bin/backup.sh; curl -fsS -m 10 --retry 5 -o /dev/null $PING/$?
```

`PING=` is a crontab variable, which the shell expands in the command. The line does three things:

- It sends `/start`, so a job that hangs goes down once the grace time runs out. In our test, a `/start` with no follow-up turned the check down exactly one grace period later.
- It runs the job.
- It sends `$?`, the job's exit status. A clean run sends `/0`, a failed one sends `/2` or whatever the script returned, and the check goes down right away.

The curl flags keep the ping from causing problems of its own:

- **`-m 10`** caps each request at 10 seconds. A stuck `/start` must not hold up the job.
- **`--retry 5`** retries timeouts and 408, 429, 500, 502, 503 and 504 responses, with growing delays. One dropped packet shouldn't page you.
- **`-fsS`** makes curl fail on HTTP errors and stay quiet unless something goes wrong, so cron doesn't mail you curl's progress bar.
- **`-o /dev/null`** throws away the response body, which is just `OK`.

If the job is a pipeline like `pg_dump | gzip > dump.gz`, its exit status is the status of `gzip`, not `pg_dump`. Move it into a script with `set -o pipefail` in bash, so a failed dump doesn't report success.

### Or let runitor do it

[runitor](https://github.com/bdd/runitor) is a small Go binary that wraps a command and handles the pings: `/start` before, the exit status after, and the last 10,000 bytes of the command's output attached to the ping.

```bash
curl -fsSL -o /usr/local/bin/runitor https://github.com/bdd/runitor/releases/download/v1.4.1-build.6/runitor-v1.4.1-build.6-linux-amd64
chmod +x /usr/local/bin/runitor
```

The crontab line gets shorter:

```bash
15 3 * * * CHECK_UUID=your-check-uuid /usr/local/bin/runitor -- /usr/local/bin/backup.sh
```

For a self-hosted instance, add `HC_API_URL=https://hc.example.com/ping`. We ran runitor v1.4.1 this way under cron: a failing backup turned the check down with exit status 2, and its error output came through in the alert.

## Send alerts to Pocket Alert

Healthchecks reaches Pocket Alert through its generic **Webhook** integration. Go to **Integrations**, find **Webhook** and click **Add Integration**. The form has two sections, "Execute when a check goes down" and "Execute when a check goes up", each with its own URL, method, body and headers. That split lets "down" be loud and "up" quiet.

Both sections accept placeholders: `$NAME`, `$NAME_JSON`, `$STATUS`, `$NOW`, `$CODE`, `$SLUG`, `$TAGS`, `$EXITSTATUS`, `$BODY`, `$BODY_JSON`, `$JSON` and a few more. For JSON bodies, mind the difference: `$NAME` is pasted raw, so a check called `Nightly "db" backup` breaks the JSON, while `$NAME_JSON` is a complete JSON string, quotes included. The same goes for `$BODY` and `$BODY_JSON`.

### Option 1: straight to the API

In the **down** section, choose `POST` and the URL `https://api.pocketalert.app/v1/messages`. Request headers, one per line:

```text
Content-Type: application/json
Token: YOUR_API_KEY
```

Request body:

```json
{
  "title": $NAME_JSON,
  "message": "DOWN since $NOW. Exit status of the last ping: $EXITSTATUS",
  "level": "high",
  "actions": [{"type": "view", "label": "Open check", "value": "https://healthchecks.io/checks/$CODE/details/"}]
}
```

In the **up** section, the same method, URL and headers, and a quieter body:

```json
{
  "title": $NAME_JSON,
  "message": "Back up at $NOW",
  "level": "low"
}
```

Click **Save Integration**. A new integration is attached to all existing checks in the project. This is what Healthchecks sent when our test check failed:

```json
{
  "title": "Nightly \"db\" backup",
  "message": "DOWN since 2026-10-31T16:15:01+00:00. Exit status of the last ping: 2",
  "level": "high",
  "actions": [{"type": "view", "label": "Open check", "value": "https://healthchecks.io/checks/a4b72803-3a50-4b81-b33f-88dd2200ffc2/details/"}]
}
```

Some notes on this body:

- **`high` is time-sensitive.** It breaks through Focus and the scheduled summary. On a paid plan, change it to `critical` and a dead backup job wakes you through the silent switch and Do Not Disturb. On the Free plan `critical` is accepted and delivered as `high`.
- **`low` for recoveries.** "Back up" lands in the feed without a sound. You want to know, but not at 3 a.m.
- **`$EXITSTATUS` tells failed from missing.** It is the exit status of the last ping before the alert. `2` means the job ran and returned 2. A down with `0` means the last run succeeded and the next one never came. `-1` means the last ping carried no exit status: a plain success URL, `/fail` or `/start`.
- **The button** opens the check in Healthchecks. For a self-hosted instance, put your own domain in the URL. [Action buttons](/features/action-buttons) are a paid feature; on the Free plan they are removed and the message itself is delivered as usual.

### Option 2: a Pocket Alert webhook with a template

The second way sends a small JSON document to a Pocket Alert webhook, which formats the push. Its advantage: it can include the job's output.

Open [Webhooks](/webhooks) in the Pocket Alert console, create one named `Healthchecks`, and set the message template:

```text
%name% is %status% since %time%
%log%
```

Leave **Default priority** unselected, because the payload carries its own level. Copy the webhook URL, which looks like `https://p4a.me/wh/abc123xyz`. The URL is the secret, so treat it like a password.

In Healthchecks, create the webhook integration with that URL as `POST` in both sections, the header `Content-Type: application/json`, and these bodies. Down:

```json
{"name": $NAME_JSON, "status": "$STATUS", "time": "$NOW", "code": "$CODE", "log": $BODY_JSON, "level": "high"}
```

Up:

```json
{"name": $NAME_JSON, "status": "$STATUS", "time": "$NOW", "code": "$CODE", "log": $BODY_JSON, "level": "low"}
```

`$BODY_JSON` is the body of the last ping, which is where runitor puts the job's output. When the runitor-wrapped backup failed in our test, the push read:

> **Webhook Healthchecks**
> DB backup (runitor) is down since 2026-10-31T16:15:02+00:00
> dumping appdb
> pg_dump: error: connection to server on socket failed: No such file or directory
>
> [runitor] exit status 2

Pocket Alert reads the top-level `level` field of an incoming payload, so `high` and `low` work as in Option 1. Without runitor, `$BODY_JSON` is an empty string and the second line is blank. The API can't do this: `$BODY_JSON` is a complete JSON string, so it can only be the whole `message`, and when the ping had no body that message is empty and the API rejects it.

Two other differences: the push title is always `Webhook <name>`, so the check name goes into the message, and webhook buttons are set through the `actions` field of the webhooks API, not the console form.

### Test it

On the Healthchecks **Integrations** page, every integration has a **Test!** button. It sends a "down" notification for a check named `TEST`, which arrives as a `high` push. Then run a job that exits with `1` and watch the real alert arrive.

## Without Healthchecks: a wrapper that pushes on failure

If you don't want another service, a short wrapper covers the most common case, a job that exits with an error. Save it as `/usr/local/bin/cron-alert`:

```bash
#!/bin/sh
# Usage: cron-alert <job name> <command> [args...]
name="$1"
shift

log=$(mktemp)
"$@" >"$log" 2>&1
status=$?

if [ "$status" -ne 0 ]; then
  curl -fsS -m 10 --retry 3 --retry-max-time 30 -o /dev/null \
    -H "Token: $POCKETALERT_API_KEY" \
    --form-string "title=Cron failed: $name on $(hostname)" \
    --form-string "message=Exit status $status. $(tail -c 2000 "$log")" \
    --form-string "level=high" \
    "${POCKETALERT_API_URL:-https://api.pocketalert.app/v1/messages}"
fi

cat "$log"
rm -f "$log"
exit "$status"
```

And in the crontab:

```bash
POCKETALERT_API_KEY=your-api-key
15 3 * * * /usr/local/bin/cron-alert "nightly backup" /usr/local/bin/backup.sh
```

Some deliberate choices:

- **`--form-string` instead of JSON.** The API accepts multipart form data, and `--form-string` sends the output as-is, quotes and newlines included, with no `jq` or escaping. Plain `-F` would treat a value starting with `@` or `<` as a file.
- **`tail -c 2000`** keeps the end of the output, where the error usually is.
- **`--retry-max-time 30`** matters. Over the daily message limit, the API answers `429` with a `Retry-After` of the seconds until midnight UTC, and curl's `--retry` honors it. In our test, without this flag curl sat waiting on `Retry-After: 30000` until we killed it. With it, curl gives up after one attempt.
- **The wrapper keeps the exit status and prints the output**, so cron logs and `MAILTO` still work as before.

It passes shellcheck, and under cron on Debian 13 the failing backup arrived as "Cron failed: nightly backup on" plus the hostname, with the `pg_dump` error in the text.

Be clear about what this can't do. The wrapper only runs when cron runs it. If the crontab line is deleted, the daemon is down or the machine is off, nothing is sent: the silent failure from the start of this article. A job that hangs forever goes unnoticed too. For those you need something outside the machine waiting for a heartbeat, which is what Healthchecks is for. The [Pocket Alert CLI](/tools/cli) can replace the `curl` call if you prefer.

## Honest limits

- **Healthchecks has to be up to notice.** On the hosted service that's their job. If you self-host it on the same box as the cron jobs, a dead box takes the monitor down with it. Run it somewhere else.
- **The body template is limited to 1,000 characters** in Healthchecks. That is plenty for the bodies above, but not for a long hand-written message.
- **Long output is cut in the push.** The push carries roughly the first 3 KB of the message; the full text is in the message history in the app.
- **Each alert is one message.** A flapping job that goes down and up every few minutes sends a push every time, and on the Free plan's 50 messages a day that adds up. Fix the job, or raise the grace time.

## If nothing arrives

- **Use the Test! button first.** If the test arrives but real alerts don't, check that the integration is enabled for that check on the check's page.
- **Look at the Integrations page in Healthchecks.** A failing integration is marked **Failed** there; hover over it to see the last error, such as `Received status code 401`. A `401` means the `Token` header is wrong. A `400` usually means the body isn't valid JSON: check that you used `$NAME_JSON` without quotes around it, and not `"$NAME"`.
- **A new check doesn't alert until its first ping.** Until then it is "new", not "down".
- **Check the time zone of cron checks.** If a check is always an hour early or late, the server's time zone doesn't match the one on the check.
- **Look in the feed.** `low` pushes make no sound, so a recovery can look like it never arrived.

## FAQ

### Should I use the API route or the webhook route?

The API route puts the check name in the push title and needs nothing created in Pocket Alert. The webhook route can include the job's output, so with runitor, take the webhook.

### Can I get only the "down" alerts?

Yes. Use a quiet `low` body for "up", as above, or leave the "up" URL empty and Healthchecks sends nothing on recovery.

### What about systemd timers?

Same idea. Healthchecks has an **OnCalendar** schedule type that takes the same expressions as your timer. Send `/start` from `ExecStartPre`, or wrap the `ExecStart` command in runitor.

### Where can I read more about cron alerts?

The [cron job alerts](/use-cases/cron-job-alerts) page covers sending a push from the job itself, [backup alerts](/use-cases/backup-alerts) covers backup tools, and the [Proxmox guide](/blog/proxmox-alerts) covers backups on a hypervisor.

[Create a free account](/signup), add one webhook integration in Healthchecks, and let the job that quietly stopped running be the one that wakes you up.
