Idempotency
Every POST /runs (createRun) requires an Idempotency-Key header. This isn't a courtesy
— without it, a retried request (yours, or a network layer's, after a timeout or a dropped
connection) can create a second run and charge you twice. POST /runs/{id}/stop (stopRun) needs
no key — it's naturally idempotent, since stopping an already-final run just answers
409 RUN_ALREADY_FINAL. No GET reads or writes an idempotency key.
Every other write takes the same header. It is required on the POST actions and optional
on PATCH and DELETE — see Which operations take a key. They use the
same header, the same key format and the same conflict codes; what a replay returns differs, and is
covered in Creative reviews and Edits, deletes and restores
below.
Which operations take a key
| Operation | Idempotency-Key |
|---|---|
POST /runs (createRun) | Required |
POST /creatives/{creativeId}/approve, .../reject | Required |
POST /creatives/{creativeId}/undelete | Required |
POST /assets/{assetId}/archive, .../unarchive, .../undelete | Required |
POST /campaigns, POST /campaigns/{campaignId}/undelete | Required |
PATCH /creatives/{id}, PATCH /assets/{id}, PATCH /campaigns/{id} | Optional |
DELETE /creatives/{id}, DELETE /assets/{id}, DELETE /campaigns/{id} | Optional |
POST /runs/estimate, POST /runs/{id}/stop, and every GET | Not used |
A POST action without the header is 400 IDEMPOTENCY_KEY_REQUIRED. On a PATCH or DELETE, leaving the
header out is fine: the request simply runs. A malformed key — too short, too long, or sent twice — is
400 INVALID_IDEMPOTENCY_KEY on every operation, optional or not.
Key format
Any string 16–255 characters after trimming. A UUID is a good default, but it doesn't have to be one — a hash of the request body, or a value derived from an upstream system's own idempotency key, both work.
Pick a key you can reproduce for a given logical request — never a fresh random value generated on every attempt, or you defeat the entire mechanism. A common pattern: generate the key once, store it alongside the request you're about to send, and reuse it for every retry of that same request.
The full contract for runs
| Situation | Response | What it means |
|---|---|---|
| Same key + same body | 202, the original run | This is what makes a retry safe. Resend the identical request and you get the original run back — no new charge, no new run. Holds even if your first call failed with a 5xx partway through. A retry that arrives too late to resume the run safely (3 or more minutes after the run was created) still gets the same run back as it stands — possibly already written off — and the key stays bound to it; use a fresh key to try again. |
| Same key + a different body | 409 IDEMPOTENCY_KEY_REUSED | No run id comes back — there's nothing to poll. The platform won't guess which of two different requests you meant. Use a brand-new key for the different request. |
| Same key, a create still in flight under it (a genuine race) | 409 IDEMPOTENCY_IN_PROGRESS, Retry-After: 1 | Wait the stated seconds and retry the identical request. Don't mint a new key just because you saw this. |
No Idempotency-Key header at all | 400 IDEMPOTENCY_KEY_REQUIRED | Add the header. |
| Header present but malformed (too short, too long, or sent more than once) | 400 INVALID_IDEMPOTENCY_KEY | Send exactly one header, 16–255 characters. |
A used key stays honored for at least 24 hours — retrying the identical request well after your first call still returns the original run.
What counts as "the same body"
Every field that affects what gets generated or what gets charged: brandId, presetId (or your
own preset/prompt fields), productIds (order doesn't matter), modality, creativesPerProduct,
selections, aspectRatio, and similar shape-defining fields.
metadata, expectedTotalCredits, and maxCredits do not count — you can vary these across
retries under the same key without triggering IDEMPOTENCY_KEY_REUSED. This matters in practice:
if a create is refused with PRICE_EXCEEDS_MAX or PRICE_CHANGED (both happen before your key
is claimed — see Try it safely), you can raise maxCredits or update
expectedTotalCredits and resend with the same key. That refusal never consumed it.
A replay is priced again, not just returned
Every create — including a resend of an already-used key — is validated and priced against the
live rate card before the key is looked up. So a replay can be refused exactly as a first call can:
if you resend a used key with a maxCredits below the current live quote, you get
409 PRICE_EXCEEDS_MAX, not your run. Resend with a ceiling at or above the price (or without
maxCredits/expectedTotalCredits at all) and you get the original run back.
Retry recipe
async function createRunWithRetry(body, idempotencyKey) {
for (;;) {
const res = await fetch(`${BASE_URL}/runs`, {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(body),
});
if (res.status === 202) return res.json();
const problem = await res.json();
if (problem.code === 'IDEMPOTENCY_IN_PROGRESS') {
const retryAfter = Number(res.headers.get('Retry-After') ?? '1');
await new Promise((r) => setTimeout(r, retryAfter * 1000));
continue; // identical request, same key
}
// IDEMPOTENCY_KEY_REUSED, PRICE_EXCEEDS_MAX, PRICE_CHANGED, validation errors, etc.
throw new Error(`${problem.code}: ${problem.detail}`);
}
}
Only IDEMPOTENCY_IN_PROGRESS is safe to retry automatically and unconditionally. Every other
non-2xx response needs a decision — adjust the body and mint a new key, raise maxCredits, or
surface the error — not a blind retry loop.
Creative reviews
A review's key is scoped to one logical review: one creative, one action (approve or reject), and
for a reject, one rejectionReason. Approve and reject share one key space, so a key used to approve
a creative can't then be used to reject it.
| Situation | Response | What it means |
|---|---|---|
| Same key + same creative, action and reason | 200, the creative | The review isn't recorded a second time. The body is read fresh: the creative as it stands now, in the same shape a first call returns. |
Same key + a different creative, action or rejectionReason | 409 IDEMPOTENCY_KEY_REUSED | Use a brand-new key for the different review. |
| Same key, a review still in flight under it | 409 IDEMPOTENCY_IN_PROGRESS, Retry-After: 1 | Wait the stated seconds and retry the identical request. |
No Idempotency-Key header at all | 400 IDEMPOTENCY_KEY_REQUIRED | Add the header. |
| Header present but malformed (too short, too long, or sent more than once) | 400 INVALID_IDEMPOTENCY_KEY | Send exactly one header, 16–255 characters. |
| Writes are paused | 503 WRITES_PAUSED, Retry-After | Nothing changed and the key wasn't used. Wait Retry-After and retry the identical request with the same key. |
A key is bound only once a review goes through. A request refused along the way — 404,
409 CREATIVE_NOT_PENDING, 422, 503 WRITES_PAUSED — leaves the key unused. Separately from the
key, a review is safe to repeat by nature: approving an already-approved creative (or rejecting an
already-rejected one) is a 200 that changes nothing, even under a new key.
For reviews, IDEMPOTENCY_IN_PROGRESS, WRITES_PAUSED and 429 are all safe to retry
automatically — wait Retry-After, resend the identical request, same key. The full flow, with a
retry loop in the SDK, is in Reviewing creatives.
Edits, deletes and restores
The rules are the same as for reviews. A key is scoped to one logical request: one resource, one action
and, for a PATCH, one set of changes.
| Situation | Response | What it means |
|---|---|---|
| Same key + same resource, action and body | 200, the resource | Nothing is recorded a second time. The body is read fresh: the resource as it stands now. A campaign create returns 201, with the same Location. |
| Same key + a different resource, action or body | 409 IDEMPOTENCY_KEY_REUSED | Use a brand-new key for the different request. |
| Same key, a request still in flight under it | 409 IDEMPOTENCY_IN_PROGRESS, Retry-After: 1 | Wait the stated seconds and retry the identical request. |
| Writes are paused | 503 WRITES_PAUSED, Retry-After | Nothing changed and the key wasn't used. Wait Retry-After and retry the identical request with the same key. |
A request refused along the way — a 404, a busy 409 (CREATIVE_NOT_DELETABLE,
CAMPAIGN_NOT_DELETABLE), a 412, a 422, a 503 — leaves the key unused, so you can fix the problem
and resend under the same key.
Why send a key on a DELETE. Without one, a DELETE you retry after losing the response finds the
resource already deleted and answers 404. With the same key, the retry returns the original 200.
A PATCH without a key is safe to repeat: setting the same values again changes nothing.
The same holds with If-Match: the key decides whether a retry is a replay, If-Match decides whether
the resource is still the one you read. See Managing creatives.
IDEMPOTENCY_IN_PROGRESS, WRITES_PAUSED and 429 are safe to retry automatically: wait Retry-After,
resend the identical request with the same key.