# Quickstart

> Schedule your first durable HTTP delivery with SchedStack, verify its signature, and watch it land — in under five minutes.

You need two things: an **API key** and an **endpoint** that can receive an HTTP request.
By the end of this page you'll have scheduled a delivery, verified its signature, and
watched it succeed.

If you just want the call:

```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":"30s"}'
```

That schedules one HTTP `POST` to your endpoint, 30 seconds from now. The rest of this page
walks through it and shows you how to trust what arrives.

1. ### Get your API key

   During the design-partner phase there's no self-serve signup — keys are issued to you
   directly. A key looks like `sk_test_…` (test mode) or `sk_live_…` (live mode) and goes in
   the `Authorization` header on every request:

   ```bash
   -H "Authorization: Bearer sk_test_…"
   ```

   Use your **test** key for this walkthrough. Test and live are fully isolated: a test key
   only ever sees test data, and the [dispatch-accuracy SLO](#4-watch-it-land) applies to
   live mode only. Nothing you do here touches production data.

   Your API key is a bearer credential. Keep it server-side — never ship it to a browser,
   a mobile app, or a public repo.

2. ### Create a schedule

   Point SchedStack at a destination and a time. The shortest possible request is an
   `endpoint` plus exactly one timing field:

   ```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": "30s"
     }'
   ```

   `delay` is a duration string (`"30s"`, `"15m"`, `"24h"`) with a 1-second floor. We use
   `"30s"` so you can watch the whole flow end-to-end right now; in real use you'd schedule
   minutes, hours, or days out. To fire at an exact instant instead, use `fire_at`
   (RFC 3339, e.g. `"2026-07-01T09:00:00Z"`); for wall-clock-in-a-timezone use `local_fire_at`
   with `timezone`; for recurring, use a 5-field `cron` with `timezone`. Exactly one timing
   field is required.

   A few bounds the request must obey: the minimum `delay` is **1 s** (below it →
   `422 sub_floor_delay`); a `fire_at` must be a **future RFC 3339 instant within 10 years**
   (`422 fire_at_in_past` / `422 fire_at_too_far`); the API request body is capped at **1 MB**
   (`400 invalid_json` if larger), and the delivery `body` it carries is capped at **256 KB**
   (`422 payload_too_large`). See [Limits & constraints](/docs/limits/).

   You get back a `201` with the created schedule:

   ```json
   {
     "id": "sch_01J9ZK3F8QABCDXY",
     "object": "schedule",
     "mode": "test",
     "kind": "one_shot",
     "state": "active",
     "endpoint": "https://acme.dev/hook",
     "method": "POST",
     "header_keys": [],
     "fire_at": "2026-06-27T18:30:30Z",
     "cron": null,
     "timezone": null,
     "next_fire_at": "2026-06-27T18:30:30Z",
     "next_runs": ["2026-06-27T18:30:30Z"],
     "ttl": null,
     "retry_policy": {
       "max_attempts": 8,
       "strategy": "exponential",
       "base": "5s",
       "factor": 2,
       "max": "1h",
       "jitter": true
     },
     "metadata": {}
   }
   ```

   Hold on to `id` (`sch_…`) — you'll use it to check on the delivery. `next_runs` previews
   the next fire instants; for a one-shot there's just one.

   `retry_policy.jitter` is accepted and echoed back, but **not applied yet** — today's
   backoff is deterministic exponential (`base × factor^attempt`, capped at `max`). Don't
   rely on jitter for thundering-herd spread.

   `header_keys` echoes the **names** of any headers you attached, never their values. Pass
   custom headers with a `headers` object and a request `body` string (up to 256 KB) when you
   create the schedule.

3. ### Verify the signature on what arrives

   When the schedule fires, SchedStack sends an HTTP request to your endpoint with these
   headers:

   | Header | Meaning |
   |---|---|
   | `Sched-Delivery-Id` | This occurrence's id (`dlv_…`). |
   | `Sched-Attempt` | Attempt number, 1-based. |
   | `Sched-Timestamp` | Dispatch time, Unix **seconds** (also the value bound into the signature when signed). |
   | `Idempotency-Key` | Stable per occurrence and across retries — **dedup on this**. |
   | `Sched-Signature` | `t=<unix_seconds>,v1=<hex>` — HMAC-SHA256, hex-encoded. |

   The signature covers exactly this string, joined by literal `.`:

   ```
   {timestamp}.{delivery_id}.{attempt}.{METHOD}.{path}.{body}
   ```

   where `path` is the URL path of your endpoint (e.g. `/hook`) and `body` is the raw request
   bytes. Recompute the HMAC with your signing secret and constant-time compare. Here's a
   minimal verifier:

   ```js

   // `rawBody` MUST be the exact bytes received (a Buffer), not a re-serialized object.
   export function verify({ method, path, rawBody, headers, secret, toleranceSec = 300 }) {
     const sig = headers['sched-signature'] || '';
     const deliveryId = headers['sched-delivery-id'];
     const attempt = headers['sched-attempt'];

     // Sched-Signature: t=<unix_seconds>,v1=<hex>[,v1=<hex>…]
     const fields = sig.split(',');
     const t = fields.find((f) => f.startsWith('t='))?.slice(2);
     const v1s = fields.filter((f) => f.startsWith('v1=')).map((f) => f.slice(3));
     if (!t || v1s.length === 0) return false;

     // Reject stale or forged timestamps. (The sender enforces no window — you do.)
     if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;

     // {timestamp}.{delivery_id}.{attempt}.{METHOD}.{path}.{body}
     const prefix = `${t}.${deliveryId}.${attempt}.${method.toUpperCase()}.${path}.`;
     const signed = Buffer.concat([Buffer.from(prefix), rawBody]);
     const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex');

     // Accept if ANY v1 matches — multiple means a secret rotation is in progress.
     return v1s.some(
       (s) =>
         s.length === expected.length &&
         crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
     );
   }
   ```

   - `method` — the HTTP method of the incoming request (upper-cased internally).
   - `path` — the request path, e.g. `req.path` → `/hook`.
   - `rawBody` — the exact request body bytes. Capture them **before** any JSON parsing
     (e.g. `express.raw()` or `req.rawBody`); a re-serialized object will not match.
   - `headers` — incoming headers, lower-cased keys.
   - `secret` — your endpoint or project signing secret.

   If a schedule has **no active signing secret**, deliveries are sent **unsigned** (no
   `Sched-Signature` header at all) — so always treat a missing signature as a hard failure,
   not "skip verification." Configure a secret before going live.

   The full guide — secret rotation, replay-window tuning, and Python/Go verifiers — is in
   [Verifying signatures](/docs/guides/verify-signatures/).

4. ### Watch it land

   Once your endpoint returns any `2xx`, the delivery flips to `succeeded`. List the
   schedule's deliveries to confirm:

   ```bash
   curl https://api.schedstack.com/v1/schedules/sch_01J9ZK3F8QABCDXY/deliveries \
     -H "Authorization: Bearer sk_test_…"
   ```

   ```json
   {
     "object": "list",
     "data": [
       {
         "id": "dlv_01J9ZK3F8QPLMN",
         "object": "delivery",
         "schedule_id": "sch_01J9ZK3F8QABCDXY",
         "mode": "test",
         "status": "succeeded",
         "scheduled_for": "2026-06-27T18:30:30Z",
         "attempt_count": 1,
         "last_status_code": 200,
         "idempotency_key": "dlv_01J9ZK3F8QPLMN",
         "created_at": "2026-06-27T18:30:00Z",
         "finalized_at": "2026-06-27T18:30:30Z"
       }
     ],
     "has_more": false,
     "next_cursor": null
   }
   ```

   That's a verified delivery. Done.

## What happens if your endpoint fails

This is the core guarantee: an accepted delivery is **never silently lost**. It either
succeeds or ends up somewhere you can see it.

- **`2xx`** → `succeeded`.
- **`408`, `429`, `5xx`, or a transport/connection fault** → retried with exponential backoff
  (default: up to 8 attempts, `5s` base, ×2, capped at `1h`). A `Retry-After` header on a
  `429`/`503` is honored. If every attempt fails, the delivery moves to `dead_letter`.
- **`3xx`, `4xx` (other than 408/429), or a blocked address** → treated as terminal and
  **dead-lettered immediately** — retrying won't help.

Nothing is dropped on the floor. Inspect each HTTP try with
`GET /v1/deliveries/{id}/attempts`, and re-send a dead-lettered delivery with
`POST /v1/deliveries/{id}/replay` once you've fixed the cause.

Delivery is **at-least-once**, so a retry can land a second copy. That's why the two
safety primitives above matter: **verify the signature** to trust the request, and
**deduplicate on `Idempotency-Key`** to make repeats harmless. Together they make
at-least-once safe to build on.

The published **100–300 ms dispatch SLO** (p99) measures *dispatch initiation* — the gap
between when a schedule was due and the first byte we send — for first attempts in **live**
mode. It is not a delivery-completion time, and it excludes retries and reclaims.

## Next steps

- **[Core concepts](/docs/concepts/overview/)** — schedules, deliveries, attempts, and the
  state machines behind them.
- **[Verify webhook signatures](/docs/guides/verify-signatures/)** — the full trust guide,
  with rotation and multi-language verifiers.
- **[Recurring schedules & DST](/docs/guides/recurring/)** — `cron` + `timezone`, with
  DST-correct firing.
- **[Agent-native (MCP)](/docs/mcp/)** — let an agent schedule, preview, and inspect
  deliveries in natural language.
