# Core concepts

> The SchedStack mental model — schedules produce deliveries, deliveries make attempts, and every accepted delivery ends in a terminal state you can see.

SchedStack's delivery hierarchy is three core objects. Learn them once and the
rest of the API reads itself. (A fourth object, the optional reusable
[endpoint profile](/docs/guides/endpoint-profiles/), is a saved destination a
schedule can reference by `endpoint_id` instead of an inline `endpoint` — not
part of the delivery flow below.)

1. A **schedule** is your instruction: *this destination, at this time (or on this
   recurrence)*. You create one with `POST /v1/schedules`.

2. A schedule produces **deliveries** — one per occurrence. A one-shot schedule
   produces one delivery; a recurring schedule produces a new delivery for each
   fire of its `cron`.

3. Each delivery makes one or more **attempts** — individual outbound HTTP requests
   to your endpoint. The first attempt that gets a `2xx` ends the delivery; failures
   are retried under the delivery's `retry_policy`.

```text
schedule ──┬─► delivery (occurrence) ──┬─► attempt 1  (HTTP POST → your endpoint)
           │                           ├─► attempt 2  (retry)
           │                           └─► attempt 3  (succeeds → delivery done)
           └─► delivery (next occurrence, recurring only) …
```

## The guarantee: no silent loss

Once SchedStack accepts a delivery (your `POST` returned `2xx`), that delivery is
durable. It will not vanish, and it will not stall unobserved. It ends in exactly one
**terminal state**, and that outcome is recorded and readable on the delivery:

- **`succeeded`** — an attempt got a `2xx` from your endpoint.
- **`dead_letter`** — retries were exhausted without a `2xx`. The delivery is parked,
  not dropped; you can inspect it and [replay it](/docs/guides/dead-letter-and-replay/).
- **`expired`** — the delivery's `ttl` elapsed before it could land.
- **`canceled`** — you canceled the schedule (or the delivery) before it completed.

There is no fifth outcome and no "lost" outcome. If you accepted it, you can always
find out what happened to it.

**Where to read state**

Non-terminal deliveries are live work; terminal deliveries are recorded history. Both
are queryable via the deliveries endpoints — see the
[API reference](/docs/api/). A delivery never silently leaves this set.

## At-least-once, made safe

SchedStack is **at-least-once**, not exactly-once. A delivery can legitimately hit
your endpoint more than once — a network blip after your `2xx`, a lease handoff, a
[replay](/docs/guides/dead-letter-and-replay/). That is the honest tradeoff of durable
delivery, and we hand you the two tools to make it safe:

- **Signature** — when your project (or the endpoint profile) has at least one active
  signing secret, every outbound request carries a `Sched-Signature` HMAC so your
  receiver can prove the request came from SchedStack before acting on it. Without an
  active secret the header is omitted and the request is sent unsigned, so configure a
  secret before you rely on verification. This is the
  [signature-verification guide](/docs/guides/verify-signatures/) — read it before
  you ship a receiver.
- **Idempotency** — every attempt for a given delivery carries the **same** stable
  outbound `Idempotency-Key` — the schedule's `idempotency_key` if you set one,
  otherwise the delivery's ID. Dedup on it and a redelivered occurrence is a no-op on
  your side.

So the contract is split honestly: SchedStack guarantees *at-least-once with no silent
loss*; **you** guarantee *effectively-once* by verifying the signature and deduping on
the key. Neither side can do the other's half.

**Two idempotency mechanisms — don't confuse them**

- The **request header** `Idempotency-Key` on your `POST /v1/schedules` deduplicates
  **the API call itself** (safe retries of schedule creation). Send the same key with
  the same body and SchedStack replays the original response instead of creating a
  second schedule.
- The **body field** `idempotency_key` rides along to your receiver: it becomes the
  value of the outbound `Idempotency-Key` header, stable across every attempt of the
  occurrence, so **your receiver** can dedup redeliveries. Omit it and SchedStack uses
  the delivery's ID as that value.

The request header guards creation; the body field guards receiver-side delivery.
See [idempotency](/docs/concepts/idempotency/).

## Schedules

A schedule is created live or in **test** mode (the API key prefix — `sk_live_…` vs
`sk_test_…` — decides; the two never mix). It is either a **one-shot** (`delay`,
`fire_at`, or `local_fire_at` + `timezone`) or **recurring** (`cron`, with DST-correct
local-time recurrence — see [recurring schedules](/docs/guides/recurring/)).

```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/hook","delay":"24h"}'
```

```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/hook",
    "local_fire_at": "2026-07-01T09:00:00",
    "timezone": "America/New_York"
  }'
```

```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/hook",
    "cron": "0 9 * * 1-5",
    "timezone": "America/New_York"
  }'
```

### Schedule states

| State | Meaning |
|---|---|
| `active` | The schedule is live. One-shots have a pending delivery; recurring schedules keep producing them. |
| `paused` | You paused it. Pending deliveries are held — not fired, not lost — until you resume. |
| `canceled` | You canceled it. Outstanding deliveries are stopped; this is terminal for the schedule. |

Transitions are driven only by you, via
[pause / resume / cancel](/docs/guides/lifecycle/) — `active → paused → active`, or
`active|paused → canceled`.

**`completed` is in the enum, but the engine does not use it**

The API's schedule-state enum includes a `completed` value, but the engine does **not**
transition fired one-shots to `completed`. A one-shot stays `active` while its single
delivery runs; you observe the *delivery's* terminal state to know the outcome. Don't
poll the schedule for `completed` — it won't appear. Read the delivery instead.

## Deliveries

A delivery is one occurrence of a schedule firing. It moves through a small state
machine. Non-terminal states live on the active delivery; the four **terminal** states
are the recorded outcome.

| State | Terminal? | Meaning |
|---|:---:|---|
| `scheduled` | — | Created and waiting for its fire time. |
| `claimed` | — | A dispatcher node has it and is sending the HTTP request right now. This single state spans the whole send (there is no separate "in-flight" or "sending" state). |
| `retry_scheduled` | — | The last attempt failed retryably; the next attempt is queued for a later time. |
| `paused` | — | The parent schedule was paused while this delivery was outstanding. |
| `succeeded` | ✅ | An attempt got a `2xx`. |
| `dead_letter` | ✅ | Retries exhausted without success. [Inspect and replay.](/docs/guides/dead-letter-and-replay/) |
| `expired` | ✅ | The `ttl` elapsed before the delivery could land. |
| `canceled` | ✅ | Canceled before it completed. |

**States you might expect but won't find**

There is **no** `in_flight` state — `claimed` covers the active send. And `deferred` is
**not** a persisted state; deferral is internal scheduling, never something you observe
on a delivery. Build dashboards and alerts around the eight states above, nothing else.

## Attempts

An attempt is a single HTTP request to your endpoint, numbered from `1`. Every attempt
carries the delivery's reserved headers so your receiver can authenticate and dedup:

| Header | Value |
|---|---|
| `Sched-Delivery-Id` | The delivery's stable ID. |
| `Sched-Attempt` | The attempt number (`1`, `2`, …). |
| `Sched-Timestamp` | Unix seconds when the request was signed. |
| `Sched-Signature` | HMAC over the payload, format `t=…,v1=…` (supports key rotation overlap). Present only when an active signing secret exists; omitted otherwise (request sent unsigned). |
| `Idempotency-Key` | The schedule's `idempotency_key` if you set one, otherwise the delivery ID — **stable across every attempt** of this occurrence either way. Dedup on it. |

These headers are reserved: you cannot override `Sched-*` or `Idempotency-Key` from your
schedule's own `headers`. Each attempt is classified by its result:

| Outcome | What it means | What SchedStack does next |
|---|---|---|
| `success` | A `2xx` response. | Delivery → `succeeded`. Done. |
| `retryable` | A `5xx`, `429`, timeout, or connection error. | Schedule the next attempt per `retry_policy`; delivery → `retry_scheduled`. When retries run out → `dead_letter`. |
| `terminal` | A non-retryable result (most `4xx`, plus `3xx` redirects and blocked/SSRF-rejected destinations). | Stop. Delivery → `dead_letter`. No point retrying a request your endpoint rejected — or one we refused to send. |

How many `retryable` attempts run, and the backoff between them, comes from the
delivery's `retry_policy` — see [retries and backoff](/docs/concepts/retries-and-dead-letter/).

## Status legend

A compact reference you can keep open while you build:

| Object | Live (non-terminal) | Terminal |
|---|---|---|
| **Schedule** | `active`, `paused` | `canceled` |
| **Delivery** | `scheduled`, `claimed`, `retry_scheduled`, `paused` | `succeeded`, `dead_letter`, `expired`, `canceled` |
| **Attempt** (outcome) | — | `success`, `retryable`, `terminal` |

## Where to go next

- [Quickstart](/docs/quickstart/) — create a schedule, receive it, verify it, watch it land.
- [Verifying signatures](/docs/guides/verify-signatures/) — the receiver-side trust centerpiece, with Node / Python / Go samples.
- [Idempotency](/docs/concepts/idempotency/) — request keys vs. delivery dedup, kept straight.
- [Retries and backoff](/docs/concepts/retries-and-dead-letter/) — the `retry_policy` and how attempts are classified.
- [Dead-letter and replay](/docs/guides/dead-letter-and-replay/) — inspect parked deliveries and resend them.
- [Recurring schedules](/docs/guides/recurring/) — `cron`, timezones, and DST-correct firing.
- [Lifecycle](/docs/guides/lifecycle/) — pause, resume, cancel, reschedule.
- [API reference](/docs/api/) — the full interactive REST reference.
