Skip to main content

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​

OperationIdempotency-Key
POST /runs (createRun)Required
POST /creatives/{creativeId}/approve, .../rejectRequired
POST /creatives/{creativeId}/undeleteRequired
POST /assets/{assetId}/archive, .../unarchive, .../undeleteRequired
POST /campaigns, POST /campaigns/{campaignId}/undeleteRequired
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 GETNot 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​

SituationResponseWhat it means
Same key + same body202, the original runThis 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 body409 IDEMPOTENCY_KEY_REUSEDNo 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: 1Wait the stated seconds and retry the identical request. Don't mint a new key just because you saw this.
No Idempotency-Key header at all400 IDEMPOTENCY_KEY_REQUIREDAdd the header.
Header present but malformed (too short, too long, or sent more than once)400 INVALID_IDEMPOTENCY_KEYSend 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.

SituationResponseWhat it means
Same key + same creative, action and reason200, the creativeThe 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 rejectionReason409 IDEMPOTENCY_KEY_REUSEDUse a brand-new key for the different review.
Same key, a review still in flight under it409 IDEMPOTENCY_IN_PROGRESS, Retry-After: 1Wait the stated seconds and retry the identical request.
No Idempotency-Key header at all400 IDEMPOTENCY_KEY_REQUIREDAdd the header.
Header present but malformed (too short, too long, or sent more than once)400 INVALID_IDEMPOTENCY_KEYSend exactly one header, 16–255 characters.
Writes are paused503 WRITES_PAUSED, Retry-AfterNothing 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.

SituationResponseWhat it means
Same key + same resource, action and body200, the resourceNothing 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 body409 IDEMPOTENCY_KEY_REUSEDUse a brand-new key for the different request.
Same key, a request still in flight under it409 IDEMPOTENCY_IN_PROGRESS, Retry-After: 1Wait the stated seconds and retry the identical request.
Writes are paused503 WRITES_PAUSED, Retry-AfterNothing 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.