# Webhook delivery contract

> The exact shape of an outbound SchedStack delivery — HTTP method, headers (Sched-Delivery-Id, Sched-Attempt, Idempotency-Key, Sched-Timestamp, Sched-Signature), body, and the 2xx/non-2xx response contract.

When a schedule fires, SchedStack makes one HTTP request to your endpoint. This page is the
exact contract for that request: the method, every header you receive, the body, and what
your response must say so the delivery is recorded as delivered — not retried or
dead-lettered.

Everything here is verified against the engine. Delivery is **at-least-once**: your endpoint
can receive the same delivery more than once (a retry after a network blip, a recovery fire
after a node restart). The contract gives you exactly what you need to make that safe — a
stable `Idempotency-Key` to dedup and an optional `Sched-Signature` to authenticate.

## The HTTP request

A delivery looks like this on the wire (signed; line-wrapped for readability):

```http
POST /hooks/billing HTTP/1.1
Host: example.com
Content-Type: application/json
X-Your-Header: configured-on-the-schedule
Sched-Delivery-Id: dlv_01KV8Z6Q2J7M3N4P5R6S7T8U9V
Sched-Attempt: 1
Idempotency-Key: dlv_01KV8Z6Q2J7M3N4P5R6S7T8U9V
Sched-Timestamp: 1750972800
Sched-Signature: t=1750972800,v1=3f9a...e1c2

{"invoice":"inv_123","amount":4200}
```

- **Method** comes from the schedule (inline `method`) or the endpoint profile, and defaults
  to `POST`. `GET` and `DELETE` deliveries carry the same headers; the body is sent only if
  you configured one.
- **Your headers** (the `headers` you set on the schedule or profile) are included on the
  request.
- **Reserved headers always win.** The `Sched-*` headers, `Idempotency-Key`, and
  `Content-Type` (when configured) overwrite any user header of the same name — you cannot
  override them by configuring one yourself. Their relative ordering on the wire is not
  guaranteed.
- **Body** is the raw payload bytes you configured, sent verbatim. SchedStack does not parse,
  re-encode, or wrap it. The delivery `body` is capped at **256 KB**; a larger one is rejected
  at create time with `422 payload_too_large`. See [Limits & constraints](/docs/limits/).

## SchedStack headers

These are set on every delivery (except `Sched-Signature` and `Content-Type`, which are
conditional — see the notes).

| Header | Example | Always sent? | Meaning |
|---|---|---|---|
| `Sched-Delivery-Id` | `dlv_01KV8Z6Q…` | Yes | The occurrence ID. Stable across every retry of this delivery; unique per occurrence of a recurring schedule. Use it to look the delivery up via the API. |
| `Sched-Attempt` | `1` | Yes | The 1-based attempt number for this delivery. `1` on the first try, `2` on the first retry, and so on. |
| `Idempotency-Key` | `dlv_01KV8Z6Q…` | Yes | The **stable dedup key** for this occurrence. Identical across all attempts and recovery fires of the same delivery. Use it as the dedup key in your handler. |
| `Sched-Timestamp` | `1750972800` | Yes | Unix time in **seconds** (decimal) at the moment the request was signed. This is the `t=` value covered by the signature. |
| `Sched-Signature` | `t=…,v1=…` | Only when a signing secret is configured | HMAC-SHA256 over the canonical payload, hex-encoded. Format and verification: see [Verify webhook signatures](/docs/guides/verify-signatures/). |
| `Content-Type` | `application/json` | Only when set on the target | The content type you configured on the schedule or profile. SchedStack does not infer one from the body. |

`Sched-Request-Id` is returned on **API responses** (for example, when you create a schedule),
**not** on outbound deliveries — your endpoint will not receive it. The OpenAPI document
implies otherwise; the engine does not send it on deliveries.

`Sched-Signature` is **omitted entirely** if no signing secret is active for the delivery
(the endpoint profile's secrets first, then the project default). A delivery with no active
secret is sent **unsigned** — there is no empty or placeholder signature header. Configure a
signing secret before launch and treat any unsigned request as untrusted. See
[Verify webhook signatures](/docs/guides/verify-signatures/).

### `Idempotency-Key`: your dedup key

The `Idempotency-Key` header equals the schedule's `idempotency_key` if you set one, otherwise
the delivery ID (`dlv_…`). Either way it is **stable for the occurrence and across every retry
and recovery fire** — the row keeps its identity. Record processed keys on your side and skip
work when a key repeats; that is what makes at-least-once delivery safe to act on.

This is the **delivery** `Idempotency-Key` — the one SchedStack sends to *your* endpoint.
It is unrelated to the `Idempotency-Key` you send on your own API calls to dedup `POST`s to
SchedStack. See [Idempotency](/docs/concepts/idempotency/) for the distinction.

## Response contract

Your endpoint's HTTP status code decides what happens next.

- **Return `2xx`** (200–299) and the delivery is recorded as **succeeded**. SchedStack reads
  and discards a bounded amount of the response body for connection reuse; the body content is
  not interpreted.
- **Return `408`, `429`, or any `5xx`**, or fail at the transport layer (connection reset,
  timeout, DNS failure), and the attempt is **retryable**. SchedStack schedules another attempt
  per the retry policy until it succeeds or the attempts/budget are exhausted.
- **Return any other non-2xx** (a `3xx` redirect, or any `4xx` other than `408`/`429`) and the
  delivery is **terminal** — dead-lettered immediately, with no further attempts.
- A delivery to a **blocked/disallowed address** (e.g. a private or SSRF target) is also
  terminal — it is dead-lettered immediately, not retried.

The full retry timing, budgets, and dead-letter rules are on
[Retries & dead-letter](/docs/concepts/retries-and-dead-letter/).

Only a `2xx` counts as success — this is not configurable per schedule. A `3xx` redirect is
**not** followed and is treated as terminal. Return your final status directly.

### `Retry-After` is honored

On a retryable response you can ask SchedStack to wait longer before the next attempt.
SchedStack reads the standard `Retry-After` header — either delta-seconds (`Retry-After: 30`)
or an HTTP-date — falling back to `RateLimit-Reset` if it's absent. Whichever it reads acts as
a **floor**, not a full override: the next attempt is scheduled at
`max(backoff, now + Retry-After)`, so the hint can push the next attempt **later** than the
backoff schedule but never earlier. With neither header present, the schedule's backoff policy
alone decides.

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 30
```

### Timeouts

Each attempt has a request timeout. If your endpoint does not respond within it, the attempt
is treated as a transport fault and becomes **retryable**. Acknowledge fast: return `2xx` as
soon as you have durably accepted the delivery, and do the slow work afterward, rather than
holding the connection open until your processing finishes.

## What to do in your handler

A correct receiver, in order:

1. Read `Sched-Signature` and `Sched-Timestamp` and verify the signature against your secret,
   rejecting stale timestamps. See [Verify webhook signatures](/docs/guides/verify-signatures/).
2. Read `Idempotency-Key`. If you have already processed it, return `2xx` and stop.
3. Durably record the key and accept the work.
4. Return `2xx` immediately. Do heavier processing asynchronously.

That sequence makes redelivery safe (idempotent) and trusted (signed), which is the whole
point of the contract: a delivery is never silently lost, and you never act on the same one
twice.
