Cron Job Monitoring
Monitor scheduled jobs — cron, systemd timers, CI pipelines, Kubernetes CronJobs — by having them ping UptimeHunt on start/success/fail. A ping that never arrives raises an incident.
Cron Job Monitoring
Cron job monitoring flips the usual model around. Instead of UptimeHunt probing your job from the outside, your job pings UptimeHunt when it runs. If an expected ping doesn't show up within your schedule plus a grace period, UptimeHunt opens an incident and fires your alerts — exactly as if a probe had detected a service down.
This is the right tool for anything that isn't a reachable URL: nightly backups, database migrations, report generators, log rotation, cleanup scripts — any job that runs on a schedule and needs someone to notice when it doesn't.
A separate resource from Services
Cron monitors are their own resource — Cron Jobs in the sidebar — not a Service type. They have their own quota (separate from your Monitors limit): Free 10 · Pro 50 · Team 25 · Scale 150 · Enterprise unlimited.
Create a cron monitor
- Go to Cron Jobs in the sidebar and click Add Cron Monitor.
- Fill in the form:
- Project — optional, to group it with related services.
- Name — e.g.
nightly-db-backup. - Schedule — pick Cron expression (a crontab-style schedule, evaluated in a timezone you choose) or Fixed interval (simpler: "expect a ping every N seconds/minutes/hours", no cron syntax).
- Grace period — how long to wait past the expected time before treating a missing ping as down (minimum 60 s, default 5 min).
- Notify via — which integrations to alert; leave everything unchecked to use your organization's default channels.
- If you chose a cron expression, the form shows a live preview — the schedule in plain English plus its next 5 fire times — computed by the backend as you type, so you can confirm it means what you think before saving.
- Save. The monitor starts in the
newstate until its first ping arrives.
Get your ping URL
Every cron monitor has a ping URL on its detail page, of the form:
https://ping.uptimehunt.io/<ping_token>The token is a 43-character random string and is the credential — no headers, no login, just hit the URL. Unlike other UptimeHunt tokens, the ping URL is re-displayable: it's meant to be copied into job scripts repeatedly, so it's shown in full on the detail page every time, not just once at creation. If it ever leaks, rotate it from the same page — the old URL 404s immediately.
Wrong host, dev/self-hosted stacks
https://ping.uptimehunt.io/... is the production ping host. On a local or self-hosted stack, the same handler is also served under the versioned API — replace the host with your instance and add /api/v1/ping/, e.g. https://app.example.com/api/v1/ping/<ping_token>.
Ping from your job
Five URL forms are accepted, each over GET, POST, or HEAD:
| Form | Meaning |
|---|---|
<url> | Success — "job finished OK" |
<url>/start | Run started (opens a run so duration can be measured) |
<url>/fail | Explicit failure |
<url>/log | Informational note — doesn't change state |
<url>/<0-255> | Exit code — 0 means success, anything else means fail |
A response body sent with POST is stored as the run's log (up to 100 KB, UTF-8), viewable later on the monitor's Runs tab. An unknown or rotated token always returns 404.
Success-only
The simplest form: only pings when the job exits 0. A crash is silent — no fail run is recorded, so you only find out once the missed-ping grace period elapses.
your-job.sh && curl -fsS -m 10 --retry 5 -o /dev/null https://ping.uptimehunt.io/<ping_token>-f— treat an HTTP error status as a curl failure (so a broken ping doesn't get silently swallowed)-s -S— silent, but still show an error if one occurs-m 10— 10-second timeout for the whole request--retry 5— retry transient network failures instead of losing the ping to a blip-o /dev/null— discard the response body (the ping URL only ever repliesOK)
Exit-code trap (recommended)
Reports every run by sending the job's real exit code as the URL suffix, so a crash shows up immediately as a fail run instead of just going quiet until the grace period expires:
your-job.sh; curl -fsS -m 10 --retry 5 -o /dev/null "https://ping.uptimehunt.io/<ping_token>/$?"Pipelines and $? — turn on pipefail
$? only reflects the exit status of the last command in a pipeline. If your job is your-job.sh | tee /var/log/job.log, a failure inside your-job.sh is masked by tee's own (usually 0) exit code — the trap above would wrongly report success. Add set -o pipefail (bash/zsh) near the top of the script so $? reflects the pipeline's real failure:
#!/usr/bin/env bash
set -o pipefail
your-job.sh | tee /var/log/job.log
curl -fsS -m 10 --retry 5 -o /dev/null "https://ping.uptimehunt.io/<ping_token>/$?"Exit-code trap + captured log
Same as above, but also attaches the job's output as the run's log — a failure alert then carries the last lines of output, so you don't have to SSH in to see what went wrong:
LOG=$(your-job.sh 2>&1); curl -fsS -m 10 --retry 5 -o /dev/null --data-raw "$LOG" "https://ping.uptimehunt.io/<ping_token>/$?"You can pipe output directly instead of capturing it in a variable first — the same pipefail caveat applies:
set -o pipefail
your-job.sh 2>&1 | curl -fsS -m 10 --retry 5 --data-binary @- -o /dev/null https://ping.uptimehunt.io/<ping_token>Start + finish, with a run correlation id
For accurate duration tracking (and to detect overlapping runs), ping /start before the job and the completion form after, both carrying the same rid query parameter:
RID=$(uuidgen)
curl -fsS -m 10 --retry 5 -o /dev/null "https://ping.uptimehunt.io/<ping_token>/start?rid=$RID"
your-job.sh
curl -fsS -m 10 --retry 5 -o /dev/null "https://ping.uptimehunt.io/<ping_token>/$?" -G --data-urlencode "rid=$RID"Pings are rate-limited to 5 per minute per monitor — anything above that is dropped with 429, so don't call /log in a tight loop.
Choosing a schedule
Grammar and timezone rules, exactly as validated
Get these two things right and the schedule always behaves the way the preview shows it:
cron_expressionis either a standard 5-field crontab string (minute hour day-of-month month day-of-week, e.g.0 4 * * *) or exactly one of these macros:@hourly,@daily,@weekly,@monthly,@yearly(alias@annually),@midnight.@rebootis not accepted — there's no fixed wall-clock time to expect a ping against, so it has nothing to monitor.timezoneis a separate, required field — an IANA tz-database name such asEurope/WarsaworAmerica/New_York(defaultUTC). A raw UTC offset (+02:00,GMT+2) is rejected, and the timezone must never be embedded in the expression itself (noCRON_TZ=0 4 * * *orTZ=Europe/Warsaw 0 4 * * *prefix) — always set it via thetimezonefield so daylight-saving transitions are handled correctly.
If you'd rather not think in cron syntax at all, use Fixed interval mode instead — "expect a ping every N seconds" — which skips schedule parsing entirely.
An invalid schedule is rejected with a 400 naming exactly what's wrong (bad syntax, @reboot, an offset instead of an IANA name, or an expression that parses but never actually fires, like 0 0 31 2 * for February 31st) — see the API reference for the full error shape.
Drift detection
Drift detection flags a run whose duration strays far from that monitor's own recent history — a classic sign of a hung or degrading job. It's opt-in (off by default) and, once enabled, only ever marks a run degraded — it never opens or forces an incident by itself.
Duration is only measured for a monitor that pings /start and a completion form sharing the same rid — a monitor pinged success/fail-only, with no /start, has no duration to compare and drift never fires for it.
Turn it on and tune it from your monitor's detail page:
Baseline
The baseline duration is the arithmetic mean of the last drift_baseline_runs successful AND non-drifted runs (3–50, default 5) — a run already flagged drifted: true doesn't count toward the sample, so the baseline window can reach further back than a literal "last N successes" reading implies. Drift detection stays unarmed — nothing can be flagged — until that many qualifying runs have completed.
Drift mode
Pick one comparison mode, not two combined:
- Percentage of baseline (
percent, the default) — the threshold (default 50%) is a percentage of the baseline duration, and drift fires in either direction. A job that normally takes 120s is flagged whenever a run deviates from that baseline by more than the threshold — either much slower (past ~180s) or unexpectedly fast (under ~60s) — a sign of a hung/degrading job or an unexpected early exit. - Fixed amount (
absolute) — the threshold is a fixed number of seconds a run may deviate from the baseline, regardless of how long the baseline itself is.
Long-running jobs
max_runtime_seconds ("Max runtime") is a separate, always-optional cap on how long a run may stay running after its /start ping — leave it empty and there is no fallback, a long run never alerts on duration alone:
- Left empty (the default) — no runtime cap. A run that takes an unusually long time is caught only by drift detection, if you've enabled it — it never becomes
downby itself. - Set (e.g., 1 hour) — a run still
runningpast this duration is treated asdownand alerts immediately, independent of drift. This catches jobs that start but never finish.
Drift is off by default for Kubernetes autodiscovered monitors
When the operator creates a monitor from a CronJob, drift detection stays disabled (drift_alerts: false) until you turn it on — either with the monitor.uptimehunt.io/drift-enabled annotation, or directly on the monitor's detail page. Autodiscovered monitors are fully editable in UptimeHunt; see Kubernetes zero-touch monitoring for how your edits survive the operator's reconcile.
Pausing, suspending, and rotating
- Pause (the button on the monitor's detail page) stops sweeping and alerting until you resume it — or until the next successful ping, which auto-resumes it for you.
- Suspended is a separate, read-only-in-the-UI state that only appears when the monitor is managed by the Kubernetes zero-touch operator and the underlying
CronJobhasspec.suspend: true. It behaves like a pause (no sweeping, no alerts) but can only be lifted by un-suspending the CronJob in the cluster — it's independent of the manual pause, so re-enabling the CronJob can't silently un-pause a monitor you paused by hand. - Rotate token issues a new ping URL immediately; the old one starts returning
404. Update every job script that pings the old URL before (or right after) rotating.
List view
The Cron Jobs list supports the same Normal / Dense / Cards view-mode toggle as Services — pick your preference in Settings → Preferences → Lists layout. A single quiet, bordered box wraps each project group's rows in Normal/Dense view, with flat rows separated by thin dividers; Cards view shows card tiles directly on the page with no header or container. History strips pack each run's result as densely as the available width allows.
Each bar's height also encodes that run's duration, so a run that took unusually long visibly stands taller than a quick one at a glance. A missed run still renders as a fixed-height hollow block rather than collapsing to zero — no ping means no data, not a fast run, so it must never look like the fastest bar on the strip.
Related Documentation
- Kubernetes zero-touch monitoring — no script changes, CronJobs are discovered automatically
- GitLab CI scheduled pipelines
- systemd timers
- Import from crontab
- Cron Monitors API Reference
- Incidents
- Alerting