Skip to content

API Reference

All routes are mounted under /api on zilean-core and require a valid session cookie plus the permission listed.

Method Path Permission
GET /jobs jobs:read
POST /jobs jobs:write (+ jobs:script for script jobs)
GET /jobs/{id} jobs:read
PUT /jobs/{id} jobs:write (+ jobs:script for script jobs)
DELETE /jobs/{id} jobs:write
POST /jobs/{id}/enable jobs:write
POST /jobs/{id}/disable jobs:write
POST /jobs/{id}/run jobs:write
POST /jobs/enable-all jobs:write
POST /jobs/disable-all jobs:write

A script job’s capabilities (declared volumes) is part of the job object on both requests and responses. POST and PUT return 400 if it is non-empty on an HTTP job or names an unknown volume, or a mode above the volume’s ceiling.

Job responses also carry a read-only capabilities_invalid boolean. It is true when a job’s stored volume declaration cannot be read; capabilities then shows {}, and the job is cancelled at run time instead of running without its volumes. A PUT that includes capabilities replaces the unreadable declaration; a PUT that omits it leaves it as is.

PUT takes the full job object — there are no partial updates. See Job fields.

POST and PUT return 400 when group_name is over 255 bytes or 16 levels deep (group_name must be at most 255 bytes / group_name must be at most 16 levels deep). A job whose stored group_name is over a limit still displays and can still be enabled, disabled, run and deleted; saving it through PUT must bring the value within the limits in the same save.

POST /jobs/{id}/run triggers immediately, bypassing the reconcile-window uniqueness guard. Notable responses:

Status Meaning
409 Every pool index is already running, the job is disabled, its group is disabled, or the job is one_off (a manual trigger would run it early) or webhook (a manual trigger would fire it with an empty payload).
Method Path Permission Body
GET /groups/disabled jobs:read —
POST /groups/enable jobs:write {"group_name": "..."}
POST /groups/disable jobs:write {"group_name": "..."}

A disabled group gates its jobs — checked by the scheduler and again by the worker at execution time. POST /groups/disable returns 400 for a group name over the 255-byte or 16-level limit (the same messages as jobs: group_name must be at most 255 bytes / group_name must be at most 16 levels deep) and rejects a name that is blank after trimming segments — only slashes or whitespace — with group_name is required. POST /groups/enable accepts any non-empty name regardless of length or depth, so a group disabled before the limits existed can still be re-enabled.

Method Path Permission
GET /jobs/{id}/calls jobs:read
GET /jobs/heartbeats jobs:read
GET /jobs/{id}/activity jobs:read

/jobs/{id}/calls — ?limit= default 20, maximum 100. A non-integer or non-positive value is a 400. Returns a bare array ([], never null), newest first.

Each entry: job_id (River’s queue job id, not the registry UUID), state, attempt, attempted_at, finalized_at, scheduled_at, last_error, pool_index, loop_iteration, and last_call. For HTTP jobs, secret values are replaced with REDACTED in last_call.url, last_call.response_snippet and the error fields.

/jobs/heartbeats returns every job in one response:

{
"window_start": "...", "bucket_width_seconds": 60, "slot_count": 40,
"jobs": [{ "job_id": "...", "buckets": [] }]
}

A job with no history gets "buckets": [].

/jobs/{id}/activity returns the same window geometry plus buckets and lifetime invocations_total / invocations_completed / invocations_failed — which cover the job’s whole life, not the window.

Param Default Cap Invalid
since 24h 720h, clamped silently 400 since must be a positive duration, e.g. 24h
buckets 40 500, clamped silently 400 buckets must be a positive integer

A bucket contains bucket_start, total, completed, failed, duration_ms_avg, duration_ms_max. The duration fields are only populated on the per-job activity route.

Method Path Permission
GET /jobs/{id}/alarms jobs:read
POST /jobs/{id}/alarms notify:write
PUT /jobs/{id}/alarms/{alarmID} notify:write
DELETE /jobs/{id}/alarms/{alarmID} notify:write

A per-job alarm on the job’s invocation count over a rolling window. GET returns a bare array ([], never null), and an empty array for a job without any. POST returns 201 with the created alarm, PUT returns 200 with the updated one, and DELETE returns 204.

POST and PUT take the same body:

{ "enabled": true, "metric": "failed", "threshold": 5, "window_minutes": 30 }
Field Type Constraints
enabled bool A disabled alarm is kept but not evaluated.
metric string total (every attempt, including loop iterations and retries) or failed (failed attempts only).
threshold int 1 … 2147483647. Fires at or above this count.
window_minutes int 1 … 1440. Counted in 1-minute buckets, including the current partial minute.

An alarm is returned in this shape:

{
"id": 3,
"job_id": "...",
"enabled": true,
"metric": "failed",
"threshold": 5,
"window_minutes": 30,
"last_state": "breached",
"last_changed_at": "2026-09-22T20:01:00Z",
"created_at": "2026-09-22T19:00:00Z"
}

last_state is ok or breached, and last_changed_at is null until the state first changes. An unknown job id, or an alarmID belonging to a different job, is a 404; an invalid body is a 400. See Monitoring jobs for the evaluation and notification behavior.

Method Path Permission
GET /workflows workflows:read
POST /workflows workflows:write
GET /workflows/{id} workflows:read
PUT /workflows/{id} workflows:write
DELETE /workflows/{id} workflows:write
POST /workflows/{id}/enable workflows:write
POST /workflows/{id}/disable workflows:write
POST /workflows/{id}/run workflows:write
GET /workflows/{id}/runs workflows:read
GET /workflows/{id}/runs/{runID} workflows:read
GET /workflows/{id}/runs/{runID}/steps workflows:read
POST /workflows/enable-all workflows:write
POST /workflows/disable-all workflows:write

GET /workflows, GET /workflows/{id}/runs and GET /workflows/{id}/runs/{runID}/steps each return a bare array ([], never null) when there is nothing to list.

POST /workflows and PUT /workflows/{id} apply the same group_name limits as jobs — 255 bytes and 16 levels deep; an over-limit value is a 400.

workflows:read/workflows:write are distinct from jobs:read/jobs:write — a workflow is its own schedulable entity, not a scheduled_jobs row. Saving a workflow whose graph contains any script block also requires jobs:script, checked after the graph is decoded (the same reason jobs:script is enforced in the job handlers rather than at the route). See Workflows for the graph model, block types, and run-monitoring surfaces these routes back.

Method Path Permission
GET /metrics/live metrics:read
GET /metrics/history metrics:read

?service= accepts core, ui, worker, mcp, webhook, script-worker-bash, script-worker-node, script-worker-groovy. Empty defaults to worker; an unknown value is a 400.

live returns the latest sample, or JSON null with a 200 if that service has never sampled. history accepts since and buckets as above; service=all switches the response to samples_by_service and includes a worker-wide invocations series.

The Prometheus endpoint is /prometheus/metrics (not under /api), also requiring metrics:read.

Method Path Permission
GET /scripts/liveness jobs:script

Returns, per script job type, {"live": bool, "last_sample_at": <time|null>}. A runtime is live if it has reported a metrics sample within 2 minutes.

Method Path Permission
GET /script-volumes scriptvolumes:read
POST /script-volumes scriptvolumes:write
PUT /script-volumes/{name} scriptvolumes:write
DELETE /script-volumes/{name} scriptvolumes:write
GET /script-workers/status scriptvolumes:read

A volume is {"name", "max_mode", "description", "usage", "created_at", "updated_at"}. usage is the number of jobs plus the number of workflows that declare it; a workflow counts once however many of its script blocks use the volume. name must match ^[a-z][a-z0-9_]{0,62}$, max_mode is ro or rw, and PUT changes only max_mode and description. Volume create, update and delete are recorded in the audit log.

DELETE removes the catalog entry only and never touches files on disk. If a job or workflow still declares the volume it returns 409:

{
"error": "<message>",
"jobs": [{ "id": "<job-id>", "name": "nightly-report" }],
"workflows": [{ "id": "<workflow-id>", "name": "export-pipeline" }]
}

GET /script-workers/status returns one entry per script worker queue:

[
{
"queue": "scripts-bash",
"sandbox_enforced": true,
"volumes_root_mounted": true,
"updated_at": "2026-10-06T12:00:00Z",
"stale": false
},
{
"queue": "scripts-node",
"sandbox_enforced": true,
"volumes_root_mounted": false,
"updated_at": "2026-10-06T12:00:00Z",
"stale": false
}
]

volumes_root_mounted is false when that worker’s volumes root is unset, missing, not a directory, /, or not writable by the worker. A false value means the worker treats the root as not mounted and still runs jobs that declare no volume; when the value is set but unusable the worker also logs a warning, and jobs that declare a volume fail. The check runs once, at worker startup, and the heartbeat repeats its result for the process’s lifetime, so restart the worker after fixing ownership or permissions. stale is true when the worker has not reported for more than 3 minutes. See Volumes.

Method Path Permission
GET /system/health metrics:read

Returns a JSON array, one entry per running service (worker, script-worker-bash, script-worker-node, script-worker-groovy, core, ui, mcp, webhook):

[{ "service": "worker", "live": true, "last_sample_at": "2026-09-22T12:00:00Z" }]

live is true only if that service’s latest metrics sample is newer than 2 minutes; last_sample_at is null if the service has never sampled. This is a status roll-up derived from the same stored samples /metrics/live and /scripts/liveness read — it adds no new sampling. Unlike /scripts/liveness, a read failure for one service degrades just that entry to “never seen” rather than failing the whole request. See Monitoring jobs.

Method Path Permission
GET /global-variables globalvars:read
POST /global-variables globalvars:write
PUT /global-variables/{id} globalvars:write
DELETE /global-variables/{id} globalvars:write

A variable has name, value, is_secret and allow_in_scripts. Names may contain letters, digits and underscores, and are referenced from jobs as {{name}}. allow_in_scripts only matters when is_secret is true — it marks the variable as eligible to be injected into a script job’s environment; a job still needs the variable named in its own script_secret_vars list to actually receive it. See Script jobs.

A secret variable’s value is write-only. Responses return value: "" for a secret, plus a has_value boolean saying whether one is stored. On PUT, an empty value for a variable that is secret before and after the update keeps the stored value. Changing a variable from secret to non-secret with an empty value clears it rather than revealing it. Audit log entries for a global variable mask a secret’s value the same way.

Method Path Permission
GET /account/me Any authenticated user
POST /account/change-password Any authenticated user

/account/me returns {email, is_super_admin, must_change_password, permissions[]}.

change-password takes {current_password, new_password} → 204. A wrong current password is a 400, as is a new_password shorter than 8 characters or longer than 72 bytes (bcrypt’s input limit).

Method Path Permission
GET /users users:read
POST /users users:write
PATCH /users/{id} users:write
POST /users/{id}/reset-password users:write
DELETE /users/{id} users:write

POST /users takes {email, role_ids} and returns 201 {user, temp_password} — the plaintext password is returned once and never stored. role_ids is a non-empty array; a user can hold more than one role, and effective permissions are the union of every assigned role’s permissions.

PATCH accepts {role_ids?, status?}; status must be exactly active or disabled. A supplied role_ids replaces the full set and must not be empty — a user always has at least one role. Disabling and deleting both revoke that user’s sessions immediately.

Super Admin targets return 409 on role change, status change, delete and reset-password.

GET /users returns a bare array ([], never null).

Method Path Permission
GET /roles roles:read
GET /roles/permissions roles:read
POST /roles roles:write
PATCH /roles/{id} roles:write
DELETE /roles/{id} roles:write

/roles/permissions is the live list of permission keys. The three system roles cannot be renamed or deleted. Super Admin’s permissions can’t be edited either; Admin and Read-Only can have their permissions updated via PATCH /roles/{id} (name must be left unchanged). GET /roles returns a bare array ([], never null).

Method Path Permission
POST /invites invites:create
GET /invites invites:create
POST /invites/{id}/resend invites:create

POST /invites takes {email, role_ids} → 201 {invite, link}. role_ids is a non-empty array — an invitee can be assigned more than one role. Tokens last 7 days; only their hash is stored. GET /invites returns a bare array ([], never null) of pending invites.

Method Path Permission
GET /audit-log audit:read
GET /settings/audit audit:read
PUT /settings/audit audit:write

/audit-log filters: actor_user_id (uuid), action, resource_type, since / until (RFC 3339), cursor. Malformed values are a 400.

Returns {"entries": [...], "next_cursor": "..."} — newest-first, keyset-paginated. entries is an empty array ([], never null) when nothing matches. Page size is fixed at 50 and is not client-tunable.

/settings/audit reads and writes {"audit_retention_days": n}, accepting 1–3650. Changing it is not itself audited.

Method Path Permission
GET /notify/targets notify_settings:read
POST /notify/targets notify_settings:write
PUT /notify/targets/{id} notify_settings:write
DELETE /notify/targets/{id} notify_settings:write
POST /notify/targets/{id}/test notify_settings:write
GET /settings/apprise notify_settings:read
PUT /settings/apprise notify_settings:write
POST /settings/apprise/test notify_settings:write
GET /notify/thresholds notify_settings:read
PUT /notify/thresholds/{service} notify_settings:write

A notify target has a kind of apprise, email or webhook — all three share the same /notify/targets route family. A job’s own notify_on/notify_all_targets/ notify_targets fields are read and written through the regular Jobs routes instead, gated by notify:read/notify:write rather than notify_settings:*. See Configuration for target kinds and the Apprise server connection, and Monitoring jobs for metrics alarms (/notify/thresholds); per-job invocation alarms use the alarm routes instead.

Method Path Permission
GET /settings/smtp settings:smtp
PUT /settings/smtp settings:smtp
POST /settings/smtp/test settings:smtp

GET returns {host, port, username, from, enabled, has_password} — the password itself is never returned, only the boolean has_password. It is stored encrypted, and is masked in the audit log.

PUT takes {host, port, username, password, from, enabled}. An empty password means “keep the existing one”, so the UI can save other fields without re-entering it. enabled: true with an empty host is a 400.

POST /settings/smtp/test sends a test message, taking {"to": "..."}. An optional draft object of the same shape as the PUT body tests unsaved edits instead of the stored settings — a blank password keeps the saved one only when host and port match the saved settings; otherwise the request is a 400 asking for the password. Responses:

Status Meaning
200 {"ok": true} — the message was sent.
400 to is missing.
409 No draft was supplied and SMTP isn’t configured.
502 The send itself failed; the body carries the SMTP error.
Method Path Permission
GET /settings/river-jobs settings:river_jobs
PUT /settings/river-jobs settings:river_jobs

/settings/river-jobs reads and writes {"river_job_retention_days": n}, accepting 1–3650. Unlike the audit log (a zilean table), this governs River’s own river_job table — finished (completed/cancelled/discarded) rows older than the window are swept daily by zilean-core’s scheduler, the same as audit_retention_days. River’s own built-in job cleaner is disabled in every worker binary so the two mechanisms don’t race on the same rows; this setting is now the only thing controlling river_job retention.

Method Path Permission
GET /mcp-tokens settings:mcp
POST /mcp-tokens settings:mcp
DELETE /mcp-tokens/{id} settings:mcp
GET /settings/mcp settings:mcp
PUT /settings/mcp settings:mcp

A token authenticates the MCP endpoint; this API is the admin-facing management surface for it (issuing and revoking tokens, and toggling the endpoint on or off).

POST /mcp-tokens takes {"name": "...", "role_id": "...", "expires_in": "30d"} — expires_in is one of 30d, 90d, 365d, or never. The response’s token field is the plaintext bearer credential and is returned exactly once; only its sha256 hash is ever stored. role_id must name a role whose permissions don’t exceed the caller’s own, and can never name the Super Admin role (see Permissions). Each token is backed by a hidden, non-interactive user account carrying that role, deleted together with the token on DELETE /mcp-tokens/{id}.

GET /mcp-tokens lists name, linked role, creation time, expiry, and last-used time — never the token value itself, which isn’t recoverable after creation. Returns a bare array ([], never null) when no tokens exist.

PUT /settings/mcp takes {"enabled": bool}, gating whether the MCP endpoint accepts connections at all. GET/PUT both respond with that same field plus live and checked_at: whether a live health probe against the separate cmd/mcp process (its own GET /healthz, 2s timeout) just succeeded, and when that probe ran. This is informational only — enabling MCP while cmd/mcp is unreachable still succeeds; the admin UI just warns, since cmd/mcp runs as its own binary/process and isn’t guaranteed to be deployed just because core/ui are (see Architecture). Where that probe points is configurable server-side via MCP_HEALTH_URL (see Deploying).

Method Path Permission
GET /jobs/{id}/webhook jobs:read
POST /jobs/{id}/webhook/rotate jobs:write

A job whose schedule_type is webhook has a per-job token that fires it. This API is the admin-facing management surface for that token — reading its identification hint and rotating it. Chains of webhook-triggered jobs are bounded by the loop guard described in Scheduling.

GET /jobs/{id}/webhook returns the token’s 8-character webhook_token_hint and when it was issued, never the token itself: only a sha256 digest is stored, so the plaintext is not recoverable after it is first shown. POST /jobs/{id}/webhook/rotate issues a fresh token, returns the plaintext exactly once, and invalidates the previous URL immediately — no redeploy is needed. A routine edit of the job never rotates the token.

Method Path Permission
GET /meta None

Returns {"timezone": "..."} — the zone the scheduler evaluates cron expressions in. The only ungated API route: harmless server info the UI shell needs before any permission check.

Served by zilean-ui:

Path Notes
/ Admin SPA (client-side routes: /jobs, /metrics, /settings, /account).
/riverui/* Embedded River UI. Requires jobs:write — it can retry, cancel and delete jobs.
/login, /logout, /admin/assets/*, /admin/accept-invite Registered outside the auth middleware. /logout accepts POST only.