# Idempotency

> The two idempotency mechanisms in SchedStack — the Idempotency-Key you send on API calls, and the Idempotency-Key SchedStack sends to your endpoint on every delivery.

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 |

Delivery is **at-least-once**. SchedStack guarantees your endpoint is called until it
succeeds (never silently lost) — which means it can be called **more than once** for the
same occurrence. The delivery `Idempotency-Key` is how you make that safe on your side.

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

```bash
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

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 body** → `409 Conflict`, code `idempotency_key_reuse`. A
   key is bound to the first body it saw.

3. **Same key, a request still in progress** → `409 Conflict`, code
   `idempotency_in_progress`. Wait and retry.

A reuse conflict looks like this:

```json
{
  "error": {
    "type": "idempotency_error",
    "code": "idempotency_key_reuse",
    "message": "This Idempotency-Key was already used with a different request body.",
    "request_id": "req_…"
  }
}
```

**Errors don't poison the key**

Only settled outcomes (`2xx`/`3xx`) are cached. If your call returns a `4xx` or `5xx`, the
lock is released — a corrected retry with the **same** key can proceed instead of being
stuck on a 409 for 24 hours.

**Test and live never cross**

Keys are scoped by mode. The same `Idempotency-Key` used with an `sk_test_…` key and an
`sk_live_…` key are independent — a test request never replays a live response or vice
versa.

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](/docs/guides/idempotency-keys/).

## 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
```

Dedup on `Idempotency-Key`, **not** on `Sched-Attempt` or `Sched-Delivery-Id` semantics
you invent. `Sched-Attempt` increments on every retry, so it is different each time. The
`Idempotency-Key` is the value that stays constant for one logical occurrence.

### 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_…`:

```json
{
  "endpoint": "https://acme.dev/hook",
  "fire_at": "2026-07-01T09:00:00Z",
  "idempotency_key": "order_4821_reminder"
}
```

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

```js
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);
});
```

```python
@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 "", 200
```

```go
func 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)
}
```

Always **verify the signature before** trusting `Idempotency-Key`. An unauthenticated
caller could otherwise replay a known key to suppress a real delivery. See
[Verifying signatures](/docs/guides/verify-signatures/).

## Which one do I need?

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