Cron and heartbeat monitors
A Heartbeat monitor is inverted monitoring: CompleteStatus doesn't probe your system — your system pings CompleteStatus. If the ping stops arriving on schedule, the monitor goes down and you get alerted. This is the right tool for cron jobs, backups, queue workers, ETL pipelines and anything else that runs on your side of the firewall.
How it works
- When you create a heartbeat monitor, CompleteStatus issues a unique ping token and shows you a ping URL.
- Your job requests that URL (GET or POST — curl, wget, even a browser works) at the end of every successful run.
- Every minute, a sweeper checks all heartbeat monitors. If the last ping is older than expected interval + grace period, the monitor is marked overdue and, once confirmed, goes Down and opens an incident.
- The next ping that arrives immediately resolves the incident and sends recovery alerts.
The token in the URL is the only credential — there is no login on the ping endpoint. Unknown tokens get a 404.
Create a heartbeat monitor
- Go to /monitors/create.
- Choose Type: Heartbeat, pick a project, and give the monitor a name and target (a short identifier for the job, e.g.
nightly-backup). - Set the heartbeat schedule — two modes:
- Simple interval — how often the job should ping: every minute, 5 minutes, 15 minutes, 30 minutes, every hour, every 6 hours, or every day.
- Cron expression — the exact five-field crontab line your job runs on (e.g.
0 3 * * *), plus the timezone it runs in. Paste the same line from your crontab; the form previews the next 3 expected runs so you can sanity-check it. Standard crontab syntax only — if you paste a 6- or 7-field Quartz/systemd expression, the validator tells you which fields to drop.@daily-style shortcuts work too (except@reboot, which has no predictable schedule). - Grace period (both modes) — extra seconds allowed after the expected time before the ping counts as missed (0–86400 seconds; default 60). Use this to absorb normal runtime variance — a backup that takes 5–20 minutes needs a generous grace.
- Max run duration (optional) — if the job signals
/startand hasn't finished within this many seconds, the monitor goes down with the cause "run exceeded Ns". This catches the job that hangs instead of crashing.
- Tick the ownership attestation and save.
The monitor page now shows a Heartbeat panel with your unique ping URL, the schedule, the grace period, the time of the last ping, copy-paste curl examples, and the run log (see below).
The ping URL looks like:
https://completestatus.com/api/hb/<40-character-token>
Every heartbeat monitor also gets two optional companion endpoints on the same token — same GET-or-POST rules, same rate limit:
https://completestatus.com/api/hb/<token>/start # a run is beginning
https://completestatus.com/api/hb/<token>/fail # the run failed — go down now
/startmarks the beginning of a run. The next success (or fail) ping closes the run and records its duration, and it arms the optional max run duration check. A start ping does not reset the missed-window clock — a job that starts and then crashes silently will still miss its window./failreports an explicit failure. The monitor goes Down and opens an incident immediately — no waiting for the missed window, no second sweep to confirm, because an explicit signal is definitive.- A POST body on any signal is kept as a run-log snippet (first 2KB) — handy for an exit message or a log tail. It is stored as plaintext, so never post secrets or sensitive data.
Note: If the panel says no ping token has been generated yet, re-save the monitor to issue one. Editing the monitor's schedule, grace or max duration updates the live token settings.
The run log
Every signal is recorded as a run on the monitor page (the last 50 are shown): when it happened, its outcome — success, fail, timeout (exceeded the max run duration) or still running — and its duration when the run was opened with /start. Above the table you get the average/min/max duration across the listed timed runs, so a job that is slowly getting slower is visible long before it starts missing windows. Runs are kept for 90 days.
Instrument your job
Add a ping to the end of your job so it only fires on success.
Crontab
Chain the ping with && so a failing job never pings:
# run the backup every hour; ping CompleteStatus only if it succeeded
0 * * * * /usr/local/bin/backup.sh && curl -fsS https://completestatus.com/api/hb/YOUR_TOKEN > /dev/null
The flags matter: -f makes curl fail on HTTP errors, -sS keeps it quiet except for real errors — so cron only emails you when the ping itself fails.
Shell script
#!/usr/bin/env bash
set -euo pipefail
do_the_actual_work
curl -fsS "https://completestatus.com/api/hb/YOUR_TOKEN" > /dev/null
wget instead of curl
0 3 * * * /usr/local/bin/nightly.sh && wget -qO /dev/null https://completestatus.com/api/hb/YOUR_TOKEN
With start and fail signals
When you want duration tracking and instant failure alerts instead of waiting out the missed window, bracket the job: signal /start first, then ping success or /fail depending on the exit code:
curl -fsS https://completestatus.com/api/hb/YOUR_TOKEN/start > /dev/null
/usr/local/bin/backup.sh \
&& curl -fsS https://completestatus.com/api/hb/YOUR_TOKEN > /dev/null \
|| curl -fsS https://completestatus.com/api/hb/YOUR_TOKEN/fail > /dev/null
Ping from any language
The ping endpoint is deliberately boring: issue a plain GET or POST to the ping URL from your scheduler, worker or CI step, and it responds 200 OK with the body OK. It is rate-limited to 60 requests per minute per caller, which is far more than any sane schedule needs. Whatever the language, three principles apply everywhere:
- Ping only on success. Run the job first; ping last, and only if the job succeeded. Never ping from a
finallyblock. - Use a short timeout (5–10 seconds). The ping must never hang your job or its scheduler slot.
- Retry where it's cheap (e.g.
curl --retry 3), but never let a failed ping crash the job itself — log it and move on. A single missed ping is absorbed by your grace period anyway.
PHP
Plain PHP needs nothing beyond the standard library — file_get_contents with a stream-context timeout is enough (the curl extension works just as well). Do the work first so an exception or fatal error skips the ping, and swallow ping failures with a log line instead of letting them fail the job.
<?php
function pingHeartbeat(string $url): void
{
$context = stream_context_create(['http' => ['timeout' => 10]]);
if (@file_get_contents($url, false, $context) === false) {
error_log('CompleteStatus heartbeat ping failed');
}
}
run_nightly_backup(); // throws on failure — the ping below never runs
pingHeartbeat('https://completestatus.com/api/hb/YOUR_TOKEN');
Laravel
The Laravel scheduler (Laravel 11+, routes/console.php) has heartbeat pings built in: ->pingOnSuccess($url) requests the URL only when the task exits successfully, which is exactly the semantics you want. Its sibling ->thenPing($url) fires after every run regardless of outcome — avoid it for heartbeats, or a failing task will look healthy forever. Both work for artisan commands and Schedule::call() closures alike.
use Illuminate\Support\Facades\Schedule;
Schedule::command('backup:run')
->dailyAt('03:00')
->pingOnSuccess('https://completestatus.com/api/hb/YOUR_TOKEN');
// or, for ad-hoc work, ping explicitly at the end of the closure:
Schedule::call(function () {
do_the_actual_work();
\Illuminate\Support\Facades\Http::timeout(10)
->get('https://completestatus.com/api/hb/YOUR_TOKEN');
})->hourly();
Python
With requests, ping at the very end of the job with a timeout, and catch request exceptions so a briefly unreachable endpoint doesn't turn a successful job into a crashed one. If your job raises earlier, the ping is never reached — which is the point.
import requests
def main():
run_the_job() # raises on failure — the ping below never runs
try:
requests.get("https://completestatus.com/api/hb/YOUR_TOKEN", timeout=10)
except requests.RequestException as exc:
print(f"heartbeat ping failed: {exc}")
if __name__ == "__main__":
main()
No third-party packages? The standard library does it in one line: urllib.request.urlopen("https://completestatus.com/api/hb/YOUR_TOKEN", timeout=10).
Node.js
Node 18+ ships fetch natively, so no dependency is needed. Wrap the job in an async function, await the work, and only then ping — an exception in the job propagates out and skips the ping. AbortSignal.timeout() keeps the ping from hanging the process.
async function main() {
await runTheJob(); // throws on failure — the ping below never runs
try {
await fetch("https://completestatus.com/api/hb/YOUR_TOKEN", {
signal: AbortSignal.timeout(10_000),
});
} catch (err) {
console.error("heartbeat ping failed:", err.message);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
Go
Go's default http.Client has no timeout, so always construct one with Timeout set. Exit non-zero on job failure before the ping, and treat a failed ping as a log line, not an error.
package main
import (
"log"
"net/http"
"time"
)
func pingHeartbeat() {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get("https://completestatus.com/api/hb/YOUR_TOKEN")
if err != nil {
log.Printf("heartbeat ping failed: %v", err)
return
}
resp.Body.Close()
}
func main() {
if err := runTheJob(); err != nil {
log.Fatalf("job failed: %v", err) // exits — no ping on failure
}
pingHeartbeat()
}
Ruby
Net::HTTP from the standard library is all you need. Let the job raise on failure so execution never reaches the ping, and set both open and read timeouts on the request.
require "net/http"
run_the_job # raises on failure — the ping below never runs
begin
uri = URI("https://completestatus.com/api/hb/YOUR_TOKEN")
Net::HTTP.start(uri.host, uri.port,
use_ssl: true, open_timeout: 10, read_timeout: 10) do |http|
http.get(uri.request_uri)
end
rescue StandardError => e
warn "heartbeat ping failed: #{e.message}"
end
PowerShell / Windows Task Scheduler
On Windows, wrap the job and the ping in one script and register that script as the scheduled task's action (e.g. powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Jobs\nightly-backup.ps1). Check the job's exit code explicitly — a native executable failing does not stop a PowerShell script by default — and only ping when it succeeded.
# nightly-backup.ps1
$ErrorActionPreference = "Stop"
& "C:\Jobs\backup.exe"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # job failed — no ping
try {
Invoke-RestMethod -Uri "https://completestatus.com/api/hb/YOUR_TOKEN" -TimeoutSec 10 | Out-Null
} catch {
Write-Warning "Heartbeat ping failed: $_"
}
Kubernetes CronJob
In a CronJob manifest, chain the ping onto the container command with && so it only runs when the job command exits 0. Keep restartPolicy: Never (or OnFailure) so a failing pod doesn't retry-loop its way into a misleading ping, and make sure the image contains curl (or wget).
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-backup
spec:
schedule: "0 3 * * *"
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
containers:
- name: backup
image: your-registry/backup:latest
command:
- /bin/sh
- -c
- /app/backup.sh && curl -fsS --max-time 10 https://completestatus.com/api/hb/YOUR_TOKEN
GitHub Actions
For scheduled workflows, add a final step guarded with if: success() so it only runs when every previous step passed. Store the ping token as a repository secret rather than committing it — the token is the only credential on the endpoint.
name: Nightly backup
on:
schedule:
- cron: "0 3 * * *"
jobs:
backup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run the backup
run: ./scripts/backup.sh
- name: Ping CompleteStatus heartbeat
if: success()
run: curl -fsS --retry 3 --max-time 10 "https://completestatus.com/api/hb/${{ secrets.CS_HEARTBEAT_TOKEN }}"
Docker / docker compose
For containerized jobs, chain the ping onto the container's command with &&, exactly like crontab — the ping only fires when the job's process exits 0. The same pattern works in docker-compose.yml via command: sh -c "run-the-job && curl -fsS https://completestatus.com/api/hb/YOUR_TOKEN".
docker run --rm your-registry/backup:latest \
sh -c '/app/backup.sh && curl -fsS --max-time 10 https://completestatus.com/api/hb/YOUR_TOKEN'
Start pings and duration tracking
Every monitor's /start endpoint marks the beginning of a run; the success ping (or /fail) that follows closes it and records the duration in the run log. That unlocks two things the plain success ping can't do: duration trends (average/min/max on the monitor page) and "ran too long" detection — set a max run duration and a started run that hasn't finished within it takes the monitor down with the cause Run exceeded Ns, even though nothing has technically missed its window yet. If you don't need durations, skip /start entirely and just size the grace period to absorb your job's runtime variance — the success-only pattern keeps working unchanged.
What triggers an alert
A heartbeat monitor is overdue when now is later than:
last ping time + expected interval + grace period (interval mode)
next expected cron run after the last ping + grace period (cron mode)
Cron-mode deadlines are computed in the schedule's configured timezone, DST shifts included. If the job has never pinged, the clock starts at the monitor's creation time — so a monitor whose job was never wired up will go down on its own and tell you.
Missed pings feed the same state machine as every other check type, which requires two consecutive failures to confirm DOWN. The sweeper runs every minute, so in practice: the first sweep after the deadline records an unconfirmed failure, the next sweep a minute later confirms it, the monitor goes Down, an incident opens with the cause Missed heartbeat (overdue by Ns), and your down alert rules fire. Once down, the sweeper stops piling on results — the incident simply stays open until a ping arrives.
Two signals skip the confirmation wait, because they are definitive rather than inferred: an explicit /fail ping, and a started run exceeding its max run duration (cause Run exceeded Ns). Both take the monitor down and open an incident immediately.
When the success ping does arrive: the monitor flips to Up, the incident is resolved with its total downtime recorded, and up (recovery) alerts fire.
Warning: Ping on success only. If you ping unconditionally (e.g. in a
finallyblock), a job that runs but fails will look healthy forever.
Pausing and muting
- Pause the monitor while a job is intentionally disabled — paused heartbeat monitors are skipped by the sweeper entirely.
- Mute it (1–1440 minutes) during maintenance when the job keeps running but you don't want pages; overdue detection continues but alerts are suppressed.
Alert routing
Route heartbeat events like any other monitor: open the monitor page, add an alert rule with a channel, tick down and up, and set a cooldown. See Getting started for setting up channels.
Related guides
- HTTP uptime monitors — the state machine and incident lifecycle in full.
- API monitors and assertions — for jobs you can probe from outside.