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.
| Status | Terminal? | Meaning |
|---|---|---|
QUEUED | no | Accepted; 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. |
RUNNING | no | Your 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. |
STOPPING | no | A 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. |
SUCCEEDED | yes | Every item produced a creative. |
PARTIAL | yes | Some items produced a creative, some didn't. Check listRunItems for which and why. |
FAILED | yes | No item produced a creative, and it wasn't a safety refusal or a stop. |
REFUSED | yes | No item produced a creative because a safety check refused the run's output — ours, or the provider's own content-policy block. |
STOPPED | yes | A stop request took effect before every item completed. |
EXPIRED | yes | Items 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.estimatedis 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 treatcredits.estimatedas the amount you approved.countsis always present, from the very first poll (all-zero withpendingequal tototalon a freshly queued run). While the run is non-terminal, the five buckets are tallied fresh on every read and can change between polls. OncefinalizedAtis set they're frozen. On alistRunsrow that isn't yet final,countsis 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 oncounts. There's a real window — up to roughly a minute — wherecountsalready sums tototalbutstatushasn'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. estimatedCompletionis optional. It's omitted whenever an input to the estimate is unavailable — never zero, never a placeholder. When present, size your poll interval fromdurationSecondsrather 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
errorCode | What it means | What to do |
|---|---|---|
INSUFFICIENT_CREDITS | The 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_REACHED | A 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_REFUSED | A 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_UNAVAILABLE | The 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_FAILED | The generation provider call failed for this item, and the platform judged it not retryable. | Not worth retrying unchanged — treat as the non-retryable case. |
STOPPED | Not produced because a stop took effect before it ran. | Expected on a STOPPED run — not an error to work around. |
NO_PRIMARY_IMAGE | The 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_AGAIN | A 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:
errorCode | Can you receive it? |
|---|---|
SAFETY_UNAVAILABLE | Yes — withheld output on this plane. |
PROVIDER_FAILED | Yes — also the catch-all for a non-retryable failure with no more specific code. |
TRY_AGAIN | Yes, for a failure the platform marked retryable — including an item whose credits couldn't be reserved in time. |
STOPPED | Yes, 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_CREDITS | Yes — 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_REFUSED | Yes, for images and videos, and for a provider's own content-policy block. The item's outcome is refused. |
SPEND_CEILING_REACHED | Rarely. 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_IMAGE | Yes, 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.
imageSizeis the output size of each image:1K(the default),2Kor4K. A larger size is priced higher, and the price is part of the quote you see inPOST /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 with403 IMAGE_SIZE_NOT_ENTITLED; a size that has no published price yet is refused with409 IMAGE_SIZE_UNPRICED. Both are decided before anything is written or charged, so theIdempotency-Keyis not used up. A size is never reduced to a smaller one.referenceSourceis which of each product's images seed the creative:PRIMARY(the default) uses the primary image alone;ALLuses 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.