> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.morphiaone.ai/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.morphiaone.ai/_mcp/server.

# Idempotency

Submissions are billable and a lost response is indistinguishable from a lost
request. `Idempotency-Key` is what makes the ambiguity safe.

```http
POST /v1/skin/analyses
Idempotency-Key: order-48213-skin
```

The header is required on Skin Analysis, Makeup Try-On, and Hair Color Try-On
submissions: a missing one is `400 IDEMPOTENCY_KEY_REQUIRED`, and a value that is not
1 to 255 printable ASCII characters is `400 INVALID_IDEMPOTENCY_KEY`. Uploads take no
key — see [Assets](/concepts/assets).

## The four answers

| Situation                                       | Status                                        | What you get                         |
| ----------------------------------------------- | --------------------------------------------- | ------------------------------------ |
| First time this key is seen                     | `202`                                         | a new job                            |
| Exact replay: same key, same request body       | `200` + `Idempotency-Replayed: true`          | the **same** job, and its `Location` |
| Same key, a different asset or different fields | `409 IDEMPOTENCY_KEY_REUSED`                  | nothing — this is a bug in your code |
| Same submission still being admitted            | `409 IDEMPOTENCY_IN_PROGRESS` + `Retry-After` | retry after the delay                |

The third row looks unhelpful and is the useful one. A key is a claim that two
requests are the same logical operation; if the requests differ, one of those two
requests is going to be silently dropped. Answering `409` surfaces the mistake
instead of choosing for you.

The fourth is the narrow race where two copies of your retry arrive at once. It is
retryable, it carries a short `Retry-After` and no job id, and it clears.

"Same request" is semantic: `concerns` and `visualizations` are sets, so their order
does not matter, while makeup `products` is the layer order, so reordering it is a
different request. An exact replay answers the replay even after the input asset has
expired — it does not become a `410`.

## Deriving a key

Derive it from the durable business operation, not from the attempt:

```python
key = f"order-{order.id}-skin"          # stable across retries
key = f"{uuid4()}"                       # WRONG: a new key every attempt
```

A fresh key per attempt gives you no idempotency at all — that is what the feature
exists to prevent. The rule is blunt: **never generate a new key because a response
was lost.** A lost response is exactly when the old key is valuable.

If the asset or the fields change, the operation changed, so the key changes with it. Version the
suffix (`order-48213-skin-v2`) rather than reusing the key with new content.

## How long a key lasts

A key belongs to its job for 30 days, much longer than the job's own lifetime. So:

* a replay long after the job's `expires_at` still answers `200` with the original job id;
* a replay after the job itself is gone still answers `200` with the original job id
  (with its output only while the job exists), so you can recover the id from the key
  alone.

`IDEMPOTENCY_IN_PROGRESS` is short-lived: the claim behind it is a lease of about
two minutes, separate from how long a key lasts, so a retry after an admission
that died mid-flight resolves once the lease runs out.

Without that spine, the window is the job's own lifetime, and a later reuse of the
key starts a new job. Ask which shape your environment is on if a month-long
recovery window matters to your reconciliation.

## Persist the job id anyway

Idempotency recovers the job id when you have lost it. Persisting the job id when
you receive it means you rarely need to. Do both — they cost almost nothing
and they fail in different circumstances.

```python
job = submit(asset_id, idempotency_key=f"order-{order.id}-skin")
order.vision_job_id = job["id"]
order.save()                              # before you start waiting
```