Skip to content

Schedules API

All schedule endpoints are project-scoped. The actions are enable / disable, pause / resume, and trigger.

disable and pause are different things and the difference matters. Disabling retires a schedule and is propagated to the owning adapter. Pausing holds it during an incident and records how long the hold has run, leaving the definition alone. Reach for pause when you mean "stop this for now", and disable when you mean "this should not be running any more".

GET /api/v1/projects/{slug}/schedules

Role: viewer.

Query params: cursor and limit only. limit defaults to 50 and is capped at 500. The opaque cursor advances the keyset ordered by (name, id); this endpoint does not accept server-side scheduler or enabled filters.

{
"items": [
{
"id": "...",
"project_id": "...",
"engine": "celery",
"scheduler": "z4j-scheduler",
"name": "email.daily-digest",
"task_name": "email.send_daily_digest",
"kind": "cron",
"expression": "0 9 * * *",
"timezone": "UTC",
"queue": null,
"priority": "normal",
"args": [],
"kwargs": {"locale": "en-US"},
"is_enabled": true,
"paused_at": null,
"overlap_policy": "allow",
"last_run_at": "...",
"next_run_at": "...",
"total_runs": 42,
"consecutive_failures": 0,
"external_id": null,
"catch_up": "skip",
"source": "dashboard",
"source_hash": null,
"control_token": "...",
"legacy_fire_control_token": null,
"schedule_revision": 3,
"quarantine_control_token": null,
"quarantine_code": null,
"quarantine_detail": null,
"quarantined_at": null,
"created_at": "...",
"updated_at": "..."
}
],
"next_cursor": "...",
"circuit_breaker_threshold": 5
}

consecutive_failures is the schedule's unbroken run of trailing failed / acked_failed fires, newest first; 0 means the newest fire is not a failure. circuit_breaker_threshold is the count at which the breaker auto-disables a schedule, 0 when the breaker is switched off. See Schedule fire history.

GET /api/v1/projects/{slug}/schedules/{schedule_id}

Role: viewer. Carries the same consecutive_failures as the list. Responses to the mutating endpoints below return the schedule with consecutive_failures: null, because they do not recount.

GET /api/v1/projects/{slug}/schedules/runs?id={schedule_id}&id={schedule_id}&limit=20

Role: viewer. id repeats, at most 500 per request (more is a 422); limit is 1 to 50 and defaults to 20. One row per requested schedule with its newest limit fires reduced to fire_id, status, scheduled_for, fired_at and latency_ms, plus the project's circuit_breaker_threshold. Ids belonging to another project are dropped from the response, not refused. This is the read behind the dashboard's run strips; the full fire record stays on Fire history.

{
"items": [
{
"schedule_id": "...",
"runs": [
{
"fire_id": "...",
"status": "acked_success",
"scheduled_for": "...",
"fired_at": "...",
"latency_ms": 594
}
]
}
],
"limit": 20,
"circuit_breaker_threshold": 5
}
POST /api/v1/projects/{slug}/schedules

Role: admin. CSRF-protected.

{
"name": "email.daily-digest",
"engine": "celery",
"scheduler": "z4j-scheduler",
"kind": "cron",
"expression": "0 9 * * *",
"task_name": "email.send_daily_digest",
"timezone": "UTC",
"queue": null,
"args": [],
"kwargs": {"locale": "en-US"},
"catch_up": "skip",
"is_enabled": true
}

If scheduler is omitted, the project default owner is used. overlap_policy is not an implemented control: only "allow" is accepted, and any other value is refused rather than silently ignored.

priority is reported for every schedule and is always normal. It is not settable on create or update and nothing in the dispatch path reads it; task priority belongs to the engine, not to the schedule.

The field validators cap serialized args and kwargs at 64 KiB each. The HTTP request-body limit is checked first, however, and defaults to 8192 bytes for the whole request (Z4J_MAX_PAYLOAD_SIZE_BYTES), so the default deployment cannot submit values near those field ceilings without raising that setting.

PATCH /api/v1/projects/{slug}/schedules/{schedule_id}

Role: admin. CSRF-protected. Mutable fields are engine, kind, expression, task_name, timezone, queue, args, kwargs, catch_up, is_enabled, and source_hash. name, scheduler, and source are not update fields; migrate ownership by deleting and recreating the schedule through the appropriate owner path.

DELETE /api/v1/projects/{slug}/schedules/{schedule_id}

Role: admin. CSRF-protected.

POST /api/v1/projects/{slug}/schedules/{schedule_id}/enable
POST /api/v1/projects/{slug}/schedules/{schedule_id}/disable
POST /api/v1/projects/{slug}/schedules/{schedule_id}/trigger

Role: operator. CSRF-protected. Enable and disable update the cadence. trigger issues an immediate command without moving next_run_at and writes a schedule.trigger_now audit row naming the caller. It does not create a schedule_fires row, so REST-triggered runs do not appear in fire history and do not populate triggered_by_user_id there.

POST /api/v1/projects/{slug}/schedules/{schedule_id}/pause
POST /api/v1/projects/{slug}/schedules/{schedule_id}/resume

Role: operator. CSRF-protected. A hold, not a retirement: the definition is untouched and nothing is propagated to the owning adapter. The schedule stops firing until you resume it, and paused_at records when the hold began. Pausing an already-paused schedule does not reset that timestamp, because during an incident the useful question is how long this has been held.

Offered for schedules z4j fires. A schedule owned by celery-beat or another external scheduler is defined in that scheduler and projected here, so there is no channel to tell it to stop; those are refused with an explanation rather than given a hold that nothing would honour.

Rolling back to a release without paused_at is refused while any schedule is paused, because that column is the only record of the holds and dropping it would release every one of them silently.

GET /api/v1/projects/{slug}/schedules/{schedule_id}/fires

Role: viewer. Returns the schedule's recent fires, newest first. limit defaults to 100, capped at 1000. Recorded for schedules fired by z4j-scheduler.

[
{
"id": "...",
"fire_id": "...",
"schedule_id": "...",
"command_id": "...",
"status": "acked_success",
"scheduled_for": "...",
"fired_at": "...",
"acked_at": "...",
"latency_ms": 594,
"error_code": null,
"error_message": null,
"triggered_by_user_id": null
}
]

status is stored as extensible text. Current paths use pending, accepted, delivered, buffered, buffer_expired, buffer_stale, operator_skipped, acked_success, acked_failed, failed, and the terminal_completed, terminal_failed, terminal_cancelled, and terminal_timeout outcomes. triggered_by_user_id may identify a manual scheduler-protocol fire, but null is not proof of an automatic cadence fire: the REST trigger creates no fire row, legacy rows omit it, and deleting the user sets the field to null. See Schedule fire history for lifecycle and retention.

GET /api/v1/projects/{slug}/schedules/{schedule_id}/misfires

Role: viewer. Returns the schedule's detected misfires, newest first (limit defaults to 50, capped at 1000). A misfire is a system-detected "this enabled schedule missed its slot" event, written by the brain's misfire detector; each row carries detected_at, expected_fire_at, lateness_seconds, and the grace in force at detection time. See misfire detection.

GET /api/v1/projects/{slug}/schedules/misfires

Role: viewer. Returns every detected misfire across ALL of the project's schedules, newest first (limit defaults to 50, capped at 1000). Unlike the per-schedule view above, each row carries its own schedule_id so an operator can triage missed slots project-wide in one call. The z4j misfires --project <slug> CLI command is the shell-side twin of this endpoint.

When the scheduler is authoritative (Celery beat, APScheduler), three colon-suffixed endpoints drive the sync workflow:

POST /api/v1/projects/{slug}/schedules:import
POST /api/v1/projects/{slug}/schedules:diff
POST /api/v1/projects/{slug}/schedules:resync

All three routes require project admin. :import accepts a body and writes its supplied schedule snapshot into the brain. :diff accepts the same body and previews the insert/update/delete set without writing. :resync takes no body: it dispatches schedule.resync to online scheduler agents, then treats their snapshots as authoritative and can delete brain rows missing from those snapshots.

Four further routes exist for unsticking a schedule whose fire evidence is ambiguous. They are recovery surfaces, not part of normal operation, and the incident response page is the place to start before using them.

POST /api/v1/projects/{slug}/schedules/{schedule_id}/legacy-fire-grant
POST /api/v1/projects/{slug}/schedules/{schedule_id}/resolve-terminal-fire
POST /api/v1/projects/{slug}/schedules/{schedule_id}/resolve-legacy-evidence
GET /api/v1/projects/{slug}/schedules/external-control-operations/{operation_id}

legacy-fire-grant requires project admin. resolve-terminal-fire and resolve-legacy-evidence require operator. The external-control operation lookup, which returns the durable status a Location header pointed at, requires viewer.