# Idempotency

Unsafe requests (`POST`, `PATCH`, `PUT`, `DELETE`) can carry an `Idempotency-Key` header so
that a retry — after a dropped connection, a timeout, or a cron re-run — does not create a
duplicate.

```
Idempotency-Key: <your-unique-key>
```

Use a fresh, unique value per logical operation (a UUID is ideal).

## How it works

The server stores, per `(tenant, api_key, idempotency_key)`, a hash of the request and the
response it eventually returned (status + body), for **24 hours**.

- **Same key + same request body** → the original response is **replayed verbatim** (same
  status, same body) with an `Idempotency-Replayed: true` response header. The operation runs
  only once.
- **Same key + different request body** → `422 idempotency.key_mismatch`. Use a fresh key.
- Only responses with status `< 500` are cached. A request that errored with a 5xx is not
  cached, so a later retry can succeed.
- Safe methods (`GET`/`HEAD`/`OPTIONS`) ignore the header.

> Some writes are already idempotent by nature — e.g. `POST /api/v1/reports` returns the
> existing in-flight job for the same `report_year` + `standard_type` + `format`. The
> `Idempotency-Key` header adds at-most-once semantics to **any** write.

## An idempotent POST

**curl**
```bash
KEY="qk_live_<your-key>"
IDEMP=$(uuidgen)   # e.g. 9f1c2e3a-...

curl -X POST https://api.qlim8.com/api/v1/targets \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEMP" \
  -d '{
    "name": "SBTi 2030",
    "baseline_year": 2019,
    "baseline_emissions": 1000,
    "target_year": 2030,
    "target_reduction_percent": 46.2,
    "scope": "1+2"
  }'

# Re-running the exact command replays the first response (Idempotency-Replayed: true)
# instead of creating a second target.
```

**TypeScript (fetch)**
```ts
import { randomUUID } from "node:crypto";

async function createTarget(key: string, body: object) {
  const idempotencyKey = randomUUID();
  const send = () =>
    fetch("https://api.qlim8.com/api/v1/targets", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${key}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey, // SAME key on every retry of this op
      },
      body: JSON.stringify(body),
    });

  let res = await send();
  if (res.status >= 500) res = await send(); // safe to retry — at most one target is created
  if (!res.ok) throw new Error(`[${res.status}] ${(await res.json()).code}`);
  return res.json();
}
```

**Python (httpx)**
```python
import os, uuid, httpx

def create_target(key: str, body: dict) -> dict:
    idempotency_key = str(uuid.uuid4())
    headers = {
        "Authorization": f"Bearer {key}",
        "Idempotency-Key": idempotency_key,  # reuse on every retry of this op
    }
    with httpx.Client() as client:
        resp = client.post("https://api.qlim8.com/api/v1/targets", headers=headers, json=body)
        if resp.status_code >= 500:
            resp = client.post("https://api.qlim8.com/api/v1/targets", headers=headers, json=body)
        resp.raise_for_status()
        return resp.json()

create_target(os.environ["QLIM8_KEY"], {
    "name": "SBTi 2030", "baseline_year": 2019, "baseline_emissions": 1000,
    "target_year": 2030, "target_reduction_percent": 46.2, "scope": "1+2",
})
```

## Error

| HTTP | `code` | Cause | Fix |
|---|---|---|---|
| 422 | `idempotency.key_mismatch` | Same `Idempotency-Key` reused with a different request body | Use a fresh key for a different operation. |
