The following operations manage schedules and their history:
| Operation | Route | Purpose |
|---|---|---|
schedule.create (substrio_schedule_create) |
POST /v1/_cron |
{"function": "tick", "expression": "*/5 * * * *"} returns {"id": "7_1", "success": true} |
schedule.list |
GET /v1/_cron |
List schedules |
schedule.update |
PATCH /v1/_cron/{id} |
Pause a schedule with {"enabled": false} |
schedule.delete |
DELETE /v1/_cron/{id} |
Remove a schedule; its stored runs remain |
schedule.run (substrio_schedule_run) |
POST /v1/_cron/{id}/run |
Run a schedule now |
schedule.next (substrio_schedule_next) |
GET /v1/_cron/{id}/next |
List the next fire times |
schedule.history (substrio_schedule_history) |
GET /v1/_cron/{id}/history |
Read stored runs |
Every run is retained. Use schedule.history to page through them.
Time rules
A schedule's expression is evaluated against the wall clock in its timezone,
an IANA zone id such as Europe/Berlin. Without a timezone, it uses UTC. The
server's timezone has no effect. schedule.next and the history return Unix
seconds alongside an ISO 8601 UTC string. An unknown zone id is refused by
schedule.create and by a version push with INVALID_ARGUMENT; the error
names the id and sets field to timezone.
For daylight saving changes, an expression with fixed hours, such as
30 2 * * *, specifies wall times. Its hour field is a number, list, or range,
and each scheduled time has one nominal fire. A skipped wall time, such as 02:30
when the clock jumps from 02:00 to 03:00, has its nominal fire at the first
instant after the gap (03:00). Two skipped wall times in one gap share one fire.
A repeated wall time, such as 02:30 when the clock falls back from 03:00 to 02:00, uses its first
occurrence only. This applies to gaps or repeats of any length, including the
30-minute shift in Australia/Lord_Howe.
An expression whose hour is * or */N, such as 0 * * * *, */15 * * * *,
or 0 */3 * * *, is an interval. It keeps its cadence through a clock change,
so a repeated hour fires again and a skipped hour has no fires.
An expression is five fields, minute hour day-of-month month day-of-week
(Sunday is 0), or @yearly, @monthly, @weekly, @daily, @hourly,
@every_minute. A field is *, */N, a number, a list 1,5, or a range
1-5. A run fires when every field matches (day-of-month and day-of-week must
both match when both are restricted). Schedule discovery runs at startup and
at each wall-minute boundary. A tenant opened between those checks also has
its saved schedules discovered.
Each automatic fire is dispatched at a stable offset within its minute.
The offset is the tenant id modulo 60, in seconds. A fire whose nominal time
is 12:00:00 for tenant 17 becomes eligible at 12:00:17. The offset is
fixed for that tenant. All schedules of that tenant share it.
The offset is an eligibility time. A run never starts before it, and a busy
server can start it later. There is no exact-second deadline. scheduled_for
in the event and the times returned by schedule.next remain the nominal
expression times, before the offset. Manual runs start immediately.
An expression that can never fire (31 February, 60 * * * *, day 0) is
refused by schedule.create with
INVALID_ARGUMENT (400), field expression, detail beginning
expression: it never fires. An expression that does not parse is the same
refusal with detail Invalid cron expression, and a missing function or
expression names that member in field.
schedule.next on any schedule answers that error if it is ever asked for one
that cannot fire.
Missed fires: catch_up
schedule.create takes "catch_up": "none" | "last" | "all" (default
last). A fire is on time when it is found within a minute of its time. A
fire found later is handled by catch_up:
| catch_up | A missed fire |
|---|---|
none |
is dropped |
last |
only the latest missed fire is scheduled |
all |
every missed fire runs, oldest first, each after the one before has ended |
A schedule never catches up to a time before it was created, nor to a time it
was paused: enabling it starts it from now. The last fire handled is saved
when its run ends. Catch-up continues from the last completed fire. The event of a
scheduled run carries scheduled_for (the fire's Unix seconds), caught_up
(true for a missed fire) and attempt (1 for the first try).
Delivery is at least once. A function can run again for the same scheduled
time. Use the schedule id and scheduled_for together to identify a fire,
and write the function so repeating that work is safe. The catch-up and
overlap rules still apply.
A fire due while the previous run is still going is skipped and stored as a
skipped run (skipped_overlaps counts them); under all it waits for the
run to end instead, so no fire is lost.
Failed runs: retry and dead letters
A run is retried with exponential backoff if the function throws, traps, hits a
limit, or returns a status of 400 or more, or if the server refuses it because
it is too busy. The app settings (settings.set) are cron.max_attempts
(default 3) and cron.backoff_ms (default 1000). The delay doubles up to
delivery.backoff_cap_ms (default 3600000). Each attempt is a separate run in
the history, with trigger retry after the first. After the last attempt, the
run goes to _dead_letters with kind cron, a delivery_id, its error, and
its event. The schedule runs its next fire as usual.
schedule.run
curl -s -X POST "$SUBSTRIO_URL/v1/_cron/7_1/run" -H "Authorization: Bearer $SUBSTRIO_KEY"
Starts the function now, with the event
{"trigger":"cron","cron_id":"7_1","function":"tick","timestamp":<unix seconds>,"manual":true}.
A scheduled or manual run never overlaps another run in flight. The second
request returns 409 and is stored as a skipped run.
For HTTP, the response is sent after the function has finished and its run has been recorded. The request is parked during the function run, so the general worker can serve other requests. The busy claim stays held until completion, even if the client disconnects.
Pipeline $exec, MCP calls, trusted synchronous in-process calls, and remote
mesh RPC waits retain their synchronous behavior. Function invocation and
sign-in email delivery retain their existing HTTP deferred responses.
{"success": true, "id": "7_1", "outcome": "ok", "started_at": 1760000000,
"started_at_iso": "2025-10-09T08:53:20Z", "duration_ms": 12, "error_id": null,
"error": null, "result": {"ticked": true}}
When the function throws, the response is still 200 with success false,
outcome error, error the message and error_id the
function.errors entry id of the throw; result is absent.
Another app's schedule id is 404.
schedule.next
curl -s "$SUBSTRIO_URL/v1/_cron/7_1/next?count=2" -H "Authorization: Bearer $SUBSTRIO_KEY"
{"id": "7_1", "expression": "30 6 * * *", "enabled": true, "timezone": "UTC",
"catch_up": "last", "fires_at": [1760077800, 1760164200],
"fires_at_iso": ["2025-10-10T06:30:00Z", "2025-10-11T06:30:00Z"], "count": 2}
count is 1 to 1000, with a default of 5. Other values return 400. A paused
schedule still lists its times; enabled indicates that it is paused. The
search looks 40 years ahead. An expression with no fire in that span is refused
as never firing.
schedule.history
curl -s "$SUBSTRIO_URL/v1/_cron/7_1/history?count=2" -H "Authorization: Bearer $SUBSTRIO_KEY"
{"id": "7_1", "runs": [
{"id": "0000019a1b2c3d4e00000003:7_1", "schedule": "7_1",
"started_at": 1760000000, "started_at_iso": "2025-10-09T08:53:20Z",
"started_at_ms": 1760000000123, "duration_ms": 12, "outcome": "ok",
"trigger": "manual", "error_id": null},
{"id": "0000019a1b2c3d4e00000002:7_1", "schedule": "7_1", "outcome": "error",
"trigger": "schedule", "error_id": "0000019a1b2c3d4e00000001:acme/tick"}],
"count": 2, "next_cursor": "0000019a1b2c3d4e00000002:7_1"}
outcome:ok,error(the function threw),skipped(a run was already in flight; duration 0).trigger:schedule,manualorretry.- Newest first.
countis the page size (absent: every stored run);cursoris anext_cursorthis operation answered, the same opaque type asfunction.errors(<timestamp 16 hex><sequence 8 hex>:<id>);next_cursorisnullon the last page. A bad cursor orcount=0is 400. An unknown schedule id, or another app's, is 404. error_idis thefunction.errorsentry of a failed run: read it withfunction.errorsand therequest_idof the entry, or page from it ascursor.