Schedules

Run a deployed function on a cron schedule, with retries, catch-up and a run history.

Docs menu

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, manual or retry.
  • Newest first. count is the page size (absent: every stored run); cursor is a next_cursor this operation answered, the same opaque type as function.errors (<timestamp 16 hex><sequence 8 hex>:<id>); next_cursor is null on the last page. A bad cursor or count=0 is 400. An unknown schedule id, or another app's, is 404.
  • error_id is the function.errors entry of a failed run: read it with function.errors and the request_id of the entry, or page from it as cursor.