Cron job monitoring

Cron job monitoring with on-call alerts

A job that silently stops is an outage nobody sees. The backup that last ran three weeks ago, the invoice run that never went out, the queue worker that died on a Friday evening: nothing is down to check, the logs simply end, and the dashboards draw whatever traffic they saw. Quietbeat watches the job itself. The job pings a URL when it finishes; when the ping is late or reports a failure, an incident opens and the person on call is paged.

How it works.

A heartbeat monitor is a dead man's switch for a scheduled job: it alerts on the report that did not arrive, so a job that was never started is caught the same way as one that crashed.

  1. Create a heartbeat monitor

    Say how often the job runs: an interval from 60 seconds (30 from Team up) to 24 hours, or a cron expression read in the time zone you choose. Then say how late a run may be: the grace window, 5 minutes unless you change it. You get a ping URL whose token is the only credential, so no API key goes into the crontab.

  2. Add the ping to the job

    One curl when the job finishes. Optionally one at the start, and one on failure that carries the exit code. A POST body of up to 10 KB — a log tail, a row count — is kept with the ping as evidence.

  3. Silence becomes an incident

    A sweep runs every minute. When the expected time plus the grace window has passed with no finish or fail ping, the monitor is marked down and a missed incident opens. A fail ping opens a failed incident the moment it arrives.

  4. Someone is paged

    Email for the account plus every alert channel, or an escalation ladder that climbs rung by rung to a channel or to whoever is on call when the rung fires. The next successful ping resolves the incident and sends the all-clear to whoever was paged.

Three pings, no auth header.

The token in the path is the whole credential. GET and POST both work, so a crontab with nothing but curl is enough, and a POST body is stored as the run's evidence.

Ping What it means
GET · POST · HEAD /b/TOKEN The run finished. The wait starts again from now, and an open incident on the monitor is resolved.
GET · POST /b/TOKEN/start The run began. A finish or fail ping within a day is paired with it to measure the run. With a slow-run limit set on the monitor, the minute sweep reports a run still open past that limit plus the grace window as missed. A start on its own is not a success.
GET · POST /b/TOKEN/fail The run failed. The monitor goes down and a failed incident opens at once, without waiting for the window to close. ?exit_code=$? carries the shell's exit code; runtime_ms and message are optional too. While an incident is already open, further fail pings are kept as evidence and page nobody again.

The grace window is added to the moment the run was expected, not to the interval: a job due at 03:00 with a 15-minute grace is missed at 03:15. Pings to a paused monitor are accepted and kept, and nothing alerts; during a maintenance window the incident still opens and nobody is paged. The API reference has every parameter.

Copy, paste, replace the token.

Each example sends the finish ping only when the job exits 0 and reports any other exit through /fail. Replace YOUR_PING_TOKEN with the token from your monitor.

crontab

crontab
# the finish ping goes out only when the job exits 0; any other exit reports itself
0 3 * * *  /usr/local/bin/backup.sh && curl -fsS https://quietbeat.dev/b/YOUR_PING_TOKEN || curl -fsS "https://quietbeat.dev/b/YOUR_PING_TOKEN/fail?exit_code=$?"

&& runs the finish ping only when the job succeeded; || runs the fail ping otherwise, and $? is the exit code of whatever failed. -f makes curl exit non-zero on an HTTP error; -sS keeps it quiet except for errors.

systemd timer

backup.service
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
# runs only after ExecStart exited 0
ExecStartPost=/usr/bin/curl -fsS https://quietbeat.dev/b/YOUR_PING_TOKEN
# runs on every outcome; systemd sets $SERVICE_RESULT and $EXIT_STATUS
ExecStopPost=/bin/sh -c '[ "$SERVICE_RESULT" = success ] || curl -fsS "https://quietbeat.dev/b/YOUR_PING_TOKEN/fail?exit_code=$EXIT_STATUS"'
backup.timer
# /etc/systemd/system/backup.timer
[Unit]
Description=Nightly backup at 03:00

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true

[Install]
WantedBy=timers.target

ExecStartPost= runs only after ExecStart= succeeded, so it is the finish ping. ExecStopPost= runs on every outcome with $SERVICE_RESULT and $EXIT_STATUS set by systemd, so it reports the failure. Enable with systemctl enable --now backup.timer.

A wrapper script, for any scheduler

run-backup.sh
# timed run with a sanitized diagnostic tail on both outcomes; exits with the job's own status
curl -fsS https://quietbeat.dev/b/YOUR_PING_TOKEN/start || true
if backup.sh > /tmp/backup.log 2>&1; then rc=0; else rc=$?; fi
if [ "$rc" -eq 0 ]; then
  curl -fsS https://quietbeat.dev/b/YOUR_PING_TOKEN --data-raw "$(tail -20 /tmp/backup.log)" || true
else
  curl -fsS "https://quietbeat.dev/b/YOUR_PING_TOKEN/fail?exit_code=$rc" \
    --data-raw "$(tail -20 /tmp/backup.log)" || true
fi
exit "$rc"

Start, then finish or fail, with a sanitised log tail as evidence on both outcomes. The script exits with the job's own status, so the scheduler sees the real result whether or not a ping got through; --data-raw posts the tail as it is, even when it begins with @. Strip secrets and personal data before they leave the job: the tail is shown wherever the incident is.

GitHub Actions

.github/workflows/nightly.yml
# .github/workflows/nightly.yml
on:
  schedule:
    - cron: "0 3 * * *"

jobs:
  nightly:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: curl -fsS "https://quietbeat.dev/b/YOUR_PING_TOKEN/start?run=${{ github.run_id }}"
      - run: ./scripts/nightly.sh
      - if: success()
        run: curl -fsS "https://quietbeat.dev/b/YOUR_PING_TOKEN?run=${{ github.run_id }}"
      - if: failure()
        run: curl -fsS "https://quietbeat.dev/b/YOUR_PING_TOKEN/fail?run=${{ github.run_id }}"

?run= carries the workflow's run id, so start and finish are paired even when two runs overlap. Keep the ping URL in a repository secret when the repository is public, and size the grace window to cover the time a scheduled workflow may wait for a runner.

Kubernetes CronJob

cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-report
spec:
  schedule: "0 3 * * *"
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      backoffLimit: 0
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: report
              image: ghcr.io/example/report:1.4.2
              command: ["/bin/sh", "-c"]
              args:
                - |
                  curl -fsS https://quietbeat.dev/b/YOUR_PING_TOKEN/start
                  if /app/report; then
                    curl -fsS https://quietbeat.dev/b/YOUR_PING_TOKEN
                  else
                    rc=$?
                    curl -fsS "https://quietbeat.dev/b/YOUR_PING_TOKEN/fail?exit_code=$rc"
                    exit $rc
                  fi

The image needs a POSIX shell and curl. backoffLimit: 0 stops Kubernetes from retrying a failed job on its own; leave it out if you want retries, and the retry that succeeds resolves the incident.

When a job is late or fails.

An incident, with the evidence

A missed window opens an incident whose cause reads "No beat within the grace period"; a fail ping opens one that reads "The job reported the run as failed", with the exit code. Either carries the last payload the job sent, the last and average run duration, and when the job was last heard from.

A page, then the ladder

With no escalation ladder, everything fires at once: email for the account and every alert channel. With one, each rung fires after its delay unless the incident has been resolved or acknowledged in the meantime, to a channel or to whoever is on call when the rung fires — not whoever held the pager when the incident opened.

Repeat until acknowledged

On paid plans a monitor can page again at an interval you set, up to 20 rounds, until someone acknowledges the incident or the next successful ping resolves it.

A status page, if you want one

A heartbeat monitor can sit on a public status page like any other monitor. People who subscribe to the page are told when the incident opens and when it resolves.

Plans.

A heartbeat monitor counts as one monitor, whatever the job's schedule. Every plan includes the ping endpoints, the grace window, start and fail pings, and email alerts.

Plan Monitors Beat expected as often as Price
Hobby 10 every 60 s $0
Solo 50 every 60 s $9/mo
Team 200 every 30 s $19/mo
Business 500 every 30 s $49/mo
Scale 1,000 every 30 s $99/mo

On-call schedules start on Solo. Escalation ladders, repeat-until-acknowledged and the chat, pager and webhook channels are on the paid plans; pricing has the whole table.

Questions people ask.

How do I monitor a cron job?

Create a heartbeat monitor in Quietbeat, tell it how often the job should run and how late a run may be, and append one curl command to the job's command line. Every run that finishes pings the monitor's URL. When no ping arrives by the expected time plus the grace window, Quietbeat opens an incident and alerts the people you chose.

What happens if the job runs but fails?

Report it: a second curl to the monitor's /fail address, with the shell's exit code, runs when the job exits with anything but 0. Quietbeat marks the monitor down and opens an incident at once, with the exit code and any output you post; while that incident is open, further fail pings are kept as evidence and page nobody again. Without the fail ping, a failing job only shows up as silence when its window closes.

Can I alert on a job that hangs?

Yes. Ping the monitor's /start address when the run begins and set a slow-run limit on the monitor. A sweep runs every minute: when it finds a started run past its slow-run limit plus the grace window, or the monitor past its usual schedule plus the grace window, with no finish or fail ping, it reports the run missed. The monitor goes down, an incident opens and it pages. A finish after that resolves the incident, and the time in between counts as downtime.

What is a dead man's switch?

A dead man's switch raises the alarm when an expected signal stops arriving, rather than when something is seen to fail. A heartbeat monitor is one: the job has to keep reporting in, and the absence of a report is what alerts. That is how a job that was never started, or whose server is gone, gets caught.

My job runs weekly. Does that work?

Yes. An interval monitor expects a beat at least every 24 hours, so for anything rarer give the monitor a cron expression instead, such as 0 3 * * 1 for Mondays at 03:00. Quietbeat reads it in the time zone you set and expects the beat at the next occurrence, plus the grace window.

How long is the grace window?

5 minutes unless you change it, from no grace at all up to a day. It is added to the time the run was expected: a job due at 03:00 with a 15-minute grace is missed at 03:15, and the sweep that notices runs every minute.

Is there a free plan?

Yes. The Hobby plan has 10 monitors, heartbeats included, with email alerts and no card. On-call schedules start on Solo; escalation ladders and the chat, pager and webhook channels are on the paid plans. Prices are flat and do not depend on how often the jobs run.

Can a cron job appear on my status page?

Yes. A heartbeat monitor can be shown on a public status page like any other monitor, and people who subscribe to the page are told when its incident opens and when it resolves.

One line at the end of the job.

10 monitors are free, no card. Create a heartbeat monitor, paste the ping into the job, and the next run that does not report in is the first one you hear about. The manual has the full API and MCP for your agent. Weighing tools? See Quietbeat beside Healthchecks.io and Cronitor.

Start monitoring — free