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). |
Groups
Section titled “Groups”| 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.
Job history and activity
Section titled “Job history and activity”| 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.
Shared window parameters
Section titled “Shared window parameters”| 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.
Job invocation alarms
Section titled “Job invocation alarms”| 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.
Workflows
Section titled “Workflows”| 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.
Metrics
Section titled “Metrics”| 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.
Scripts
Section titled “Scripts”| 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.
Script volumes
Section titled “Script volumes”| 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.
System health
Section titled “System health”| 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.
Global variables
Section titled “Global variables”| 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.
Account
Section titled “Account”| 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).
Invites
Section titled “Invites”| 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.
Audit log
Section titled “Audit log”| 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.
Notifications
Section titled “Notifications”| 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.
Outbound email (SMTP)
Section titled “Outbound email (SMTP)”| 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. |
River job retention
Section titled “River job retention”| 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.
MCP tokens
Section titled “MCP tokens”| 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).
Job webhooks
Section titled “Job webhooks”| 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.
Non-API routes
Section titled “Non-API routes”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. |