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.
I've set up heartbeat pings for every cron job I run in production. Some of them I got wrong the first time. This post covers the patterns that actually work, the flags you should use, and the mistakes I made so you don't have to.
If you're not sure what heartbeat monitoring is or why your cron jobs need it, read my article on dead man's switch monitoring first. This post assumes you already have a monitoring service set up — I use WatchCron, but the curl patterns work with any heartbeat API.
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://watchcron.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 WatchCron 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. That's a lot of coverage for 80 characters.
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.
My go-to combination: -fsS --retry 3 --max-time 10. I 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://watchcron.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
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 \
https://watchcron.com/ping/your-uuid > /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.
I use inside-the-script for anything I wrote myself, and inline crontab for third-party tools where I 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, I use three signals:
#!/bin/bash
PING="https://watchcron.com/ping/your-uuid"
# Signal: job started
curl -fsS --retry 3 --max-time 10 "$PING/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" > /dev/null
else
# Signal: failure
curl -fsS --retry 3 --max-time 10 "$PING/fail" > /dev/null
exit 1
fi
With /start, WatchCron 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="https://watchcron.com/ping/your-uuid"
# Automatically report failure if any command fails
trap 'curl -fsS --max-time 10 "$PING/fail" > /dev/null' ERR
# Signal start
curl -fsS --retry 3 --max-time 10 "$PING/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" > /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. I use this pattern in every production script — it's fewer lines and harder to mess up than manual error checking.
Sending job output for debugging
WatchCron accepts POST bodies up to 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="https://watchcron.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" > /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://watchcron.com/ping/your-uuid > /dev/null
When I look at the ping log in my dashboard and see "gzip: /backups/mydb.sql: No space left on device", I know exactly what went wrong without logging into the server. This has saved me real debugging time more than once.
Real-world examples
Here are the actual cron entries I use in production, with the monitoring baked in. Not sanitized examples, these are running right now (with UUIDs changed, obviously).
Database backup with S3 upload
#!/bin/bash
set -e
PING="https://watchcron.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/fail" > /dev/null' ERR
curl -fsS --retry 3 --max-time 10 "$PING/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/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" > /dev/null
The size check is important. I once had a backup that was "successful" (pg_dump ran and gzip ran) but the dump was empty because the database connection string was wrong. The file existed but was tiny. Now I validate the file size before declaring success.
SSL certificate renewal check
#!/bin/bash
set -e
PING="https://watchcron.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 | 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/fail" > /dev/null
exit 1
fi
curl -fsS --retry 3 --max-time 10 -X POST \
--data-raw "SSL OK: $DAYS_LEFT days remaining" \
"$PING" > /dev/null
I run this daily. It doesn't renew the cert (certbot handles that). It just 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://watchcron.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://watchcron.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://watchcron.com/ping/uuid-rates > /dev/null
The queue worker check is an interesting one. Instead of monitoring "did the job run," I'm 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 I get an alert. Simple and effective.
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://watchcron.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 a lot of headaches.
Common mistakes I've made
Here are the things I got wrong before I got them right:
Redirecting stderr to /dev/null in the crontab. I had 2>/dev/null on a cron line that included the && chain. The script failed, curl didn't run, and I couldn't figure out why because the error was going to /dev/null. Now I only redirect curl's stdout, not the entire command chain's stderr.
Missing --max-time. A DNS issue caused curl to hang for 2+ minutes on each retry. My backup script that normally takes 5 minutes took 11 because curl was blocking. With --max-time 10, the total curl time is capped at 10 seconds no matter what.
Pinging before validation. My backup script pinged success right after pg_dump, before checking if the file was actually valid. The dump was corrupt (disk error), but the ping had already been sent. Now I 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. I spent 20 minutes debugging why a script wasn't pinging, only to discover the monitoring endpoint URL had a typo. With -sS, I would have seen "curl: (6) Could not resolve host" immediately.
Getting started
Pick your most important cron job. Add one curl line. Set up an alert channel. That's all it takes.
If you want to try it with WatchCron, the free tier gives you 5 monitors — enough to cover your critical jobs. Create a check, copy the ping URL, and paste it into your script or crontab.
For Laravel developers, there's a cleaner approach using built-in scheduler methods — I covered that in monitoring Laravel scheduled commands. And for understanding why this whole approach works, see what a dead man's switch is and why your cron jobs need one.
Next up, I'll write about the 5 types of cron job failures and how to catch each one — because not all failures are created equal.
If you have a curl pattern I didn't cover, or a creative use of heartbeat pings, I'd like to hear about it.
Vitalii Holben