Skip to content

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.

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

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.

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.

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.

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.

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 Requests
Retry-After: 30

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.

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