Skip to content

Cron jobs & heartbeats

Uptime checks tell you a server stopped answering. They can’t tell you a nightly backup silently stopped running three weeks ago — nothing is listening on a cron job. A heartbeat inverts the direction: your job calls us when it finishes, and we alert when a call doesn’t arrive on time. Backups, ETL jobs, queue workers, certificate renewals, billing runs, anything on a schedule.

Create a heartbeat monitor in the dashboard, copy its ingest URL, and add one line to your job. Alerts route through the same channels, rules, escalation and maintenance windows as every other monitor — email, Slack, PagerDuty, SMS, webhooks and the rest.

Append the ping to the job with && so it only fires when the job actually succeeded:

0 3 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 --retry 3 https://hb.uptimeglobe.com/hb/YOUR_TOKEN

Every flag there is load-bearing:

  • && — ping only if the job exited 0. With ; instead, a job that fails every night still reports green — the single most common way to build a heartbeat that lies to you.
  • -f — treat our 4xx/5xx as a failure, so a mistyped URL shows up in cron mail instead of silently never pinging.
  • -sS — quiet on success, but still print real errors.
  • -m 10 — cap the request at 10s so a network hang can’t wedge your job.
  • –retry 3 — survive a transient blip. Well inside the rate limit — see Reference.

Prefer wget:

0 3 * * * /usr/local/bin/backup.sh && wget -q -T 10 -t 3 -O /dev/null https://hb.uptimeglobe.com/hb/YOUR_TOKEN

Two numbers define a heartbeat, and we alert on their sum:

  • Expected interval — how often the job should check in. 30 seconds to 24 hours, subject to your plan’s minimum — free plans start at 5 minutes, paid plans at 1 minute.
  • Grace window — how late it may be before we call it missed. 0 to 24 hours, in seconds.

A backup that runs daily and usually takes 10 minutes, with an interval of 24 hours and a grace of 30 minutes, alerts if nothing arrives within 1d 30m of the previous ping. Set grace from how much your job’s finish time actually varies, not from how long it takes — roughly 20% of the interval is a sane starting point. Too tight and a slow night pages someone; too loose and a real failure sits undetected.

An interval cannot express “weekdays at 9am”. The closest you can get is a 24-hour interval, which reports the job missing every Saturday and every Sunday. Switch the schedule to Cron expression and paste the line from your crontab instead — we then expect a ping after each firing of that expression, so quiet weekends stay quiet.

  • 0 9 * * 1-5 — weekdays at 09:00
  • 0 2 * * * — every day at 02:00
  • 0 4 1 * * — the 1st of each month at 04:00
  • */15 * * * * — every 15 minutes

Standard 5-field crontab syntax: ranges (1-5), lists (1,15), steps (*/15), three-letter names (mon-fri, jan), 0 or 7 for Sunday, and the @daily / @weekly / @monthly / @hourly / @yearly shorthands. We read it exactly the way your own cron daemon does — including the long-standing quirk where a day-of-month and a day-of-week that both start with something other than * are combined with OR, not AND. Quartz and Spring syntax (?, L, W, #) and the 6-field seconds form are not supported, and are rejected rather than guessed at.

Set the timezone to your cron daemon’s, which is usually your server’s and often UTC. Daylight saving is handled: a 02:00 job stays at 02:00 local through both transitions. The grace window works the same way as it does for an interval — it starts at each scheduled time, so make it longer than the job normally takes. The create and edit forms list the next few runs we’ll expect; check the days on that list are the ones you meant.

Plenty of jobs are judged by a deadline rather than a cadence — the overnight ETL has to be done before the business opens, whenever it started. Express that with the schedule set to when the job runs and the grace window covering the gap to your deadline:

  • 0 3 * * * — the job starts at 03:00
  • 10800 — grace of three hours — so we alert at 06:00

Point the expression at the run time, not the deadline. Writing 0 6 * * * instead looks like it says “due at 06:00”, and it doesn’t: we expect a ping after each firing, so a job that checks in at 03:05 is then expected again at 06:00 the same day, does not run again until tomorrow, and you get an alert every single morning for a job that is working. The schedule describes your job; the grace window describes your patience.

That works when the job starts at a predictable time. If it is kicked off by something upstream and can begin anywhere in a window, use Due by a time instead — below.

Some jobs are judged only by when they finish. An overnight import starts when the upstream file lands — 01:00 some nights, 05:00 others — and simply has to be done before the business opens. Set the schedule to Due by a time and give the deadline:

  • 0 6 * * * — there must be a check-in before 06:00, every day

The check-in time itself no longer moves anything: whether your job reports at 01:05 or 05:55, the next deadline is tomorrow’s 06:00. That is the difference from Cron expression, which expects a ping after each firing and so assumes it knows when the job runs.

  • No grace — the time you set is the deadline. There is no window after it, and the grace field disappears — a value that did nothing would be worse than none.
  • Late is still late — if 06:00 passes with no check-in we alert, and a check-in afterwards closes that incident and counts against the deadline it missed. The next day is then watched normally — recovering does not skip a day.

A new heartbeat sits in Pending and cannot raise an incident. Monitoring starts at the first ping. So creating a monitor on Friday and wiring the cron on Monday will not page anyone over the weekend, and a Pending monitor that never leaves Pending is itself the signal that the job never ran — or was never wired up.

Each ping marks the monitor up and resets the clock. A missed deadline opens an incident through your normal notification rules; the next ping closes it and sends the recovery. Pings are timestamped on arrival, so the run history shows real check-in times rather than intended schedule times.

With && alone, a failure is expressed by the ping simply not arriving — so we can only report it once the deadline lapses. For a nightly job that means the failure at 03:01 surfaces at 03:00 tomorrow; for a weekdays-only job that fails on Friday, on Monday. Your job already knew at 03:01. Tell us, and the incident opens then — append the exit code, and one line covers both outcomes. $? is the status of the command that just ran, so 0 means success and anything else is a failure:

0 3 * * * /usr/local/bin/backup.sh; curl -fsS -m 10 --retry 3 https://hb.uptimeglobe.com/hb/YOUR_TOKEN/$?

We keep the code, so the alert says the job exited 137 (terminated by signal 9) rather than just “it failed”. Note the ; here is correct and deliberate: the ping must run whether the job succeeded or not. It is only dangerous on the plain URL, which always reports success. Keep --retry 3 — a ping lost to a blip is a missed-job alert for a job that ran.

Don’t reach for job && curl URL || curl URL/fail. It looks equivalent and isn’t: || fires when either the job or the success ping failed, so one flaky network moment reports a failure for a job that worked. If you want two separate URLs rather than the exit code, branch explicitly — and keep it on one line, because cron reads the command to the end of the line and has no continuation character:

0 3 * * * if /usr/local/bin/backup.sh; then curl -fsS -m 10 --retry 3 https://hb.uptimeglobe.com/hb/YOUR_TOKEN; else curl -fsS -m 10 --retry 3 https://hb.uptimeglobe.com/hb/YOUR_TOKEN/fail; fi
  • https://hb.uptimeglobe.com/hb/YOUR_TOKEN — the job succeeded.
  • https://hb.uptimeglobe.com/hb/YOUR_TOKEN/0 — the job succeeded. Identical to the line above, so /$? is safe on the happy path.
  • https://hb.uptimeglobe.com/hb/YOUR_TOKEN/1…255 — the job exited non-zero. Reported down immediately.
  • https://hb.uptimeglobe.com/hb/YOUR_TOKEN/fail — the job failed and has no exit code to hand — from inside a script, a CI step, or an application.

Anything else in that position is a 404 — /succeeded, /ok, /256. We refuse rather than guess, because a typo that reported success would be silent forever. That is the same reason the -f flag is in every example on this page: with it, a wrong URL lands in cron mail on the very first run.

A failure URL is not a test URL. Curling it opens a real incident and notifies whoever is on call — and on a monitor that has never been pinged it also ends Pending and starts monitoring, in the same request. Check your wiring with the plain URL. And treat both as the credential they are: anyone holding the URL can now raise an alert as well as silence one, so if it has been in a CI log or a chat message, rotate it.

A reported failure still counts as a check-in — the job ran — so the clock resets and you get one clear incident instead of that plus a misleading “missed heartbeat” behind it. If the job then stops running entirely, the next missed deadline catches it as usual. And if you have payload checks configured, an explicit failure wins outright: we don’t read the body to argue with a job that just told us it failed.

A job that fails can tell us so. A job that hangs can’t — it just never checks in, so we only notice when its deadline lapses. For a nightly job that is the next morning, while the run has been stuck all night.

Add a second call at the start of the job, and set a maximum run time on the monitor:

0 3 * * * curl -fsS -m 10 https://hb.uptimeglobe.com/hb/YOUR_TOKEN/start; /usr/local/bin/backup.sh; curl -fsS -m 10 --retry 3 https://hb.uptimeglobe.com/hb/YOUR_TOKEN/$?

Now we also know how long each run takes — it shows up as the run time on the monitor page, with the usual median and slowest figures — and if a run passes your maximum without checking in, we open an incident while it is still stuck.

  • Maximum run time — optional, and empty by default. Set it on the monitor, under the schedule. It alerts while a run is still stuck; to alert on a run that finished slowly, use a run-time check instead.
  • A start is not a check-in — it does not reset the deadline and it does not take a new monitor out of Pending. If it did, a job that starts and hangs every night would look permanently healthy.
  • Overlapping runs — if two copies of the job can run at once, add ?rid=<uuid> — the same value on the start and the finish — so each run is timed against its own start. One id per run; generate it in the wrapper.
  • Adopting it — entirely optional. A job that only sends the finish keeps working exactly as before; it just has no run time.
RID=$(uuidgen)
curl -fsS -m 10 "https://hb.uptimeglobe.com/hb/YOUR_TOKEN/start?rid=$RID"
/usr/local/bin/backup.sh
STATUS=$?
curl -fsS -m 10 --retry 3 "https://hb.uptimeglobe.com/hb/YOUR_TOKEN/$STATUS?rid=$RID"

Capture the exit code into a variable first. Writing it inline is where this goes wrong: the code is a path segment and rid is a query parameter, and $? is a shell variable rather than the ? that starts a query string — so the obvious-looking one-liner produces a URL we reject with a 404. One extra line removes the ambiguity entirely.

Ping on success, and let a failure fall through so the deadline lapses and we alert:

const HEARTBEAT_URL = process.env.HEARTBEAT_URL;
async function ping() {
try {
const res = await fetch(HEARTBEAT_URL, {
method: 'POST',
// Name your job: it shows up in your own logs and proxies, and it keeps
// the ping clear of bot filters that refuse generic library agents.
headers: { 'User-Agent': 'nightly-job/1.0' },
signal: AbortSignal.timeout(10_000),
});
// fetch() does NOT reject on 4xx/5xx. Without this check a mistyped,
// rotated or paused URL returns 404 and the job reports success while
// nothing is monitoring it — the same trap the -f flag guards in curl.
if (!res.ok) throw new Error(`heartbeat HTTP ${res.status}`);
} catch (err) {
// Never let telemetry break the job it is measuring.
console.error('heartbeat failed:', err);
}
}
try {
await runNightlyJob();
await ping(); // only on success
} catch (err) {
console.error(err);
process.exit(1); // no ping -> we alert when the deadline passes
}
import os, sys, urllib.request
HEARTBEAT_URL = os.environ["HEARTBEAT_URL"]
def ping():
try:
urllib.request.urlopen(
urllib.request.Request(
HEARTBEAT_URL,
method="POST",
# Set your own User-Agent. Python's built-in default
# (Python-urllib/3.x) is refused with a 403 before the ping
# reaches us. requests, httpx and curl are not affected.
headers={"User-Agent": "nightly-job/1.0"},
),
timeout=10,
)
except Exception as err: # never break the job
print(f"heartbeat failed: {err}", file=sys.stderr)
try:
run_nightly_job()
ping() # only on success
except Exception:
raise # no ping -> we alert

Store the URL as a repository secret — it is a bearer credential, and workflow logs are readable by anyone with repo access. if: success() is what keeps a failed workflow from reporting green:

name: nightly
on:
schedule:
- cron: '0 3 * * *'
jobs:
nightly:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: ./scripts/nightly.sh
- name: Heartbeat
if: success()
run: curl -fsS -m 10 --retry 3 "${{ secrets.HEARTBEAT_URL }}"

Chain the ping in the same container so it inherits the job’s exit status:

apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-backup
spec:
schedule: "0 3 * * *"
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: backup
image: your/backup:latest
command: ["/bin/sh", "-c"]
args:
- /usr/local/bin/backup.sh && curl -fsS -m 10 --retry 3 "$HEARTBEAT_URL"
env:
- name: HEARTBEAT_URL
valueFrom:
secretKeyRef:
name: uptimeglobe-heartbeat
key: url

ExecStartPost only runs when the main command succeeded:

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
ExecStartPost=/usr/bin/curl -fsS -m 10 --retry 3 https://hb.uptimeglobe.com/hb/YOUR_TOKEN

A job can exit 0, ping us, and have done nothing. Send a JSON body with your ping and we’ll alert on its contents — so a backup that runs but copies zero files opens an incident instead of reporting green.

ROWS=$(/usr/local/bin/etl.sh) # your job prints a number
curl -fsS -m 10 --retry 3 https://hb.uptimeglobe.com/hb/YOUR_TOKEN \
-H 'Content-Type: application/json' \
-d "{\"rows_processed\": $ROWS}"

Then add a check on the monitor: rows_processed is greater than 0 → alert me. Nothing else changes — the alert routes through the same channels, rules and maintenance windows as any other incident.

  • Numbers — is greater than / at least / less than / at most. Strict: a value sent as "5" (a string) does not satisfy a numeric check — if your job starts quoting its numbers we tell you instead of quietly passing.
  • Values — equals / does not equal / contains. contains works on a string or an array.
  • Presence — is present / is missing. A field explicitly set to null counts as missing.
  • Nesting — use dots: result.count, items.0.status.
  • Run time — pick how long the run took instead of a field name, and give the bound in seconds. We measure it — see below.

The same checks work on the run time itself, which we measure between your /start ping and the finish. A nightly ETL that normally takes four minutes and suddenly takes forty has not failed — but you want to know.

Choose how long the run took as the subject, in seconds, and set the healthy bound: is less than 300 → alert me pages you when a run passes five minutes. Checks state the condition you expect to hold, the same way rows_processed is greater than 0 does.

  • No body needed — a run-time check reads what we measured, not what you sent. A monitor whose checks are all run-time ones keeps pinging with no JSON body at all.
  • Needs /start — without it there is nothing to measure, so the check is skipped, not failed — adding the rule before you add the start call cannot alert you by mistake.
  • vs. maximum run time — Maximum run time catches a job that is still stuck, alerting mid-run. A run-time check judges a run that finished, and can flag rather than page. They work together.

Two things worth knowing before you rely on it

Section titled “Two things worth knowing before you rely on it”
  • Flag vs alert — “just flag it” marks the run degraded on your dashboard and sends no alert. Choose “alert me” if you want to be paged.
  • No body = failure — once a check on a reported field is configured, a ping with no JSON body — or one we can’t parse — counts as a failure. That is deliberate: a job whose output format silently changes must not keep reporting green. Run-time checks are exempt: they need no body, so a monitor with only those keeps accepting bare pings.
  • Size — bodies over 64 KB are rejected unread. Send metrics, not logs.

The ping itself still counts as a check-in even when a check fails — the job ran, it just did bad work — so you get one clear “bad payload” incident rather than that plus a misleading “missed heartbeat” on top.

Every finished run is recorded and browsable on the monitor’s page: when it finished, how long it took, whether it passed, and the exit code it reported. This is on every plan, including free.

A failure tells you that the job failed. Run history plus captured output tells you why.

Turn on Store this job’s output when editing a heartbeat, and whatever the job sends in the ping body is kept alongside that run:

#!/bin/bash
OUT=$(/usr/local/bin/backup.sh 2>&1)
CODE=$?
curl -fsS -m 10 --retry 3 --data-raw "$OUT" "https://hb.uptimeglobe.com/hb/YOUR-TOKEN/$CODE"

Now a failed run does not just say job_exit_code:1 — you can open it and read rsync: write failed: No space left on device.

It does not have to be JSON. Checks on reported fields need JSON; captured output is just bytes, so a plain log is fine.

  • Off by default. A monitor that has not asked for it behaves exactly as before: we never read the ping body at all.
  • A ping with no body stores nothing. curl $URL on its own costs nothing extra — no body, no stored object.
  • 64 KB per run. Longer output is kept up to that point and marked truncated, so a verbose failure still tells you something.
  • Output is stored on paid plans. Run history itself — timings, outcome, exit code — is recorded on free too.

Two limits, whichever runs out first:

Plan Runs kept per monitor Time kept Output stored
Free 100 30 days —
Starter 500 90 days yes
Pro 2,000 365 days yes
Business 10,000 730 days yes
Enterprise 50,000 unlimited yes

A nightly job on Pro hits neither limit for years. A job pinging every five minutes on free reaches 100 runs in about eight hours, so its history is a rolling window rather than a full month. When a run is dropped its stored output goes with it.

POST https://hb.uptimeglobe.com/hb/YOUR_TOKEN
POST https://hb.uptimeglobe.com/hb/YOUR_TOKEN/0 # success, same as above
POST https://hb.uptimeglobe.com/hb/YOUR_TOKEN/<1..255> # failed with this exit code
POST https://hb.uptimeglobe.com/hb/YOUR_TOKEN/fail # failed
  • Methods — POST, GET and HEAD are all accepted, so a client that can only issue a GET still works. Prefer POST: a URL that responds to GET can be marked up by anything that merely visits it — a link preview in Slack, a prefetcher, a corporate URL scanner. Treat the URL as private.
  • Body — ignored unless the monitor has payload checks configured, in which case it must be JSON — see Check what the job reported.
  • Auth — the token in the URL is the credential. No header, no key.
  • 200 — {"received": true, "request_id": "…"} — recorded.
  • 200 — {"received": true, "recorded": false, "request_id": "…"} — received, but faster than this monitor’s expected interval allows, so not recorded. See Pinging faster than you declared. Not an error: don’t retry it.
  • 404 — unknown, paused, or deleted monitor. Deliberately indistinguishable, so a leaked URL can’t be used to enumerate valid ones. Also an unrecognised outcome suffix — see Reporting a failure.
  • 429 — rate limited, with Retry-After in seconds. The ping is dropped, not recorded.
  • 405 — a method other than POST / GET / HEAD.
  • 503 — temporary — retry.

10 check-ins per minute, per monitor — and a separate 10 start signals. The fastest schedule you can configure is one check-in every 30 seconds, so a job running on its own schedule uses 2 of its 10 and a --retry 3 burst fits comfortably.

The two allowances are deliberately separate. A check-in resets your deadline and can open or close an incident; a start signal only records that a run began. Sharing one budget would let a chatty start signal use up the room your check-in needs — and a throttled ping is dropped, not queued, so that is the one you cannot afford to lose. Adding /start therefore costs you nothing against the limit that matters.

Hitting either limit means something is pinging far faster than it declared — a retry loop or a duplicated cron entry. Because the check-in limit sits five times above the fastest cadence you can configure, being throttled can never by itself cause a missed-job alert.

Each heartbeat records up to two pings per expected interval, with room for a burst of five — the expected interval you set, or the shortest gap in your cron schedule. A job that runs on its own schedule never gets near that, and neither do its retries.

A ping beyond that is answered 200 with "recorded": false and is not stored. Your deadline is never at risk: a job pinging that often still has a ping recorded at least every half interval.

A change of outcome is always recorded, whatever the count — a failure after successes, or a success after a failure — so an outage or a recovery is never hidden.

If you see "recorded": false regularly, your job runs more often than the monitor expects. Set the expected interval to how often it really runs.

The token is a bearer credential and it ends up in crontabs, shell history and CI logs. Rotate it from the monitor’s page — Rotate token. Two things happen immediately: the old URL stops working, and the monitor returns to Pending until the new URL is pinged. Update your job first, then rotate.

Symptom Cause
Ping returns 404 The monitor is paused or deleted, or the token is wrong (check for a truncated copy, or a stale URL after rotating). Re-copy it from the monitor page.
Stuck on Pending No ping has ever arrived. Run the curl line by hand — if it works from your shell but not from cron, cron’s PATH or network egress is the usual culprit.
Alerts when the job did run Grace is too tight for how much the finish time varies, or the ping runs before the slow part of the job. Put it last, and widen grace.
Always green, never alerts The ping is chained with ; instead of &&, so it fires even when the job fails. Either switch to &&, or keep the ; and append /$? so the exit code decides. Also check for a stray browser tab or monitoring script hitting the URL.
Failure ping returns 404 The suffix isn’t one we accept. Only /fail and /0–/255 are valid; anything else is refused rather than guessed at. A shell that sends an empty $? hits the plain URL and reports success — check the variable is actually set.
429 in logs Something is pinging much faster than the configured interval — usually a retry loop or a duplicated cron entry.