UptimeHunt Docs
API Reference

Cron Monitors API Reference

The Cron Monitors API creates and manages push-based (heartbeat) monitors for scheduled jobs, plus the public ping ingest endpoint jobs report to.

Cron Monitors API Reference

Overview

A cron monitor is a push-based monitor for a scheduled job — a crontab entry, a systemd timer, a CI schedule, a Kubernetes CronJob. Instead of UptimeHunt probing a URL, the job pings the monitor on start/success/fail; the absence of an expected ping within the schedule plus a grace period opens an incident, exactly like a failed active check. See Cron Job Monitoring for the user-facing guide and curl idioms.

Cron monitors are a separate resource from Services — they have their own endpoints under /cron-monitors, their own plan quota (max_cron_monitors, independent of the Monitors limit), and their own capabilities (cronmonitor.*).

Management endpoints (list/create/update/delete/runs/events) require a JWT bearer token or an organization service token and honor the X-Org-Slug active-organization header, the same as every other resource endpoint. The ping endpoint is the one deliberate exception — it's public, and the ping token itself is the credential.

Cron Monitor Object

{
  "id": 7,
  "name": "nightly-db-backup",
  "icon": "database",
  "schedule_kind": "cron",
  "period_seconds": null,
  "cron_expression": "0 4 * * *",
  "timezone": "Europe/Warsaw",
  "grace_seconds": 300,
  "max_runtime_seconds": 1800,
  "drift_pct": 0.5,
  "drift_min_seconds": 30,
  "drift_alerts": true,
  "paused": false,
  "suspended": false,
  "state": "up",
  "display_state": "up",
  "state_since": "2026-07-01T04:00:12Z",
  "last_ping_at": "2026-07-01T04:00:11Z",
  "last_run_status": "success",
  "next_expected_at": "2026-07-02T04:00:00Z",
  "ping_token": "kJ8fQ2mzX7pR4nL9wT1vY6bH3cD5gA0sE2uI4oP8qN1",
  "ping_url": "https://ping.uptimehunt.io/kJ8fQ2mzX7pR4nL9wT1vY6bH3cD5gA0sE2uI4oP8qN1",
  "schedule_human": "every day at 04:00 (Europe/Warsaw)",
  "project_id": 3,
  "integration_ids": [3, 7],
  "source": null,
  "external_id": null,
  "managed": false,
  "recent_runs": [
    { "status": "success", "at": "2026-07-01T04:00:04Z", "duration_ms": 4210, "overlapped": false, "drifted": false },
    { "status": "fail", "at": "2026-06-30T04:00:31Z", "duration_ms": 31200, "overlapped": false, "drifted": true }
  ],
  "avg_duration_ms": 4210.5,
  "overlap_count": 0,
  "created_at": "2026-06-01T00:00:00Z",
  "updated_at": "2026-07-01T04:00:12Z"
}

No slug, project, org, or owner_id — unlike some other resources

Some other list/detail responses in this API (e.g. Services) embed a project/org summary object or a slug. Cron monitors currently do not — the fields above are the complete set. Resolve the owning project/org from project_id / the X-Org-Slug you sent, not from an embedded object.

The ping URL is readable, not write-once

Unlike a probe's connection token, ping_token/ping_url are returned on every read — not just once at creation. That's intentional: the ping URL is meant to be copied into job scripts repeatedly. Rotate it with POST /cron-monitors/{id}/token if it ever leaks; the old URL 404s immediately.

Field Reference

FieldTypeDescription
idintegerUnique monitor identifier
namestringDisplay name (1–128 characters)
iconstring|nullIcon identifier
schedule_kindstringperiod (expect a ping every N seconds) or cron (a crontab schedule)
period_secondsinteger|nullRequired and non-null only when schedule_kind is period. Seconds, minimum 60
cron_expressionstring|nullRequired and non-null only when schedule_kind is cron. See Schedule Grammar
timezonestringIANA tz-database name the cron_expression is evaluated in (default UTC). Ignored in period mode
grace_secondsintegerSlack after the expected fire time before a missing ping opens an incident. Minimum 60, default 300
max_runtime_secondsinteger|nullCap on how long a run may stay running after /start before it's treated as down. null falls back to grace_seconds
drift_pctnumberDuration-drift sensitivity — fraction of the rolling median baseline. Default 0.5
drift_min_secondsintegerAbsolute floor for the duration-drift trigger. Default 30
drift_alertsbooleanWhether duration-drift alerts are emitted for this monitor. Default true
pausedbooleanUser pause — suppresses sweeping and alerting. A success ping auto-resumes it
suspendedbooleanExternal suspension (mirrors a Kubernetes CronJob's spec.suspend). Set/cleared only by the managing source, independent of paused
statestringnew, up, late, down, or paused (the folded display value while paused/suspended); read-only
display_statestringAlias of state (same folded value); read-only
state_sincestring|nullWhen the monitor entered its current state; read-only
last_ping_atstring|nullTimestamp of the most recent ping of any kind; read-only
last_run_statusstring|nullStatus of the most recent run: running, success, fail, or missed; read-only
next_expected_atstring|nullPrecomputed next expected fire time; read-only, null until the schedule is armed
ping_tokenstring43-character ping credential; read-only, readable on every read
ping_urlstringFull ping URL on the dedicated ping host; read-only
schedule_humanstringHuman-readable rendering of the schedule (e.g. every day at 04:00 (Europe/Warsaw)); read-only
project_idinteger|nullProject this monitor belongs to
integration_idsinteger[]Integrations to notify. Empty list falls back to the org's default integrations
sourcestring|nullManaging source for operator-upserted monitors (e.g. kubernetes); read-only
external_idstring|nullStable id from the managing source; read-only
managedbooleanTrue when a source (the operator) keeps this monitor in sync; read-only
recent_runsarrayLast N run summaries {status, at, duration_ms, overlapped, drifted}, embedded so a list/detail render never needs a separate runs call; read-only
avg_duration_msnumber|nullWindowed (7-day) average successful-run duration; read-only
overlap_countintegerCount of overlapping runs in the same 7-day window as recent_runs/avg_duration_ms; read-only
created_at / updated_atstringTimestamps; read-only

Resolving the owning project/org

There is no embedded project/org summary object — look up the project by project_id (e.g. GET /projects/{id}) if you need its name/color, and the org is whichever one you sent via X-Org-Slug (or the caller's default org).

Endpoints

List Cron Monitors

GET /api/v1/cron-monitors

Authentication: Required

Query Parameters:

ParameterTypeDescription
statestringFilter by state (new, up, late, down, paused)
projectstringFilter by project id; "null" for unassigned monitors
page, limitintegerPagination

Every row embeds recent_runs[] and avg_duration_ms — produced by a single aggregation over the whole page of monitors, never a per-row lookup.

Response — 200 OK:

{
  "data": [ /* Cron Monitor objects, each with recent_runs + avg_duration_ms */ ],
  "meta": { "total": 4, "page": 1, "limit": 50 }
}

No totalPages

meta on this endpoint is exactly {total, page, limit} — there is no totalPages; compute it client-side as Math.ceil(total / limit) if you need it.

cURL Example:

curl "https://app.uptimehunt.io/api/v1/cron-monitors?state=down" \
  -H "Authorization: Bearer <token>"

Create a Cron Monitor

POST /api/v1/cron-monitors

Authentication: Required. Requires the cronmonitor.create capability and is subject to the plan's max_cron_monitors limit (Free 10 · Pro 50 · Team 25 · Scale 150 · Enterprise unlimited).

Request Body:

FieldTypeRequiredDescription
namestringYes1–128 characters
schedule_kindstringYesperiod or cron
period_secondsintegerOnly for periodMinimum 60
cron_expressionstringOnly for cronSee Schedule Grammar
timezonestringNoIANA name, default UTC
grace_secondsintegerNoMinimum 60, default 300
max_runtime_secondsintegerNonull falls back to grace_seconds
drift_pct / drift_min_seconds / drift_alertsnumber/integer/booleanNoDefaults 0.5 / 30 / true
pausedbooleanNoDefault false
suspendedbooleanNoDefault false; normally set only by the managing source
project_idintegerNo
integration_idsinteger[]No
source / external_idstringNoTogether they form the upsert key — see below

Supplying both period_seconds and cron_expression (or the one that doesn't match schedule_kind) is a 400 schedule error with code: "schedule_conflict" — this deliberately avoids silently letting one field win over the other.

Upsert by (owner, source, external_id)

When the body carries both source and external_id, the create is an idempotent upsert: a second call with the same pair updates the existing monitor (keeping its ping_token and identity) instead of creating a duplicate. This is what the Kubernetes zero-touch operator relies on to re-reconcile the same CronJob safely.

Response — 201 Created:

The body includes the readable ping_url/ping_token — see the Cron Monitor Object.

cURL Example:

curl -X POST "https://app.uptimehunt.io/api/v1/cron-monitors" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "nightly-db-backup",
    "schedule_kind": "cron",
    "cron_expression": "0 4 * * *",
    "timezone": "Europe/Warsaw",
    "grace_seconds": 300
  }'

Preview / Validate a Schedule

GET /api/v1/cron-monitors/schedule-preview

Authentication: Required

Renders a cron schedule human-readably and lists its next 5 fire times — computed by the same backend evaluator used at create/update time, so this doubles as pre-save validation.

Query Parameters:

ParameterTypeRequiredDescription
cronstringYesThe expression to preview — see Schedule Grammar
tzstringNoIANA name, default UTC

period mode needs no backend call — the expected cadence previews trivially client-side ("every N seconds").

Response — 200 OK:

{
  "text": "Every day at 04:00 (Europe/Warsaw)",
  "next": [
    "2026-07-02T04:00:00+02:00",
    "2026-07-03T04:00:00+02:00",
    "2026-07-04T04:00:00+02:00",
    "2026-07-05T04:00:00+02:00",
    "2026-07-06T04:00:00+02:00"
  ]
}

A bad schedule returns 400 with the schedule error envelope instead.

cURL Example:

curl -G "https://app.uptimehunt.io/api/v1/cron-monitors/schedule-preview" \
  -H "Authorization: Bearer <token>" \
  --data-urlencode "cron=0 4 * * *" \
  --data-urlencode "tz=Europe/Warsaw"

Get a Cron Monitor

GET /api/v1/cron-monitors/{id}

Authentication: Required. A monitor belonging to another organization reads as 404.

cURL Example:

curl "https://app.uptimehunt.io/api/v1/cron-monitors/7" \
  -H "Authorization: Bearer <token>"

Update a Cron Monitor

PATCH /api/v1/cron-monitors/{id}

Authentication: Required. Requires cronmonitor.edit.

Request Body: Any subset of the create fields (all optional). Toggling paused here is the pause/resume control. Changing schedule_kind or a schedule field re-runs the same validation as create (400 schedule error on failure).

cURL Example — pause a monitor:

curl -X PATCH "https://app.uptimehunt.io/api/v1/cron-monitors/7" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"paused": true}'

Delete a Cron Monitor

DELETE /api/v1/cron-monitors/{id}

Authentication: Required. Requires cronmonitor.delete. Invalidates the ping token immediately — subsequent pings to the old URL return 404.

Response: 204 No Content

cURL Example:

curl -X DELETE "https://app.uptimehunt.io/api/v1/cron-monitors/7" \
  -H "Authorization: Bearer <token>"

Rotate the Ping Token

POST /api/v1/cron-monitors/{id}/token

Authentication: Required. Requires cronmonitor.edit.

(Re)issues the ping token. The old ping_token/ping_url become invalid immediately (they 404 at ingest). The new value is returned here and remains readable on every subsequent GET /cron-monitors/{id} — unlike a write-once probe token.

Response — 200 OK:

The body is the full Cron Monitor Object with the new ping_token/ping_url — not a slim {ping_token, ping_url} pair:

{
  "data": {
    "id": 7,
    "name": "nightly-db-backup",
    "ping_token": "nW9xR2kL7mQ4pT1vY6bH3cD5gA0sE2uI4oP8qJ8fZ3",
    "ping_url": "https://ping.uptimehunt.io/nW9xR2kL7mQ4pT1vY6bH3cD5gA0sE2uI4oP8qJ8fZ3"
    /* ...plus every other Cron Monitor Object field */
  }
}

cURL Example:

curl -X POST "https://app.uptimehunt.io/api/v1/cron-monitors/7/token" \
  -H "Authorization: Bearer <token>"

List Runs

GET /api/v1/cron-monitors/{id}/runs

Authentication: Required.

Paginated run history, newest first. Each row carries log_bytes/truncated as has-log indicators — not the log body. Fetch the captured log with Get a Run's Log.

Query Parameters:

ParameterTypeDescription
statusstringFilter by running, success, fail, or missed
from, tostringISO 8601 absolute window
pageintegerDefault 1
limitintegerDefault 50, maximum 200

Response — 200 OK:

{
  "data": [
    {
      "monitor_id": 7,
      "rid": "5f2c9e10-2c9e-4a1b-8c3d-0e1f2a3b4c5d",
      "status": "success",
      "started_at": "2026-07-01T04:00:00Z",
      "finished_at": "2026-07-01T04:00:04Z",
      "exit_code": 0,
      "duration_ms": 4210,
      "log_bytes": 35,
      "truncated": false,
      "overlapped": false,
      "drifted": false,
      "baseline_ms": 3900,
      "source": "shell",
      "meta": null,
      "events": [],
      "@timestamp": "2026-07-01T04:00:00Z"
    }
  ],
  "meta": { "total": 342, "page": 1, "limit": 50 }
}

cURL Example:

curl "https://app.uptimehunt.io/api/v1/cron-monitors/7/runs?status=fail&limit=20" \
  -H "Authorization: Bearer <token>"

Get a Run

GET /api/v1/cron-monitors/{id}/runs/{rid}

Authentication: Required.

Returns one run's metadata, its /log-ping events timeline, and log_bytes/truncatednot the log body. Fetch the captured log with Get a Run's Log.

cURL Example:

curl "https://app.uptimehunt.io/api/v1/cron-monitors/7/runs/5f2c9e10-2c9e-4a1b-8c3d-0e1f2a3b4c5d" \
  -H "Authorization: Bearer <token>"

Get a Run's Log

GET /api/v1/cron-monitors/{id}/runs/{rid}/log

Authentication: Required (cronmonitor.view).

Returns the raw captured log for one run — notes (from /log-ping events) in order, then the capture, capped at 100 KB (102400 bytes). Unlike every other endpoint in this API, the response is not enveloped: the body is the log, Content-Type: text/plain; charset=utf-8, the same way kubectl logs streams a plain-text body.

Response — 200 OK:

HeaderDescription
X-Log-BytesTotal captured bytes (matches the run's log_bytes; 0 means no log was captured)
X-Log-Truncatedtrue/false — whether the capture hit the 100 KB cap
Ping-Body-LimitThe capture cap in bytes (102400) — the same header the ping endpoint returns
Cache-Controlprivate, no-store

The run exists but captured no log → 200 with an empty body and X-Log-Bytes: 0 — this is not an error. 404 is returned only when the run (or its monitor, in the caller's active organization) doesn't exist.

cURL Example:

curl "https://app.uptimehunt.io/api/v1/cron-monitors/7/runs/5f2c9e10-2c9e-4a1b-8c3d-0e1f2a3b4c5d/log" \
  -H "Authorization: Bearer <token>"

Report a Run Event (operator / API)

POST /api/v1/cron-monitors/{id}/events

Authentication: Required (an organization service token with the cronmonitor.edit capability — the Automation preset covers it). This is the route the Kubernetes zero-touch operator uses to report start/success/fail on a CronJob's behalf without ever touching the ping token. It runs the exact same run-upsert + state-transition logic as a public ping.

Request Body:

FieldTypeRequiredDescription
eventstringYesstart, success, or fail
ridstringNo (recommended)Run-correlation id — the operator sends the Kubernetes Job UID. Required for idempotent re-reporting
exit_codeintegerNo0–255
logstringNoCapped at 100 KB, like ping bodies
sourcestringNoshell, kubernetes, or api; default kubernetes
metaobjectNo{job_name, namespace, reason}reason is the Kubernetes Job failure reason (e.g. BackoffLimitExceeded), present only on failures

Response — 202 Accepted:

{
  "data": {
    "monitor_id": 7,
    "rid": "9b2e-...-c1",
    "status": "fail",
    "state": "down"
  }
}

cURL Example:

curl -X POST "https://app.uptimehunt.io/api/v1/cron-monitors/7/events" \
  -H "Authorization: Bearer <service-token>" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "fail",
    "rid": "9b2e-...-c1",
    "exit_code": 1,
    "source": "kubernetes",
    "meta": {"job_name": "nightly-db-backup-28123456", "namespace": "data-platform", "reason": "BackoffLimitExceeded"}
  }'

Schedule Grammar

cron_expression is validated by the same evaluator on every write and on schedule-preview:

  • A standard 5-field crontab expression: minute hour day-of-month month day-of-week (e.g. 0 4 * * *).
  • Exactly one of these macros: @hourly, @daily, @weekly, @monthly, @yearly (alias @annually), @midnight.
  • @reboot is never accepted — there's no fixed wall-clock time to expect a ping against, so there's nothing to monitor.

timezone is a separate, required field — an IANA tz-database name (Europe/Warsaw, America/New_York, default UTC). A raw UTC offset (+02:00) is rejected, and the timezone must never be embedded inside cron_expression itself (no CRON_TZ=…/TZ=… prefix) — always set it via timezone.

Schedule Validation Errors

Every schedule validation failure — on create, update, and schedule-preview — returns the same 400 body, so a client can render one consistent error UI regardless of which validation fired:

{
  "error": "invalid_schedule",
  "code": "invalid_cron_syntax",
  "message": "Invalid cron expression: expected 5 fields, got 6",
  "field": "cron_expression"
}
codeMeaning
invalid_cron_syntaxThe expression/macro couldn't be parsed (or carried a forbidden CRON_TZ=/TZ= prefix, or was @reboot)
unresolvable_scheduleThe expression parses but yields no real occurrence (e.g. 0 0 31 2 * — February 31st)
unknown_timezonetimezone/tz is not an IANA tz-database name (a raw UTC offset is rejected here too)
invalid_field_typeA parameter had the wrong type (e.g. period_seconds wasn't an integer)
schedule_conflictBoth period_seconds and cron_expression were supplied, or the field required by schedule_kind was missing

field names the offending input for inline highlighting, and is null when the error isn't specific to one field.

Filtering Incidents by Cron Monitor

Cron monitor incidents share the same Incidents resource as service incidents — service and cron_monitor are mutually exclusive on each incident. Filter the incidents list to one monitor's incidents with:

curl "https://app.uptimehunt.io/api/v1/incidents?cron_monitor=7" \
  -H "Authorization: Bearer <token>"

Ping API

The ping endpoint is what a job actually calls while it runs — public, unauthenticated beyond the token itself, and deliberately Healthchecks.io-compatible (the de-facto standard most cron-monitoring scripts already target). See Cron Job Monitoring for the full set of curl recipes.

Hosts

GET|POST|HEAD  https://ping.uptimehunt.io/<token>[/<suffix>]

The same handler is also served under the versioned API, for local/self-hosted stacks and as a fallback:

GET|POST|HEAD  https://app.uptimehunt.io/api/v1/ping/<token>[/<suffix>]

Forms

PathMeaning
/ping/{token}Success — "job finished OK"
/ping/{token}/startRun started (opens a running run; enables duration tracking)
/ping/{token}/failExplicit failure
/ping/{token}/logInformational event — no state change, appended to the current run's events
/ping/{token}/{0-255}Exit code — 0 is success, anything else is fail

Every form accepts GET, POST, and HEAD. A request that isn't one of these five forms is rejected.

Authentication

The token is the credential — a 43-character random, unique, rotatable string. No headers are needed. An unknown or revoked token always returns 404, with no distinction between "never existed" and "revoked" (no oracle beyond the 404).

Request Body

On POST, the request body is stored verbatim as the run's log — UTF-8, capped at 100 KB (102400 bytes). The response carries a Ping-Body-Limit: 102400 header stating the cap, so truncation is detectable client-side; the stored run also records truncated/log_bytes.

Query Parameter — rid

?rid=<uuid> is a run-correlation id pairing a /start with its completion (enabling duration + overlapping-run detection). The server upserts the run document by (monitor, rid), so retries/replays of the same rid are idempotent. Omit it and the server synthesizes one; a bare success ping with no rid becomes a complete single-doc run.

Rate Limit

More than 5 pings per minute for one monitor returns 429 and the ping is dropped.

Responses

StatusWhenBody
200Ping accepted (GET/POST)Literal text OK, plus the Ping-Body-Limit header
204Ping accepted (HEAD)No body, plus the Ping-Body-Limit header
404Unknown or revoked token
429Rate cap exceeded

No auto-provisioning

An unknown token is always 404 — pinging never creates a monitor. Create it first with POST /cron-monitors and use the ping_url it returns.

cURL Examples:

# Success
curl -fsS -m 10 --retry 5 -o /dev/null https://ping.uptimehunt.io/<ping_token>

# Explicit failure, with a log body
curl -fsS -m 10 --retry 5 -o /dev/null \
  --data-raw "connection refused after 3 attempts" \
  https://ping.uptimehunt.io/<ping_token>/fail

# Exit code (0 = success, anything else = fail)
curl -fsS -m 10 --retry 5 -o /dev/null https://ping.uptimehunt.io/<ping_token>/1

Capabilities

CapabilityGrants
cronmonitor.viewList/read cron monitors, their runs, and the ping URL
cronmonitor.createCreate cron monitors (subject to max_cron_monitors)
cronmonitor.editUpdate, pause/resume, rotate the ping token, report events
cronmonitor.deleteDelete cron monitors

See Roles & Permissions for which built-in role each capability belongs to.

On this page