A schedule starts runs of one workflow in one app on a cron timetable. A project can have up to 1,000 schedules.
const s = await client.createSchedule({
appId, workflow: "nightly-report", name: "nightly", cron: "0 2 * * *", timeZone: "Europe/Berlin", jitterSeconds: 300,
})
await client.updateSchedule(s.id, { cron: "30 2 * * *" }, { revision: s.revision })
await client.triggerSchedule(s.id, { idempotencyKey: "rerun-2026-10-10" })
await client.pauseSchedule(s.id)
await client.resumeSchedule(s.id)
await client.deleteSchedule(s.id)jitter := 300
s, err := c.CreateSchedule(ctx, workflows.ScheduleSpec{
AppID: appID, Workflow: "nightly-report", Name: "nightly", Cron: "0 2 * * *",
TimeZone: "Europe/Berlin", JitterSeconds: &jitter,
})
run, err := c.TriggerSchedule(ctx, s.ID, "rerun-2026-10-10")s = client.create_schedule(
app_id=app_id, workflow="nightly-report", name="nightly", cron="0 2 * * *",
time_zone="Europe/Berlin", jitter_seconds=300,
)
client.update_schedule(s["id"], revision=s["revision"], cron="30 2 * * *")
client.trigger_schedule(s["id"], idempotency_key="rerun-2026-10-10")Or call CreateSchedule. A schedule has:
| Field | Meaning |
|---|---|
appId, workflow |
The workflow to run. The app can’t change later. |
name |
A name, 1 to 100 characters. Names don’t have to be unique. |
cron |
When to run (below) |
timeZone |
An IANA time zone, such as Europe/Berlin. Default UTC. |
input |
The JSON input of every run, at most 1 MiB. Default none. |
overlap |
What to do when the previous run is still going: skip (default), allow or bufferOne (below) |
catchupWindowSeconds |
How late a missed run may still start, 0 to 86,400. Default 3,600 (below) |
jitterSeconds |
Delay each run by a stable offset of 0 to this many seconds, at most 3,600. Default 0 (below) |
paused |
Create it paused |
A schedule’s view also shows its nextScheduledTime, lastScheduledTime, activeRunId, how
many runs it missed and skipped, and who paused it: pausedBy is customer (you paused it),
system (EZGH paused it while its organization’s deletion is pending), or null while it runs.
Runs a schedule started are listed with scheduleId.
Creating a schedule, changing anything but its name, resuming it and triggering it need
workflows.runs.start on its workflow, as well as the schedule action.
Cron expressions
- Five fields: minute, hour, day of month, month, day of week, such as
0 2 * * *(02:00 every day) or*/15 9-17 * * 1-5(every 15 minutes during working hours on weekdays). Or one of@hourly,@daily,@weekly,@monthlyand@yearly. - At most once a minute: there’s no seconds field.
- Daylight saving time: a local time that doesn’t exist on a day (when clocks go forward) is skipped that day. A local time that happens twice (when clocks go back) runs once, the first time.
The runs a schedule starts
- Each run’s ID is derived from the schedule and its scheduled time, so a scheduled time can never start two runs.
- The workflow sees which schedule started it:
run.schedule({ id, scheduledTime }) in TypeScript,ctx.Run().Schedulein Go, andctx.run.schedulein Python.scheduledTimeis the cron time, whatever the jitter. - Runs started by a schedule aren’t recorded in Trails.
Overlap
When a scheduled time comes and the schedule’s previous run hasn’t ended:
overlap |
What happens |
|---|---|
skip |
No run starts; the time counts as skipped. The default. |
allow |
The run starts anyway. |
bufferOne |
One run starts when the previous one ends. Further times meanwhile are skipped. |
Catch-up
After the schedule was paused or couldn’t fire (an outage), only the most recent missed time
runs, and only if it’s within catchupWindowSeconds. Older missed times count as missed and never
run, so a schedule never starts a burst of runs to catch up. 0 means never catch up.
Resuming a paused schedule fires again from now, with no catch-up for the time it was paused. Pausing and resuming are idempotent.
Jitter
Many schedules share the same times, such as 0 * * * *. jitterSeconds delays each run by an
offset between 0 and that many seconds. The offset is derived from the schedule and the scheduled
time, so it’s stable, and it never moves a run past the schedule’s next time. The scheduled time
the workflow sees doesn’t change. Use it for anything that doesn’t need to start on the exact
second.
Change, trigger and delete
- Change a schedule with UpdateSchedule: only the
fields you send change. Every change increments its
revision, which is itsETag. SendIf-Match: "<revision>"(the SDKs take the revision) to change it only if nobody else did: otherwise it’s412 precondition_failed. - Trigger starts a run now with
TriggerSchedule. It needs an
Idempotency-Key, which names the run: a repeat with the same key answers the same run, replayed with its status (201) andIdempotent-Replayed: true. A trigger ignores the overlap policy and doesn’t count as a scheduled run; itsscheduledTimeis when it was triggered. - Delete stops the schedule at once; runs it already started continue. Deleting an app deletes its schedules.
Lists of schedules show changes about a second after they’re made; getting a schedule by ID is always current.
Errors
| Status | Meaning |
|---|---|
400 invalid_request |
A field isn’t valid: a cron expression that runs more than once a minute, an unknown time zone, an app that isn’t in the project |
400 idempotency_key_required |
A trigger without an Idempotency-Key |
409 quota_exceeded |
The project has 1,000 schedules |
409 app_deleted |
Resuming or triggering a schedule whose app was deleted |
412 precondition_failed |
If-Match names an old revision |
413 |
The input is over 1 MiB |