Skip to content

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

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.

Terminal window
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"}'

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:

  1. Same key, same request, already completed → the original response is replayed verbatim (same status, same body), with an Idempotent-Replayed: true response header. Your retry sees exactly what the first call returned — no second schedule is created.

  2. Same key, different request body409 Conflict, code idempotency_key_reuse. A key is bound to the first body it saw.

  3. Same key, a request still in progress409 Conflict, code idempotency_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/reclaims
Sched-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 signature
Sched-Signature: … # HMAC over the request, when signing secrets exist

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"
}

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);
});
  • Retrying a POST /v1/schedules call 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.