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
| Field | Type | Description |
|---|---|---|
id | integer | Unique monitor identifier |
name | string | Display name (1–128 characters) |
icon | string|null | Icon identifier |
schedule_kind | string | period (expect a ping every N seconds) or cron (a crontab schedule) |
period_seconds | integer|null | Required and non-null only when schedule_kind is period. Seconds, minimum 60 |
cron_expression | string|null | Required and non-null only when schedule_kind is cron. See Schedule Grammar |
timezone | string | IANA tz-database name the cron_expression is evaluated in (default UTC). Ignored in period mode |
grace_seconds | integer | Slack after the expected fire time before a missing ping opens an incident. Minimum 60, default 300 |
max_runtime_seconds | integer|null | Cap on how long a run may stay running after /start before it's treated as down. null falls back to grace_seconds |
drift_pct | number | Duration-drift sensitivity — fraction of the rolling median baseline. Default 0.5 |
drift_min_seconds | integer | Absolute floor for the duration-drift trigger. Default 30 |
drift_alerts | boolean | Whether duration-drift alerts are emitted for this monitor. Default true |
paused | boolean | User pause — suppresses sweeping and alerting. A success ping auto-resumes it |
suspended | boolean | External suspension (mirrors a Kubernetes CronJob's spec.suspend). Set/cleared only by the managing source, independent of paused |
state | string | new, up, late, down, or paused (the folded display value while paused/suspended); read-only |
display_state | string | Alias of state (same folded value); read-only |
state_since | string|null | When the monitor entered its current state; read-only |
last_ping_at | string|null | Timestamp of the most recent ping of any kind; read-only |
last_run_status | string|null | Status of the most recent run: running, success, fail, or missed; read-only |
next_expected_at | string|null | Precomputed next expected fire time; read-only, null until the schedule is armed |
ping_token | string | 43-character ping credential; read-only, readable on every read |
ping_url | string | Full ping URL on the dedicated ping host; read-only |
schedule_human | string | Human-readable rendering of the schedule (e.g. every day at 04:00 (Europe/Warsaw)); read-only |
project_id | integer|null | Project this monitor belongs to |
integration_ids | integer[] | Integrations to notify. Empty list falls back to the org's default integrations |
source | string|null | Managing source for operator-upserted monitors (e.g. kubernetes); read-only |
external_id | string|null | Stable id from the managing source; read-only |
managed | boolean | True when a source (the operator) keeps this monitor in sync; read-only |
recent_runs | array | Last 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_ms | number|null | Windowed (7-day) average successful-run duration; read-only |
overlap_count | integer | Count of overlapping runs in the same 7-day window as recent_runs/avg_duration_ms; read-only |
created_at / updated_at | string | Timestamps; 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:
| Parameter | Type | Description |
|---|---|---|
state | string | Filter by state (new, up, late, down, paused) |
project | string | Filter by project id; "null" for unassigned monitors |
page, limit | integer | Pagination |
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:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | 1–128 characters |
schedule_kind | string | Yes | period or cron |
period_seconds | integer | Only for period | Minimum 60 |
cron_expression | string | Only for cron | See Schedule Grammar |
timezone | string | No | IANA name, default UTC |
grace_seconds | integer | No | Minimum 60, default 300 |
max_runtime_seconds | integer | No | null falls back to grace_seconds |
drift_pct / drift_min_seconds / drift_alerts | number/integer/boolean | No | Defaults 0.5 / 30 / true |
paused | boolean | No | Default false |
suspended | boolean | No | Default false; normally set only by the managing source |
project_id | integer | No | |
integration_ids | integer[] | No | |
source / external_id | string | No | Together 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:
| Parameter | Type | Required | Description |
|---|---|---|---|
cron | string | Yes | The expression to preview — see Schedule Grammar |
tz | string | No | IANA 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:
| Parameter | Type | Description |
|---|---|---|
status | string | Filter by running, success, fail, or missed |
from, to | string | ISO 8601 absolute window |
page | integer | Default 1 |
limit | integer | Default 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/truncated — not 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:
| Header | Description |
|---|---|
X-Log-Bytes | Total captured bytes (matches the run's log_bytes; 0 means no log was captured) |
X-Log-Truncated | true/false — whether the capture hit the 100 KB cap |
Ping-Body-Limit | The capture cap in bytes (102400) — the same header the ping endpoint returns |
Cache-Control | private, 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:
| Field | Type | Required | Description |
|---|---|---|---|
event | string | Yes | start, success, or fail |
rid | string | No (recommended) | Run-correlation id — the operator sends the Kubernetes Job UID. Required for idempotent re-reporting |
exit_code | integer | No | 0–255 |
log | string | No | Capped at 100 KB, like ping bodies |
source | string | No | shell, kubernetes, or api; default kubernetes |
meta | object | No | {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. @rebootis 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"
}code | Meaning |
|---|---|
invalid_cron_syntax | The expression/macro couldn't be parsed (or carried a forbidden CRON_TZ=/TZ= prefix, or was @reboot) |
unresolvable_schedule | The expression parses but yields no real occurrence (e.g. 0 0 31 2 * — February 31st) |
unknown_timezone | timezone/tz is not an IANA tz-database name (a raw UTC offset is rejected here too) |
invalid_field_type | A parameter had the wrong type (e.g. period_seconds wasn't an integer) |
schedule_conflict | Both 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
| Path | Meaning |
|---|---|
/ping/{token} | Success — "job finished OK" |
/ping/{token}/start | Run started (opens a running run; enables duration tracking) |
/ping/{token}/fail | Explicit failure |
/ping/{token}/log | Informational 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
| Status | When | Body |
|---|---|---|
200 | Ping accepted (GET/POST) | Literal text OK, plus the Ping-Body-Limit header |
204 | Ping accepted (HEAD) | No body, plus the Ping-Body-Limit header |
404 | Unknown or revoked token | — |
429 | Rate 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>/1Capabilities
| Capability | Grants |
|---|---|
cronmonitor.view | List/read cron monitors, their runs, and the ping URL |
cronmonitor.create | Create cron monitors (subject to max_cron_monitors) |
cronmonitor.edit | Update, pause/resume, rotate the ping token, report events |
cronmonitor.delete | Delete cron monitors |
See Roles & Permissions for which built-in role each capability belongs to.
Related Documentation
- Cron Job Monitoring — the user guide: creating a monitor, curl idioms, pause vs. suspend
- Kubernetes Zero-Touch Cron Monitoring
- Incidents
- API Authentication
- Roles & Permissions