Skip to content

Recurring schedules & DST

A recurring schedule fires the same delivery over and over on a cron cadence. You give SchedStack a cron expression and (optionally) a timezone; it computes each fire time itself, DST-correct, and previews the next runs in the response.

Pass cron instead of a one-shot timing field (delay / fire_at / local_fire_at). Add timezone to anchor the cron to a wall clock; omit it and SchedStack uses UTC.

Terminal window
curl -X POST https://api.schedstack.com/v1/schedules \
-H "Authorization: Bearer sk_test_…" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "https://acme.dev/cron/digest",
"method": "POST",
"body": "{\"job\":\"daily-digest\"}",
"cron": "30 9 * * *",
"timezone": "America/New_York",
"metadata": {"job": "daily-digest"}
}'

30 9 * * * in America/New_York means 09:30 local time, every day. The response echoes the schedule and previews the next fire instants — note next_runs is computed against the target timezone, so the absolute UTC offset shifts across a DST boundary while the local time stays at 09:30:

{
"id": "sch_01J9ZK3F8Q",
"object": "schedule",
"mode": "test",
"kind": "recurring",
"state": "active",
"endpoint": "https://acme.dev/cron/digest",
"method": "POST",
"header_keys": [],
"fire_at": null,
"cron": "30 9 * * *",
"timezone": "America/New_York",
"next_fire_at": "2026-06-28T13:30:00Z",
"next_runs": [
"2026-06-28T13:30:00Z",
"2026-06-29T13:30:00Z",
"2026-06-30T13:30:00Z",
"2026-07-01T13:30:00Z",
"2026-07-02T13:30:00Z"
],
"ttl": null,
"retry_policy": {
"max_attempts": 8,
"strategy": "exponential",
"base": "5s",
"factor": 2,
"max": "1h0m0s",
"jitter": true
},
"metadata": {"job": "daily-digest"},
"created_at": "2026-06-27T18:00:00Z",
"updated_at": "2026-06-27T18:00:00Z"
}

The jitter field is accepted and echoed back, but it is not currently applied to retry timing — retries use deterministic exponential backoff.

Each fire produces its own delivery — signed and retried independently. A recurring schedule is at-least-once like any SchedStack delivery, so your receiver still verifies the signature and deduplicates on the Idempotency-Key. See Verifying signatures.

cron is a 5-field standard cron expression:

┌───────────── minute (0–59)
│ ┌─────────── hour (0–23)
│ │ ┌───────── day-of-month (1–31)
│ │ │ ┌─────── month (1–12 or JAN–DEC)
│ │ │ │ ┌───── day-of-week (0–6 or SUN–SAT; 0 = Sunday)
│ │ │ │ │
* * * * *

It is minute-granular: there is no seconds field. The five fields support the usual operators — * (every), , (lists), - (ranges), and / (steps).

Expression Meaning
30 9 * * * 09:30 every day
0 * * * * Top of every hour
*/15 * * * * Every 15 minutes
0 0 * * 1 Midnight every Monday
0 9 1 * * 09:00 on the 1st of every month
0 8 * * 1-5 08:00 on weekdays

timezone is any IANA timezone name (e.g. America/New_York, Europe/London, Asia/Kolkata, UTC).

This is where SchedStack differs from naive cron. When a schedule is anchored to a timezone that observes daylight saving, two clock-shift days a year break the assumption that every local time happens exactly once. A naive scheduler either skips a fire or fires it twice. SchedStack does neither — it computes occurrences from the local wall clock and resolves the edge cases deterministically.

Spring-forward — fires once, never zero times

Section titled “Spring-forward — fires once, never zero times”

On the spring-forward day the local clock jumps ahead (in US zones, 02:00 → 03:00), so a wall time inside the gap — say 30 2 * * * (02:30) — does not exist that day.

  • Naive cron: the 02:30 slot is invalid, so the job is skipped entirely for that day.
  • SchedStack: the fire shifts to the gap-end — the first valid instant after the transition (here, 03:00 local) — so the job fires once, never zero times.

On the fall-back day the local clock repeats an hour (in US zones, 02:00 → 01:00), so a wall time inside the repeated hour — say 30 1 * * * (01:30) — happens twice.

  • Naive cron: the 01:30 slot matches both occurrences, so the job fires twice.
  • SchedStack: the fire lands on the first (pre-transition) occurrence only, so the job fires once, never twice.

Times outside the gap or fold are unaffected: a 09:30 daily job fires at 09:30 local every day, and the engine carries the corresponding UTC offset for you.

A recurring schedule keeps firing until you stop it. Use the lifecycle controls — they don’t lose in-flight deliveries:

  1. Pause to stop future fires without deleting the schedule:

    Terminal window
    curl -X POST https://api.schedstack.com/v1/schedules/sch_01J9ZK3F8Q/pause \
    -H "Authorization: Bearer sk_test_…"
  2. Resume re-activates the schedule and any paused deliveries:

    Terminal window
    curl -X POST https://api.schedstack.com/v1/schedules/sch_01J9ZK3F8Q/resume \
    -H "Authorization: Bearer sk_test_…"
  3. Cancel to stop the schedule permanently:

    Terminal window
    curl -X POST https://api.schedstack.com/v1/schedules/sch_01J9ZK3F8Q/cancel \
    -H "Authorization: Bearer sk_test_…"

To change the cron or timezone of an existing schedule, use the reschedule operation rather than editing in place. The full state machine — activepausedcanceled, and what happens to a delivery already in flight when you pause or cancel — is covered in Schedule lifecycle.