Skip to content

Schedules

Start runs of a workflow on a cron timetable, with time zones, overlap and catch-up policies, and jitter.

Updated View as Markdown

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, @monthly and @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().Schedule in Go, and ctx.run.schedule in Python. scheduledTime is 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 its ETag. Send If-Match: "<revision>" (the SDKs take the revision) to change it only if nobody else did: otherwise it’s 412 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) and Idempotent-Replayed: true. A trigger ignores the overlap policy and doesn’t count as a scheduled run; its scheduledTime is 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
Navigation

Type to search…

↑↓ navigate↵ selectEsc close