> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mareaalcalina.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Idempotency and safe mutations

> Stripe-style idempotency keys on every POST and PATCH. Replays return the original response; body mismatches return 409 idempotency_conflict.

# Safe mutations

Marea honors `Idempotency-Key` on every `POST` and `PATCH`. Replays of the **same key + same body** return the **same response** (status + body, including failures). This lets agents retry network errors without double-creating resources.

```http theme={null}
POST /v1/users HTTP/1.1
Host: api.mareaalcalina.com
Authorization: Bearer mk_dev_...
Content-Type: application/json
Idempotency-Key: 2f1a8c4b-2e3a-4b9d-9f1a-8c4b2e3a4b9d

{
  "email": "owner@taqueria.example",
  "displayName": "La Taquería",
  "sourceAgent": "claude-code"
}
```

## Header contract

| Property           | Value                                |
| ------------------ | ------------------------------------ |
| Header name        | `Idempotency-Key`                    |
| Length             | 1–255 characters                     |
| Character class    | ASCII printable only (`0x20`–`0x7E`) |
| Recommended format | UUID v4                              |
| Applies to         | `POST` and `PATCH`                   |
| Ignored on         | `GET`, `OPTIONS`, `DELETE`           |

Invalid headers (too short, too long, or containing non-printable bytes) return `400 invalid_idempotency_key`. The regex is `^[\x20-\x7e]{1,255}$`.

## Match key

A stored idempotency record is keyed on the triple `(keyId, method+path, Idempotency-Key)`. The body is hashed (canonical JSON, sorted keys, SHA-256 hex) and compared on every replay.

* **Cross-key replays don't work.** Different API keys with the same `Idempotency-Key` are independent requests.
* **Cross-endpoint replays don't work.** `POST /v1/storefronts` and `POST /v1/storefronts/:id/products` with the same `Idempotency-Key` are independent.

## Outcomes

| Match status | What you get                                             | When                                               |
| ------------ | -------------------------------------------------------- | -------------------------------------------------- |
| `owned`      | Handler runs; response is stored for future replays      | First time the key is seen                         |
| `replay`     | Original response replayed verbatim (same status + body) | Same key + same body; original completed           |
| `in_flight`  | `409 conflict` + `Retry-After: 1` (`recoverable: true`)  | Same key + still processing                        |
| `conflict`   | `409 idempotency_conflict` (`recoverable: false`)        | Same key + **different** body                      |
| `oversize`   | `410 idempotency_snapshot_unavailable` (rare)            | Original response exceeded the 100 KB snapshot cap |

## Missing the header

Sending `POST` / `PATCH` **without** `Idempotency-Key` is not an error. The request proceeds and Marea returns a `Marea-Recommendation: include-idempotency-key` response header to nudge you. Skipping the header is fine for one-shot calls; include it for any retry-prone integration.

## The four 409 / 410 error shapes

### `in_flight` — same key still processing

```json theme={null}
{
  "error": {
    "type": "conflict",
    "code": "idempotency_in_flight",
    "message": "Another request with this Idempotency-Key is still being processed. Retry shortly.",
    "recoverable": true,
    "retryAfterMs": 1000
  }
}
```

Wait 1s and retry with the **same key**. Two concurrent calls on the same key are serialized server-side — only one wins `owned`; the other waits or replays.

### `conflict` — same key, different body

```json theme={null}
{
  "error": {
    "type": "idempotency_conflict",
    "code": "idempotency_conflict",
    "message": "Idempotency-Key was used earlier with a different request body.",
    "recoverable": false,
    "nextActions": [
      { "label": "Generate a new Idempotency-Key for this request.", "method": null, "url": null }
    ]
  }
}
```

Generate a **fresh** key. Don't reuse it. The most common cause is an agent retrying with edits ("same call, but change the email") — that's a new request and needs a new key.

### `invalid_idempotency_key` — format error

```json theme={null}
{
  "error": {
    "type": "invalid_request",
    "code": "invalid_idempotency_key",
    "message": "Idempotency-Key must be 1-255 ASCII printable chars.",
    "param": "Idempotency-Key",
    "recoverable": false
  }
}
```

Fix the header. UUID v4 is the safe default.

### `idempotency_snapshot_unavailable` — oversize response

```json theme={null}
{
  "error": {
    "type": "invalid_request",
    "code": "idempotency_snapshot_unavailable",
    "message": "The original response was too large to store. Retry without the Idempotency-Key.",
    "recoverable": false,
    "nextActions": [
      { "label": "Reissue the request without the Idempotency-Key.", "method": null, "url": null }
    ]
  }
}
```

Response snapshots are capped at **100 KB** (Firestore doc-size headroom). On the rare oversize response, the original is marked oversize and future replays return 410 — drop the header and reissue.

## What is stored

The first successful response is persisted as `(keyId, method+path, Idempotency-Key) → { status, body }`. Failed responses are also stored — a replay of a failed call returns the same failure. The stored snapshot is held to back replays; there is no client-controlled TTL.

## Convention

Stripe-style. If you already use a Stripe retry library that drives `Idempotency-Key`, it works on Marea unchanged.

## Verification in code

* `src/api/middleware/idempotency.ts` — header parsing + replay vs. owned vs. conflict branching.
* `src/api/services/apiIdempotency.service.ts` — `acquireOrReplay`, `complete`, `fail`, snapshot cap (`SNAPSHOT_MAX_BYTES`).
