# Recurring schedules & DST

> Run a delivery on a cron schedule in any IANA timezone, with DST-correct recurrence that never skips or doubles a fire.

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.

## Create a recurring schedule

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

```bash
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:

```json
{
  "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](/docs/guides/verify-signatures/).

## The cron format

`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`).

A `cron` expression that does not parse as a 5-field standard expression, or a `timezone`
that is not a known IANA name, returns **`422`** with error code **`invalid_cron`** (a
bad timezone reports on the `timezone` field). Fix the expression — the schedule is not
created.

## DST-correct recurrence

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

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.

### Fall-back — fires once, never twice

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.

You never have to reason about this in your head. The `next_runs` array in the response is
computed with the same DST rules used to fire, so the previewed instants are
exactly when delivery will be attempted. For a schedule anchored to a DST zone, inspect
`next_runs` across the transition date to confirm the local time holds steady while the UTC
offset shifts.

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 one-shot scheduled with `local_fire_at` + `timezone` resolves through the **same**
spring-forward (gap-end) and fall-back (first occurrence) logic, so a one-shot fires when
the equivalent recurring schedule would. See [Scheduling a one-shot](/docs/guides/one-shot/).

## Pause, resume, or cancel

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:

   ```bash
   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:

   ```bash
   curl -X POST https://api.schedstack.com/v1/schedules/sch_01J9ZK3F8Q/resume \
     -H "Authorization: Bearer sk_test_…"
   ```

3. **Cancel** to stop the schedule permanently:

   ```bash
   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 — `active` → `paused` → `canceled`,
and what happens to a delivery already in flight when you pause or cancel — is covered in
[Schedule lifecycle](/docs/guides/lifecycle/).
