Idempotency
SchedStack has two independent idempotency mechanisms that both use a header named
Idempotency-Key. They solve different problems and live on different sides of the wire.
Keep them straight:
| Request idempotency | Delivery dedup | |
|---|---|---|
| Direction | You → SchedStack API | SchedStack → your endpoint |
| Header | Idempotency-Key you set on a mutating call |
Idempotency-Key SchedStack sets on each delivery |
| Purpose | Make a retried API call safe (no duplicate schedule) | Let your receiver dedup at-least-once delivery |
| Value | Any string you choose | Your schedule’s idempotency_key, else the delivery id dlv_… |
| Lifetime | Stored 24h, scoped to (project, mode, key) |
Stable across all retries and reclaims of one occurrence |
1. Request idempotency (your API calls)
Section titled “1. Request idempotency (your API calls)”Set an Idempotency-Key header on any mutating call (for example POST /v1/schedules) so
a network retry can’t create two schedules. The header is optional but recommended —
send no header and you get no idempotency. The key is a free-form string (a UUID is ideal).
There’s no enforced length limit today, but keep it short — 255 characters or fewer is a good
rule.
curl -X POST https://api.schedstack.com/v1/schedules \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 7d3f2c1a-9b8e-4f60-bf2a-1e0c5d6a4b21" \ -d '{"endpoint":"https://acme.dev/hook","delay":"24h"}'How it behaves
Section titled “How it behaves”SchedStack fingerprints the request as SHA-256(method + "\n" + path + "\n" + body) and
stores it under (project, mode, key). On a repeat with the same key:
-
Same key, same request, already completed → the original response is replayed verbatim (same status, same body), with an
Idempotent-Replayed: trueresponse header. Your retry sees exactly what the first call returned — no second schedule is created. -
Same key, different request body →
409 Conflict, codeidempotency_key_reuse. A key is bound to the first body it saw. -
Same key, a request still in progress →
409 Conflict, codeidempotency_in_progress. Wait and retry.
A reuse conflict looks like this:
{ "error": { "type": "idempotency_error", "code": "idempotency_key_reuse", "message": "This Idempotency-Key was already used with a different request body.", "request_id": "req_…" }}Keys expire 24 hours after first use. Reuse one after that and it starts fresh.
For choosing good keys and a full retry-loop walkthrough, see Using idempotency keys.
2. Delivery dedup (SchedStack’s calls to you)
Section titled “2. Delivery dedup (SchedStack’s calls to you)”On every delivery to your endpoint, SchedStack sends an Idempotency-Key request
header. Its value is:
- your schedule’s
idempotency_key, if you set one when creating the schedule; otherwise - the delivery id,
dlv_….
Either way the value is stable across every retry and reclaim of the same occurrence.
The same occurrence delivered three times carries the same Idempotency-Key all three
times — so your receiver can record which keys it has processed and skip duplicates.
A delivery arrives with these headers (alongside any you configured on the endpoint):
Idempotency-Key: dlv_01KV… # stable per occurrence, across retries/reclaimsSched-Delivery-Id: dlv_01KV… # the delivery id (== Idempotency-Key when you set no key)Sched-Attempt: 2 # 1-based attempt counter (changes per retry)Sched-Timestamp: 1750000000 # unix seconds, part of the signatureSched-Signature: … # HMAC over the request, when signing secrets existPin the delivery key to your own id
Section titled “Pin the delivery key to your own id”Set idempotency_key on the schedule to a value your system already understands (an order
id, a job id) so dedup keys line up with your domain instead of an opaque dlv_…:
{ "endpoint": "https://acme.dev/hook", "fire_at": "2026-07-01T09:00:00Z", "idempotency_key": "order_4821_reminder"}Dedup in your receiver
Section titled “Dedup in your receiver”Verify the signature first, then dedup on the key. Use your datastore’s
insert-if-absent (a unique constraint, SETNX, etc.) so concurrent retries can’t both win.
app.post('/hook', async (req, res) => { // verifySignature(req) first — see the signature guide. const key = req.get('Idempotency-Key');
// INSERT ... ON CONFLICT DO NOTHING — true only the first time we see this key. const fresh = await db.recordDeliveryKey(key); if (!fresh) return res.sendStatus(200); // already processed — ack and stop.
await handleDelivery(req.body); res.sendStatus(200);});@app.post("/hook")def hook(): # verify_signature(request) first — see the signature guide. key = request.headers["Idempotency-Key"]
# Returns False if the key already exists (unique constraint / SETNX). if not db.record_delivery_key(key): return "", 200 # already processed — ack and stop.
handle_delivery(request.get_json()) return "", 200func hook(w http.ResponseWriter, r *http.Request) { // verifySignature(r) first — see the signature guide. key := r.Header.Get("Idempotency-Key")
// fresh is false if this key was seen before (INSERT ... ON CONFLICT DO NOTHING). fresh, err := recordDeliveryKey(r.Context(), key) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } if !fresh { w.WriteHeader(http.StatusOK) // already processed — ack and stop. return }
handleDelivery(r) w.WriteHeader(http.StatusOK)}Which one do I need?
Section titled “Which one do I need?”- Retrying a
POST /v1/schedulescall and want to avoid duplicate schedules → request idempotency (set the header yourself). - Receiving deliveries and want your handler to run a job exactly once → delivery dedup (read the header SchedStack sends).
Most integrations use both: a request key on the create call, and dedup-on-receive in the handler.