Skip to main content

Runs

A run generates one or more creatives from a product and a style. It is the only thing on this API that spends credits, and it is asynchronous: POST /runs (createRun) returns immediately with no assets. Items mint over time, in the background, at roughly twenty seconds per item. You poll GET /runs/{id} (getRun) until it reaches a terminal status, then read GET /runs/{id}/items (listRunItems) to collect what was produced.

If you haven't yet, read Try it safely first — it covers estimateRun, maxCredits, and expectedTotalCredits, all of which matter before you create a run.

Run statuses​

status on a Run object is one of nine values. Six are terminal — once you see one of those, the run will never change again.

StatusTerminal?Meaning
QUEUEDnoAccepted; not yet minting. No credits are held for the run as a whole — each item's are held as it mints. Every run starts here.
RUNNINGnoYour run is generating: minting has started and the run is not final. Becomes RUNNING as soon as the first item mints, and stays there — including the brief window after the last item finishes, while the run is being finalized.
STOPPINGnoA stop was requested and acknowledged, but items already dispatched are still running and still settling credits. This looks like an end state but isn't — keep polling.
SUCCEEDEDyesEvery item produced a creative.
PARTIALyesSome items produced a creative, some didn't. Check listRunItems for which and why.
FAILEDyesNo item produced a creative, and it wasn't a safety refusal or a stop.
REFUSEDyesNo item produced a creative because a safety check refused the run's output — ours, or the provider's own content-policy block.
STOPPEDyesA stop request took effect before every item completed.
EXPIREDyesItems remained unaccounted for with nothing still working — planned items that never started, or items that stalled past their worker's own time limit.

Stop polling once status is SUCCEEDED, PARTIAL, FAILED, REFUSED, STOPPED, or EXPIRED. finalizedAt is null until then, and an ISO 8601 timestamp once the run is final.

The Run object​

createRun, getRun, and each row of listRuns all return the same shape:

{
"runId": "run_a26e94e8f3ae95313f4d46c5",
"object": "run",
"status": "RUNNING",
"total": 2,
"credits": { "estimated": 8, "rateCardVersion": "2026-08-01" },
"counts": { "succeeded": 1, "failed": 0, "refused": 0, "stopped": 0, "pending": 1 },
"createdAt": "2026-09-28T12:00:00.000Z",
"finalizedAt": null,
"estimatedCompletion": {
"completesAt": "2026-09-28T12:00:20.000Z",
"durationSeconds": 20,
"concurrency": 2,
"perItemSeconds": 20
}
}
  • credits.estimated is the ceiling you agreed to at create time — not a running "charged so far" total. There's no field on this API that tells you exactly how many credits a run has billed so far; use your account's billing views outside the API for that, and treat credits.estimated as the amount you approved.
  • counts is always present, from the very first poll (all-zero with pending equal to total on a freshly queued run). While the run is non-terminal, the five buckets are tallied fresh on every read and can change between polls. Once finalizedAt is set they're frozen. On a listRuns row that isn't yet final, counts is omitted — a live tally isn't computed for list rows, so absence means "not known without reading the run directly," not zero.
  • Branch on status, not on counts. There's a real window — up to roughly a minute — where counts already sums to total but status hasn't advanced to terminal yet, because status is advanced by a periodic reconciler while counts are computed live on every read. Keep polling; it isn't stuck.
  • estimatedCompletion is optional. It's omitted whenever an input to the estimate is unavailable — never zero, never a placeholder. When present, size your poll interval from durationSeconds rather than a fixed constant: a 200-item run can take on the order of half an hour, and that's the real generation time, not a queue backlog.

Polling cadence​

Every GET /runs/{id} costs more while a run is in flight than once it's final — an in-flight poll tallies live from every item, while a terminal read is a single lookup. For a small run, polling every few seconds is reasonable. For a run with a durationSeconds in the thousands, derive your interval from that number instead of tight-looping — a one-second poll loop over a 200-item run is dramatically more expensive than one every thirty seconds, for the same information. If you get a 429, honor Retry-After — see Rate limits.

If you've configured a webhook endpoint, you don't need to poll at all for the terminal outcome — see Webhooks. Polling stays fully supported either way.

Items and outcomes​

GET /runs/{id}/items (listRunItems) returns one row per planned item:

{
"jobId": "job_5c1e8b3a2f7d",
"object": "run_item",
"productId": "prod_8f2a1c9b4d6e",
"itemIndex": 0,
"outcome": "succeeded",
"errorCode": null,
"assetId": "job_5c1e8b3a2f7d",
"assetUrl": "https://storage.googleapis.com/users-assets-a/.../job_5c1e8b3a2f7d.png"
}

outcome mirrors the run's counts buckets: succeeded, failed, refused, stopped, or pending. errorCode is set whenever outcome isn't succeeded. assetUrl is set only on succeeded — it's a permanent public link; keep assetId alongside it, since how assets are served may change in a future version (this document will say so before it does).

You can call listRunItems while the run is still RUNNING to collect items as they finish; some rows will still read outcome: "pending" until the run settles.

Item error codes​

errorCodeWhat it meansWhat to do
INSUFFICIENT_CREDITSThe account's balance couldn't cover this item when it was about to be charged. Not produced, not charged.Top up credits before creating another run.
SPEND_CEILING_REACHEDA spend ceiling was hit before this item could be charged. The row's ceiling field says which — run for this run's own maxCredits.Raise maxCredits on a new run; re-running the same shape unchanged hits the same wall.
SAFETY_REFUSEDA safety check refused this item — ours, on the generated output, or the provider's own content-policy block.Don't blindly retry the identical item. If you believe it's a false positive, that's a support conversation, not something to loop on.
SAFETY_UNAVAILABLEThe safety gate couldn't be reached or reach a verdict, so the item was withheld rather than published unreviewed.This is "we couldn't look," not "we looked and refused." A new run may succeed once the gate is reachable again.
PROVIDER_FAILEDThe generation provider call failed for this item, and the platform judged it not retryable.Not worth retrying unchanged — treat as the non-retryable case.
STOPPEDNot produced because a stop took effect before it ran.Expected on a STOPPED run — not an error to work around.
NO_PRIMARY_IMAGEThe product had no usable primary image. Normally caught for every product up front at create time — seeing it at item level means the image was removed between create and mint.Fix the product's image and create a new run.
TRY_AGAINA transient failure the platform judged retryable.Safe to retry — create a new run scoped to the affected product(s).

There is no per-item "was this charged" field: whether a failed item was charged before it failed is settled on the credit ledger, not recorded on the item, so it's omitted rather than guessed.

⚠ Which of these you can actually receive today​

Read this before you branch on the table above. Treating every code in it as equally likely invites you to write a handler for an outcome that can't currently occur, and to trust a PROVIDER_FAILED that might really be something else wearing its name.

Grounded against the emitting code:

errorCodeCan you receive it?
SAFETY_UNAVAILABLEYes — withheld output on this plane.
PROVIDER_FAILEDYes — also the catch-all for a non-retryable failure with no more specific code.
TRY_AGAINYes, for a failure the platform marked retryable — including an item whose credits couldn't be reserved in time.
STOPPEDYes, for an item cancelled by a stop. Request one with POST /runs/{id}/stop (scope runs:write) — it answers 202 and the run reads STOPPING until it settles.
INSUFFICIENT_CREDITSYes — the account's balance couldn't cover this item when it was about to be charged. The item has a row; it was not produced and not charged.
SAFETY_REFUSEDYes, for images and videos, and for a provider's own content-policy block. The item's outcome is refused.
SPEND_CEILING_REACHEDRarely. Every item is checked against the run's maxCredits before it's charged, but create already refuses a quote above maxCredits (409 PRICE_EXCEEDS_MAX) and each item is priced from that same quote, so a run's own ceiling can't trip mid-run. With ceiling: "account" it fires only when the account's daily API credit budget was used up between create and the item being charged (another run got there first) — create refuses a run that doesn't fit up front (409 ACCOUNT_SPEND_CAP_EXCEEDED). That run gets one such row, naming the budget, what was used and the reset instant; its remaining items are not produced. The row always carries ceiling.
NO_PRIMARY_IMAGEYes, at item level, for the rarer case of a product's image being removed between create and mint. The common case is still refused up front at create time (400).

SPEND_CEILING_REACHED is the vocabulary for spending limits, and removing it would break clients that already switch on it — keep the handler; expect it only in the race described above.

Every refused item has a row, with one exception. An item refused for credit, for a spend ceiling, or for a missing image appears in listRunItems with outcome: "failed" and its errorCode, and is counted in counts.failed — not in counts.refused, which is reserved for safety refusals. So a run whose account runs out of credit partway through finalizes PARTIAL (some items produced, the rest INSUFFICIENT_CREDITS), and a run whose account was empty from the first item finalizes FAILED with an INSUFFICIENT_CREDITS row per item. The run's status changes on the first one-minute reconcile after every item is terminal; its counts and item rows are live and update as each item is recorded.

The one exception is an item whose refusal cannot be recorded at all, because writing its row keeps failing. That item has no row: the operator is paged, and the run falls to the EXPIRED write-off in the status table above, with the item counted in pending.

A run stays open while any item is still working, however long that takes — a large video run can legitimately run for an hour. It's written off EXPIRED only when items remain that never started, or that stalled past their worker's own time limit. pending on a terminal run occurs only on an EXPIRED run.

⚠ PROVIDER_FAILED is the non-retryable branch. If the platform judged a failure retryable you get TRY_AGAIN instead — treat TRY_AGAIN as the retry signal and PROVIDER_FAILED as "something went wrong that retrying is unlikely to fix."

Image size and reference images​

Two optional fields on POST /runs (and POST /runs/estimate) shape an image run. Sending either with modality: "video" is a 400.

  • imageSize is the output size of each image: 1K (the default), 2K or 4K. A larger size is priced higher, and the price is part of the quote you see in POST /runs/estimate, pinned onto the run at create time: every item is held at that price and charged at that price. A size your plan does not include is refused with 403 IMAGE_SIZE_NOT_ENTITLED; a size that has no published price yet is refused with 409 IMAGE_SIZE_UNPRICED. Both are decided before anything is written or charged, so the Idempotency-Key is not used up. A size is never reduced to a smaller one.
  • referenceSource is which of each product's images seed the creative: PRIMARY (the default) uses the primary image alone; ALL uses the primary image first, then the gallery in its stored order, at most 14 images. The references are read when each item starts and recorded with it, so a retried item uses the same ones.

Sending the default ("imageSize": "1K", "referenceSource": "PRIMARY") is the same request as sending neither, so a retry that spells it out still replays under the same Idempotency-Key. A different size, or a different source, under a used key answers 409 IDEMPOTENCY_KEY_REUSED.

Stopping a run​

POST /runs/{id}/stop (stopRun, scope runs:write) answers 202 immediately; the run reads STOPPING until it settles. Every item that hasn't started is cancelled. Items already in flight are allowed to finish, and are charged only if they produce a creative — the API can't recall work a provider has already begun. A stop is naturally idempotent: stopping an already-final run answers 409 RUN_ALREADY_FINAL.

Completion estimate​

estimatedCompletion.completesAt is anchored to the run's own createdAt, so a replayed create (see Idempotency) reports the same completion time as the original — it isn't recomputed per request. Treat the whole block as the right order of magnitude for sizing a poll loop, not as an SLA: provider latency varies, a run can be stopped, and concurrency can be retuned mid-flight.