How to monitor cron jobs with curl and a heartbeat API

Every heartbeat tutorial says 'add curl to your script.' But wrong flags, wrong placement, or wrong error handling means you're not actually monitored. Here are the patterns that work — and the mistakes I made so you don't have to.

Every heartbeat monitoring tutorial starts the same way: "add curl to the end of your script." And technically, that's all there is to it. But there are a dozen ways to get it wrong — wrong flags, wrong placement, wrong error handling — and each one creates a false sense of security where you think you're monitored but you're actually not.

This post covers the patterns that actually work, the flags you should use, and the mistakes worth avoiding. The curl patterns here work with any heartbeat monitoring service — WatchCron, Healthchecks.io, Cronitor, or anything that accepts HTTP pings.

The one-liner that covers 80% of cases

Here's the simplest version. Add this to your crontab:

0 2 * * * /usr/local/bin/backup.sh && curl -fsS --retry 3 --max-time 10 https://monitoring.example.com/ping/your-uuid > /dev/null

The && operator is doing the heavy lifting here. It means "run curl only if the previous command exited with code 0." If backup.sh fails, curl never runs, the ping never arrives, and your monitoring service alerts you after the grace period.

This single line handles the three most common failure modes: the job doesn't run at all, the job runs but exits with an error, or the server is offline.

Understanding the curl flags

Those flags aren't random. Each one prevents a specific problem:

-f (fail silently on HTTP errors)

Without -f, curl exits with code 0 even if the server returns a 500 error. With -f, curl returns a non-zero exit code on HTTP errors (4xx and 5xx). You probably don't need this for a monitoring ping where you don't check curl's exit code, but it's a good habit.

-s (silent)

Suppresses the progress bar and other output. Without it, curl writes progress data to stderr, which can pollute your cron email or log files.

-S (show errors even in silent mode)

The -s flag hides everything, including errors. Adding -S brings error messages back while keeping the progress bar hidden. So you get clean output on success, but if the network is down, you'll see "curl: (7) Failed to connect" in your logs.

--retry 3

Retries the request up to 3 times if it fails due to a transient error (connection timeout, DNS failure, etc.). Network hiccups happen. Without retries, a momentary blip means a missed ping and a false alert at 3 AM.

--max-time 10

Limits the entire operation to 10 seconds. If the monitoring service is slow or unreachable, curl won't hang forever and block your cron schedule. Without this, a stalled curl can prevent the next cron job from running if you have overlap prevention.

> /dev/null

Redirects stdout to nowhere. The monitoring endpoint returns "OK" or similar — you don't need it. Some people use -o /dev/null instead, which does the same thing.

The go-to combination: -fsS --retry 3 --max-time 10. Use this on every heartbeat ping.

Inline crontab vs. inside the script

There are two places to put the curl call: directly in the crontab line, or inside the script itself. Both work, but the trade-offs are different.

In the crontab

0 2 * * * /usr/local/bin/backup.sh && curl -fsS --retry 3 --max-time 10 https://monitoring.example.com/ping/your-uuid > /dev/null

Pros: you can see all your monitoring at a glance by reading crontab -l. Easy to add or remove without touching your scripts.

Cons: the line gets long and hard to read. And && only checks the exit code — it doesn't know whether your script actually did its job correctly.

Inside the script

#!/bin/bash
set -e

PING_URL="https://monitoring.example.com/ping/your-uuid"

pg_dump mydb > /backups/mydb_$(date +%Y%m%d).sql
gzip /backups/mydb_$(date +%Y%m%d).sql

# Only reached if everything above succeeded
curl -fsS --retry 3 --max-time 10 "$PING_URL" > /dev/null

Pros: cleaner crontab. You can add custom validation before pinging (like checking if the backup file is non-empty). The script is self-contained.

Cons: if you have scripts you don't control or can't modify, inline crontab is the only option.

Use inside-the-script for anything you wrote yourself, and inline crontab for third-party tools where you can't (or don't want to) modify the script.

The start/success/fail pattern

A single ping at the end tells you "the job finished." But it doesn't tell you whether the job started and got stuck, or never started at all. For critical jobs, use three signals:

#!/bin/bash

PING_URL="https://monitoring.example.com/ping/your-uuid"

# Signal: job started
curl -fsS --retry 3 --max-time 10 "$PING_URL/start" > /dev/null

# Your actual work
if pg_dump mydb > /backups/mydb.sql 2>&1; then
  # Signal: success
  curl -fsS --retry 3 --max-time 10 "$PING_URL" > /dev/null
else
  # Signal: failure
  curl -fsS --retry 3 --max-time 10 "$PING_URL/fail" > /dev/null
  exit 1
fi

With /start, the monitoring service knows the job began. If the /start signal arrives but nothing else comes within the grace period, it means the job is hanging somewhere. That's a failure mode you can't detect with a simple "ping on success" approach.

The /fail signal triggers an immediate alert without waiting for the grace period. If your backup fails at 2:01 AM, you find out at 2:01 AM — not 30 minutes later when the grace period expires.

Using bash trap for automatic failure reporting

Manually wrapping every command in if/else gets tedious, especially in long scripts. The trap command is cleaner:

#!/bin/bash
set -e

PING_URL="https://monitoring.example.com/ping/your-uuid"

# Automatically report failure if any command fails
trap 'curl -fsS --max-time 10 "$PING_URL/fail" > /dev/null' ERR

# Signal start
curl -fsS --retry 3 --max-time 10 "$PING_URL/start" > /dev/null

# If any of these commands fail, the trap fires automatically
pg_dump mydb > /backups/mydb.sql
gzip /backups/mydb.sql
aws s3 cp /backups/mydb.sql.gz s3://my-backups/
rm /backups/mydb.sql.gz

# Signal success (only reached if nothing failed)
curl -fsS --retry 3 --max-time 10 "$PING_URL" > /dev/null

trap ... ERR fires whenever any command exits with a non-zero code (which set -e also causes the script to exit on). It's the bash equivalent of a try/catch block. This pattern works well in production scripts — fewer lines and harder to mess up than manual error checking.

Sending job output for debugging

Most heartbeat APIs accept POST bodies (typically up to 10-100 KB). You can send your script's output along with the ping, which is useful for debugging failed jobs without SSH-ing into the server:

#!/bin/bash
set -e

PING_URL="https://monitoring.example.com/ping/your-uuid"

# Capture all output
OUTPUT=$(
  pg_dump mydb > /backups/mydb.sql 2>&1 &&
  gzip /backups/mydb.sql 2>&1
)

# Send output with the success ping
curl -fsS --retry 3 --max-time 10 \
  -X POST --data-raw "$OUTPUT" \
  "$PING_URL" > /dev/null

Or the simpler version that captures everything:

0 2 * * * OUT=$(/usr/local/bin/backup.sh 2>&1); curl -fsS --max-time 10 -X POST --data-raw "$OUT" https://monitoring.example.com/ping/your-uuid > /dev/null

When you look at the ping log and see "gzip: /backups/mydb.sql: No space left on device," you know exactly what went wrong without logging into the server.

Real-world examples

Here are practical cron setups with monitoring baked in. These patterns come from real production use.

Database backup with S3 upload

#!/bin/bash
set -e

PING_URL="https://monitoring.example.com/ping/uuid-db-backup"
BACKUP_FILE="/backups/mydb_$(date +%Y%m%d_%H%M%S).sql.gz"

trap 'curl -fsS --max-time 10 "$PING_URL/fail" > /dev/null' ERR
curl -fsS --retry 3 --max-time 10 "$PING_URL/start" > /dev/null

pg_dump -Fc mydb > "${BACKUP_FILE%.gz}"
gzip "${BACKUP_FILE%.gz}"

# Validate: backup should be at least 1MB
FILE_SIZE=$(stat -f%z "$BACKUP_FILE" 2>/dev/null || stat -c%s "$BACKUP_FILE")
if [ "$FILE_SIZE" -lt 1048576 ]; then
  echo "Backup file suspiciously small: $FILE_SIZE bytes"
  curl -fsS --max-time 10 -X POST \
    --data-raw "Backup file too small: $FILE_SIZE bytes" \
    "$PING_URL/fail" > /dev/null
  exit 1
fi

# Upload to S3-compatible storage
aws s3 cp "$BACKUP_FILE" s3://my-backups/ --quiet

# Clean up local files older than 7 days
find /backups -name "mydb_*.sql.gz" -mtime +7 -delete

curl -fsS --retry 3 --max-time 10 -X POST \
  --data-raw "Backup size: $FILE_SIZE bytes" \
  "$PING_URL" > /dev/null

The size check matters. A "successful" backup can still be empty — the dump runs, gzip runs, but the file is tiny because the database connection string was wrong. Validating the file size before declaring success prevents that false confidence.

SSL certificate renewal check

#!/bin/bash
set -e

PING_URL="https://monitoring.example.com/ping/uuid-ssl-check"

# Check if cert expires within 30 days
EXPIRY=$(openssl s_client -connect mysite.com:443 -servername mysite.com \
  </dev/null 2>/dev/null | openssl x509 -noout -enddate | cut -d= -f2)

EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s 2>/dev/null || date -jf "%b %d %T %Y %Z" "$EXPIRY" +%s)
NOW_EPOCH=$(date +%s)
DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))

if [ "$DAYS_LEFT" -lt 30 ]; then
  curl -fsS --max-time 10 -X POST \
    --data-raw "SSL expires in $DAYS_LEFT days!" \
    "$PING_URL/fail" > /dev/null
  exit 1
fi

curl -fsS --retry 3 --max-time 10 -X POST \
  --data-raw "SSL OK: $DAYS_LEFT days remaining" \
  "$PING_URL" > /dev/null

Run this daily. It doesn't renew the cert (certbot handles that). It verifies the cert is still valid. If certbot's renewal cron fails silently, this catches it before the cert actually expires.

Simple crontab one-liners

Not everything needs a full script. For simple tasks, inline works fine:

# Clean up temp files older than 24h
0 4 * * * find /tmp/app-uploads -mtime +1 -delete && curl -fsS --retry 3 --max-time 10 https://monitoring.example.com/ping/uuid-cleanup > /dev/null

# Restart queue worker if it's not running
*/5 * * * * pgrep -f "queue:work" > /dev/null && curl -fsS --max-time 10 https://monitoring.example.com/ping/uuid-queue > /dev/null

# Pull latest exchange rates
0 */6 * * * /usr/local/bin/fetch-rates.sh && curl -fsS --retry 3 --max-time 10 https://monitoring.example.com/ping/uuid-rates > /dev/null

The queue worker check works differently. Instead of monitoring "did the job run," you're monitoring "is the process alive." If pgrep finds the worker process, it pings. If the worker crashed, pgrep returns non-zero, the ping is skipped, and you get an alert.

wget as an alternative to curl

Some minimal Docker containers or embedded systems don't have curl installed. wget works too:

# wget equivalent of the curl one-liner
0 2 * * * /usr/local/bin/backup.sh && wget -qO /dev/null --tries=3 --timeout=10 https://monitoring.example.com/ping/your-uuid

-q is quiet mode (like curl's -s), -O /dev/null sends output to nowhere, --tries=3 retries on failure, --timeout=10 limits the connection time. Different flags, same result.

If neither curl nor wget is available (some Alpine containers), you can use pure bash with /dev/tcp, but honestly — just install curl. It's a few megabytes and saves headaches.

Five curl mistakes that create false confidence

A few patterns that cause problems in practice:

Redirecting stderr to /dev/null in the crontab. Adding 2>/dev/null to a cron line that includes the && chain hides errors from the main script. If the job fails, you can't figure out why because the error output is gone. Only redirect curl's stdout, not the entire command chain's stderr.

Missing --max-time. A DNS issue can cause curl to hang for 2+ minutes on each retry. A backup script that normally takes 5 minutes takes 11 because curl is blocking. With --max-time 10, the total curl time is capped at 10 seconds no matter what.

Pinging before validation. If the backup script pings success right after pg_dump, before checking if the file is actually valid, a corrupt dump (disk error) goes undetected. The ping was already sent. Always validate the output before pinging.

Forgetting set -e. Without set -e, a failing command in the middle of a script doesn't stop execution. The script continues to the curl line and pings success even though step 3 of 5 failed. Either use set -e or check each command's exit code manually.

Using -s without -S. Silent mode hides everything, including errors. Debugging why a script isn't pinging is much harder when curl silently swallows "Could not resolve host" errors. With -sS, errors are visible while the progress bar stays hidden.

Start with your most critical job

Pick your most important cron job. Add one curl line. Set up an alert channel. That covers the biggest gap in most server setups.

If you want a monitoring service that understands cron expressions and shows the next five scheduled runs, WatchCron has a free plan with 20 checks — enough to cover the critical jobs. For Laravel developers, there's a cleaner approach using built-in scheduler methods covered in monitoring Laravel scheduled commands.

For understanding why this whole approach works, see what a dead man's switch is and why your cron jobs need one. And for the different ways cron jobs break, the 5 types of cron job failures covers each failure mode and how to catch it.