# Idempotency keys on requests

> Send an Idempotency-Key on POST/PATCH calls so a retried API request never double-creates — the stored response replays instead.

Network calls fail in ways that hide whether the server got the request. You send a
`POST /v1/schedules`, the connection drops before the response arrives, and now you don't
know if a schedule was created. Retry blindly and you might create two.

Send an **`Idempotency-Key`** header on mutating requests and that ambiguity goes away. A
retry with the same key replays the original response instead of running the handler
again, so the retry is safe.

This page is about **request** idempotency — the API-call side, so an HTTP retry of a
`POST`/`PATCH` doesn't double-create. That's different from **delivery** idempotency, where
SchedStack sends a stable `Idempotency-Key` to *your* endpoint on every delivery attempt so
your handler can dedup retried deliveries. See
[Idempotency](/docs/concepts/idempotency/) for the request-vs-delivery distinction.

## Send the header

Generate a unique key per logical operation (a UUID is the easy choice) and pass it on the
request:

```bash
curl -sS https://api.schedstack.com/v1/schedules \
  -H "Authorization: Bearer sk_test_…" \
  -H "Idempotency-Key: 7e3a1f6c-2b9d-4a1e-8c5f-9d0b1a2c3d4e" \
  -H "Content-Type: application/json" \
  -d '{
    "endpoint": "https://example.com/hooks/billing",
    "method": "POST",
    "delay": "5m",
    "body": "{\"invoice\":\"inv_123\"}"
  }'
```

The key is optional but recommended on every mutation. A request with **no**
`Idempotency-Key` header gets no idempotency protection — it runs every time.

Idempotency applies to the mutating endpoints:

- `POST /v1/schedules`, `PATCH /v1/schedules/{id}`, `POST /v1/schedules/{id}/reschedule`
- `POST /v1/endpoints`, `PATCH /v1/endpoints/{id}`
- `POST /v1/deliveries/{id}/replay`

## How a retry is resolved

SchedStack stores the key with a fingerprint of your request (method + path + body) and,
once the request settles, the response status and body. A retry is matched against that
record.

1. **Same key, same body, original succeeded** → the stored response replays. You get the
   original status code and body back, plus an `Idempotent-Replayed: true` response header.
   The handler does **not** run again, so nothing is double-created.

2. **Same key, *different* body** → `409 Conflict` with code `idempotency_key_reuse`. A key
   is bound to the exact request it first ran; reusing it for a different payload is a bug
   on the caller's side, so SchedStack refuses rather than guess.

3. **Same key, original still in flight** → `409 Conflict` with code
   `idempotency_in_progress`. Two requests with the same key raced; one wins and runs, the
   other is rejected so the operation runs at most once.

### Replay example

The first call creates the schedule and returns `201`. Retry the **exact same request** and
you get the same `201` body back, marked as a replay:

```http
HTTP/1.1 201 Created
Idempotent-Replayed: true
Content-Type: application/json

{ "id": "sch_01J…", "state": "active", … }
```

Because the body is byte-identical to the stored response, your code can treat the replay
exactly like the original — same schedule ID, no duplicate.

### Conflict example

Reusing a key with a changed body returns the typed error envelope:

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

A concurrent in-flight request returns the same envelope with code
`idempotency_in_progress` — retry it once the first request settles.

## What gets stored, and for how long

- **Window:** a key is remembered for **24 hours** from the request, then it expires and
  the same string can be reused for a fresh operation. Retry within that window for replay
  protection.
- **Scope:** keys are isolated per **(project, mode, key)**. The same key string in `test`
  mode and `live` mode are independent and never collide.
- **Only settled outcomes are cached.** If the original request returned a `4xx` or `5xx`,
  the key is released instead of stored — a `4xx` is a client error you can fix and a `5xx`
  is transient, so a corrected retry with the same key is allowed to proceed rather than
  being locked to a failed result for 24 hours.

Pick the key at the point where you decide to perform the operation, not per HTTP attempt.
Reuse the *same* key across all retries of that one logical operation — that's what makes
the retry safe. A new key per attempt defeats the purpose.

## Generating keys

```bash
IDEMPOTENCY_KEY=$(uuidgen)
echo "$IDEMPOTENCY_KEY"
```

```js
const idempotencyKey = crypto.randomUUID();

await fetch("https://api.schedstack.com/v1/schedules", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.SCHEDSTACK_API_KEY}`,
    "Idempotency-Key": idempotencyKey,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    endpoint: "https://example.com/hooks/billing",
    method: "POST",
    delay: "5m",
    body: JSON.stringify({ invoice: "inv_123" }),
  }),
});
```

```python

idempotency_key = str(uuid.uuid4())

httpx.post(
    "https://api.schedstack.com/v1/schedules",
    headers={
        "Authorization": f"Bearer {os.environ['SCHEDSTACK_API_KEY']}",
        "Idempotency-Key": idempotency_key,
        "Content-Type": "application/json",
    },
    json={
        "endpoint": "https://example.com/hooks/billing",
        "method": "POST",
        "delay": "5m",
        "body": json.dumps({"invoice": "inv_123"}),
    },
)
```

```go
key := uuid.NewString()

req, _ := http.NewRequest("POST",
    "https://api.schedstack.com/v1/schedules",
    strings.NewReader(`{"endpoint":"https://example.com/hooks/billing","method":"POST","delay":"5m","body":"{\"invoice\":\"inv_123\"}"}`),
)
req.Header.Set("Authorization", "Bearer "+os.Getenv("SCHEDSTACK_API_KEY"))
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Content-Type", "application/json")
```

## Related

- [Idempotency](/docs/concepts/idempotency/) — request idempotency vs delivery idempotency,
  and how SchedStack makes at-least-once delivery safe.
- [Limits & constraints](/docs/limits/) — the idempotency window, scope, and other bounds in one place.
- [Quickstart](/docs/quickstart/) — schedule your first durable delivery.
