Heartbeat Monitors
Heartbeat monitors invert the direction - your job tells us it is alive. Two modes: Scheduled (expected interval) and Event-Receiver (dormant; fires only when triggers match the payload).
MODES
- Scheduled: if no beat arrives within the configured interval, status flips to MISS and alerts fire.
- Scheduled explicit fail: call the same URL with ?status=fail when the job ran but detected a bad result. This records a KO beat and fires down handling immediately.
- Event-Receiver: status stays dormant. Each beat stores its payload; triggers with conditions evaluate against it. ?status=fail is ignored for Event-Receiver mode because trigger conditions decide what matters.
OK beat:
curl -fsS https://keepitalive.dev/heartbeat/{token}
Explicit failed beat:
curl -fsS "https://keepitalive.dev/heartbeat/{token}?status=fail"SCHEDULED RECIPES
*/5 * * * * curl -fsS https://keepitalive.dev/heartbeat/{token} >/dev/null0 2 * * * /usr/bin/backup.sh && curl -fsS https://keepitalive.dev/heartbeat/{token}curl -fsS "https://keepitalive.dev/heartbeat/{token}?status=fail&payload=disk+full"*/1 * * * * if ping -c 1 -W 2 192.168.1.50 >/dev/null 2>&1; then \
curl -fsS "https://keepitalive.dev/heartbeat/{token}"; \
else \
curl -fsS -X POST "https://keepitalive.dev/heartbeat/{token}?status=fail" \
-d "unreachable"; \
fi:: C:\scripts\keepalive_heartbeat.bat
@echo off
curl -fsS https://keepitalive.dev/heartbeat/{token} >nul 2>&1
:: schtasks /create /tn "KEEPitALIVE" /tr C:\scripts\keepalive_heartbeat.bat /sc minute /mo 5CRON SCHEDULES
A plain heartbeat expects a beat every N seconds, which is the wrong shape for a job that runs on a calendar: a weekday-only backup looks 65 hours late every Sunday. Set a cron expression instead and the deadline becomes the next scheduled run plus the grace window - so nothing is late until something was actually due. Pick the timezone the job runs in: the schedule is evaluated there, which is what keeps an 03:00 job at 03:00 when the clocks change instead of drifting an hour twice a year. The expression also sets the cost, since it decides how many runs an hour we expect - a nightly job is far cheaper than a 30-second monitor. The ingest URL is unchanged; only our expectation of when a beat is due differs.
# Give the monitor the same schedule the job runs on.
0 3 * * 1-5 # 03:00, weekdays only
*/15 * * * * # every 15 minutes
0 3 1 * * # 03:00 on the 1st of the month
@daily # midnight
# Pick the zone the job runs in, not the server's: that is
# what keeps 03:00 at 03:00 when the clocks change.EXIT CODES
Append your job's exit status to the URL and the beat reports itself: 0 is a success, anything else is a failure. This is better than chaining with && , which sends nothing at all when the job fails - so the monitor only goes down later, when the window is missed, instead of the moment the job breaks. The code is stored on the beat, so a trigger condition can route on which failure it was: payload.exit_code == 137 (killed for memory) can open an outage while payload.exit_code == 1 only notifies. Codes must be 0-255; you can also use /fail if you have no code to hand.
# $? is the exit status of the job that just ran
0 2 * * * /usr/bin/backup.sh; curl -fsS https://keepitalive.dev/heartbeat/{token}/$?
# Send the job output too, so the alert says why it failed.
# Capture it first: in a pipeline $? is the last command's status,
# not the job's.
0 2 * * * out=$(/usr/bin/backup.sh 2>&1); \
curl -fsS --data-binary "$out" https://keepitalive.dev/heartbeat/{token}/$?RUN DURATION
Ping /start when the job begins and send a normal beat when it ends, and the elapsed time is stored on the beat as duration_ms, so a trigger condition can alert on a slow run - payload.duration_ms > 300000 catches a backup that took over five minutes even though it eventually succeeded. /start is entirely optional: a single terminal beat is a complete report on its own, and miss detection and exit codes work without it. Sending one buys exactly two things - a recorded duration, and detection of a run that never finishes. A /start on its own reports nothing and does not touch the monitor status: a job that starts and then hangs must still trip its missed window, or the feature would hide exactly the failure it is meant to expose. A second /start while a run is still open is refused, because two starts with no finish between them mean the first run never reported; the refusal lifts once that run has overrun, so a job killed before its final beat is never locked out of future runs. Runs left open for more than 24 hours are discarded rather than blamed on a later beat.
# /start is optional. Without it you still get miss detection
# and the exit status - just no duration and no stuck-run alert:
0 2 * * * /usr/bin/backup.sh; curl -fsS https://keepitalive.dev/heartbeat/{token}/$?
# With it, the run is timed and a job that never finishes is caught:
0 2 * * * curl -fsS https://keepitalive.dev/heartbeat/{token}/start; \
/usr/bin/backup.sh; \
curl -fsS https://keepitalive.dev/heartbeat/{token}/$?STUCK RUNS
If a job pings /start and then hangs, nothing is late yet — the start refreshed nothing, so the monitor looks fine until the next beat would have been due. That gap is the whole problem with timing a job: a stuck run is exactly the failure you wanted to catch. So a run that has not sent its terminal beat goes down once it exceeds the interval plus the grace window, with an error saying how long it has been running. There is no separate max-runtime setting: a late beat and a stuck run are the same question — how long do we wait before calling it — so the grace value you already set answers both.
PAYLOADS
POST JSON to your heartbeat URL to report system metrics or arbitrary fields. The Latest Report card on the monitor detail page renders the latest payload as pretty JSON when possible, with raw text fallback.
curl -fsS -X POST https://keepitalive.dev/heartbeat/{token} \
-H 'Content-Type: application/json' \
-d '{"job":"nightly-backup","status":"ok","rows":42850}'EVENT RECEIVER: CI/CD MAINTENANCE
Event Receiver mode is useful for deploy pipelines. Create a payload trigger with condition payload.status == "maintenance" and incident classification maintenance. Send a maintenance payload before deploy and a running/ok payload in an if: always() final step. The incident opens while the expression matches and closes when the next accepted payload clears it.
- name: Start maintenance
run: |
curl -fsS -X POST "$KEEPITALIVE_HEARTBEAT_URL" \
-H "Content-Type: application/json" \
-d '{"status":"maintenance","source":"github-actions","sha":"${{github.sha}}"}'
- name: Deploy
run: ./deploy.sh
- name: End maintenance
if: always()
run: |
curl -fsS -X POST "$KEEPITALIVE_HEARTBEAT_URL" \
-H "Content-Type: application/json" \
-d '{"status":"running","source":"github-actions","sha":"${{github.sha}}"}'Event receivers throttle accepted payloads by the monitor interval. For very short deploy jobs, use the smallest interval your plan allows or delay the final payload until the interval has elapsed. If the source goes silent, KEEPitALIVE does not assume recovery.
#!/bin/bash
URL="https://keepitalive.dev/heartbeat/{token}"
CPU=$(top -bn1 | grep "Cpu" | awk '{print $2}')
RAM=$(free -m | awk '/Mem/{printf "%.1f", $3/$2*100}')
DISK=$(df -h / | awk 'NR==2{print $5}')
curl -fsS -X POST $URL \
-H "Content-Type: application/json" \
-d "{\"hostname\":\"$(hostname)\",\"cpu\":\"$CPU%\",\"ram\":\"$RAM%\",\"disk\":\"$DISK\"}"$url = "https://keepitalive.dev/heartbeat/{token}"
$os = Get-CimInstance Win32_OperatingSystem
$body = @{
hostname = $env:COMPUTERNAME
cpu = "{0:N1}%" -f (Get-CimInstance Win32_Processor).LoadPercentage
ram = "{0:N1}%" -f ((1 - $os.FreePhysicalMemory / $os.TotalVisibleMemorySize) * 100)
} | ConvertTo-Json
Invoke-RestMethod -Uri $url -Method POST -Body $body -ContentType "application/json"