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,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.