Schedules

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

Docs menu

Operations (MCP tool in brackets, route beside it):

Operation Route Purpose
schedule.create (substrio_schedule_create) POST /v1/_cron {"function": "tick", "expression": "*/5 * * * *"} answers {"id": "7_1", "success": true}
schedule.list GET /v1/_cron the schedules
schedule.update PATCH /v1/_cron/{id} {"enabled": false} pauses
schedule.delete DELETE /v1/_cron/{id} removes the schedule (its stored runs stay)
schedule.run (substrio_schedule_run) POST /v1/_cron/{id}/run run now
schedule.next (substrio_schedule_next) GET /v1/_cron/{id}/next next fire times
schedule.history (substrio_schedule_history) GET /v1/_cron/{id}/history stored runs

Every run is kept: page them with schedule.history.

Time rules

A schedule's expression is evaluated on the wall clock of its timezone (an IANA zone id such as Europe/Berlin; UTC when it has none). The server's own time zone has no effect. The next answers and the history give times as Unix seconds with an ISO 8601 UTC string beside them. An unknown zone id is refused by schedule.create (and by a version push) with INVALID_ARGUMENT naming the id, field timezone.

Daylight saving: an expression with fixed hours (the hour field is a number, a list or a range, as 30 2 * * *) names wall times, and each runs exactly once. A wall time the zone skips (02:30 on the day a clock jumps from 02:00 to 03:00) runs once, at the first instant after the gap (03:00); two skipped wall times in one gap run once. A wall time the zone repeats (02:30 on the day a clock falls back from 03:00 to 02:00) runs at its first showing only. This holds for a gap or repeat of any length (Australia/Lord_Howe shifts by 30 minutes).

An expression whose hour is * or */N (0 * * * *, */15 * * * *, 0 */3 * * *) is an interval, not a wall time: it keeps its real 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). Schedules are checked once a minute; schedule.next applies the same rule.

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 the server missed (it was down, or busy) is handled by catch_up:

catch_up A missed fire
none is dropped
last only the latest missed fire runs, once
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, so a server restart continues from there; a run that was in flight when the server stopped runs again as a missed 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).

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 that throws, or that the server refuses because it is too busy, is retried with exponential backoff: the app settings (settings.set) cron.max_attempts (default 3) and cron.backoff_ms (default 1000), the delay doubling up to delivery.backoff_cap_ms (default 3600000). Each attempt is its own run in the history, with trigger retry after the first. After the last attempt the run goes to the _dead_letters collection with kind cron, a delivery_id, its error and its event, and 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"

Runs the function now, in the request, with the event {"trigger":"cron","cron_id":"7_1","function":"tick","timestamp":<unix seconds>,"manual":true}. A run in flight (scheduled or manual) is never overlapped: the second answers 409 and is stored as a skipped run.

{"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 (default 5); anything else is 400. A paused schedule still lists its times (enabled says 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.