Webhook delivery 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
Section titled “The HTTP request”A delivery looks like this on the wire (signed; line-wrapped for readability):
POST /hooks/billing HTTP/1.1Host: example.comContent-Type: application/jsonX-Your-Header: configured-on-the-scheduleSched-Delivery-Id: dlv_01KV8Z6Q2J7M3N4P5R6S7T8U9VSched-Attempt: 1Idempotency-Key: dlv_01KV8Z6Q2J7M3N4P5R6S7T8U9VSched-Timestamp: 1750972800Sched-Signature: t=1750972800,v1=3f9a...e1c2
{"invoice":"inv_123","amount":4200}- Method comes from the schedule (inline
method) or the endpoint profile, and defaults toPOST.GETandDELETEdeliveries carry the same headers; the body is sent only if you configured one. - Your headers (the
headersyou set on the schedule or profile) are included on the request. - Reserved headers always win. The
Sched-*headers,Idempotency-Key, andContent-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.
SchedStack headers
Section titled “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. |
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. |
Idempotency-Key: your dedup key
Section titled “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.
Response contract
Section titled “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 any5xx, 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
3xxredirect, or any4xxother than408/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.
Retry-After is honored
Section titled “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/1.1 429 Too Many RequestsRetry-After: 30Timeouts
Section titled “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
Section titled “What to do in your handler”A correct receiver, in order:
- Read
Sched-SignatureandSched-Timestampand verify the signature against your secret, rejecting stale timestamps. See Verify webhook signatures. - Read
Idempotency-Key. If you have already processed it, return2xxand stop. - Durably record the key and accept the work.
- Return
2xximmediately. 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.