Skip to navigation

Idempotency

How to retry a submission you are not sure arrived, without paying twice.

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

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.

The four answers

SituationStatusWhat you get
First time this key is seen202a new job
Exact replay: same key, same request body200 + Idempotency-Replayed: truethe same job, and its Location
Same key, a different asset or different fields409 IDEMPOTENCY_KEY_REUSEDnothing — this is a bug in your code
Same submission still being admitted409 IDEMPOTENCY_IN_PROGRESS + Retry-Afterretry 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:

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.

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