---
title: Jobs
url: https://base_url.placeholder/docs/jobs
group: Concepts
---

# Jobs

A job is one op — or one preset — over one or more assets. It is the thing that owns the retry, the settlement, the cancellation and the webhook: every AI op is a job, because a provider call needs an owner, and a deterministic op is a job whenever you want the result kept as an asset. A job has items, one per input (times one per variant), and each item succeeds or fails on its own.

## Submitting

`POST /api/v1/jobs` with an `op` from the [catalogue](https://base_url.placeholder/docs/ops), a `presetId`, or the `steps` a preset would store, inline — plus the inputs. The service derives the job type from what you sent, so you never send one.

#### JavaScript

```js
import { ImageStep } from "imagestep";
const client = new ImageStep({ apiKey: process.env.IMAGESTEP_API_KEY });

const job = await client.ops.run("resize", { assetIds: ["ast_1a2b", "ast_3c4d"], parameters: { width: 1200 } });
console.log(job.id, job.status); // save the id
```

#### Python

```python
from imagestep import ImageStep
client = ImageStep()  # reads IMAGESTEP_API_KEY

job = client.ops.run("resize", asset_ids=["ast_1a2b", "ast_3c4d"], parameters={"width": 1200})
print(job["id"], job["status"])  # save the id
```

#### CLI

```sh
imagestep jobs submit --op resize --asset-ids ast_1a2b,ast_3c4d --params '{"width":1200}' -o json
```

#### curl

```sh
curl -s https://api.imagestep.dev/api/v1/jobs -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"op":"resize","assetIds":["ast_1a2b","ast_3c4d"],"parameters":{"width":1200}}'
```

#### MCP

```text
# tool call — "wait": false hands the job back at once; by default a write tool waits for it
transform  {"op": "resize", "asset_ids": ["ast_1a2b", "ast_3c4d"], "parameters": {"width": 1200}, "wait": false}
```

The answer is the job document — `201`, and the handle for everything after. Save the `id`. A job that could start at once is already `PROCESSING`; one that has to wait behind your other running jobs of the same type answers `PENDING` and starts on its own (lifecycle).

```json
{
  "id": "job_18b6672add8a48758c4924a61c042414",
  "type": "process",
  "op": "resize",
  "status": "PROCESSING",
  "totalItems": 2,
  "submittedItems": 0,
  "settledItems": 0,
  "completedItems": 0,
  "failedItems": 0,
  "cancelledItems": 0,
  "pipelines": [
    { "name": "op:resize", "steps": [{ "operation": "resize", "params": { "width": 1200, "fit": "inside", "withoutEnlargement": true } }] }
  ],
  "actualCredits": 0,
  "presetName": "op:resize",
  "attemptNumber": 1,
  "rootJobId": "job_18b6672add8a48758c4924a61c042414",
  "createdAt": "2026-09-17T15:27:01.365787172Z",
  "submittedAt": "2026-09-17T15:27:01.371204518Z",
  "expiresAt": "2026-10-17T15:27:01.366Z",
  "items": [
    { "index": 0, "status": "PENDING", "sourceAssetId": "ast_1a2b" },
    { "index": 1, "status": "PENDING", "sourceAssetId": "ast_3c4d" }
  ]
}
```

What the body looks like for one particular op is on the catalogue: every entry of [the ops page](https://base_url.placeholder/docs/ops#catalogue) carries an `example` the service has validated, in every surface. The shapes that are about the job rather than the op:

| to | the body |
| --- | --- |
| run a preset, pinned to version 3 | {"presetId": "product-cutout@3", "assetIds": ["ast_1a2b"]} |
| run a chain once, without saving a preset | {"steps": [{"op": "remove_bg"}, {"op": "resize", "parameters": {"width": 1200}}], "assetIds": ["ast_1a2b"]} |
| run an op on another model | {"op": "upscale", "assetIds": ["ast_1a2b"], "model": "replicate/nightmareai/real-esrgan"} |
| put the outputs in a collection | {"op": "remove_bg", "assetIds": ["ast_1a2b"], "collection": "spring-sale"} |
| render a template — rows in, no assets | {"op": "render_template", "templateId": "builtin-template-og-image", "items": [{"title": "Hello"}]} |

Every field the body may carry is in request fields, at the end of this page.

## Price it first: the dry run

The same body to `POST /api/v1/jobs?dryRun=true` runs everything a real submit runs — preset lookup, model validation, asset resolution, item count, per-item price — and stops before the first write. It cannot drift from what you would be charged, and it doubles as validation: a bad parameter or a missing asset is refused here, without spending a job to find out.

#### JavaScript

```js
const price = await client.ops.estimate("remove_bg", { assetIds: ["ast_1a2b", "ast_3c4d"] });
```

#### Python

```python
price = client.ops.estimate("remove_bg", asset_ids=["ast_1a2b", "ast_3c4d"])
```

#### CLI

```sh
imagestep jobs estimate --op remove_bg --asset-ids ast_1a2b,ast_3c4d -o json
```

#### curl

```sh
curl -s "https://api.imagestep.dev/api/v1/jobs?dryRun=true" -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" \
  -d '{"op":"remove_bg","assetIds":["ast_1a2b","ast_3c4d"]}'
```

#### MCP

```text
# tool call
transform  {"op": "remove_bg", "asset_ids": ["ast_1a2b", "ast_3c4d"], "dry_run": true}
```

**Images you have not uploaded yet** are priced by how many: `imageCount` on the dry run stands for that many more `assetIds`, because the price depends on the op, model and parameters, never on the pixels — so nothing is uploaded to ask. The CLI's `jobs estimate --image-count`, the SDKs' `imageCount` / `image_count`, MCP's `dry_run` over `urls` or `file_paths` and n8n's Dry Run over a Binary File all send it. A submit needs the images themselves, and answers the count with 400 invalid_param.

An AI op is priced in credits:

```json
{
  "type": "ai-edit",
  "model": "fal-ai/bria/background/remove",
  "presetName": "op:remove_bg",
  "totalItems": 2,
  "costPerItem": 227,
  "estimatedCredits": 454,
  "creditBalance": 0,
  "sufficientCredit": false,
  "assetCountLeft": 198,
  "maxInputEdge": 2048
}
```

A deterministic op costs none, and is counted against the plan's monthly allowance instead:

```json
{
  "type": "process",
  "totalItems": 2,
  "costPerItem": 0,
  "estimatedCredits": 0,
  "creditBalance": 0,
  "sufficientCredit": true,
  "assetCountLeft": 198,
  "processCountLeft": 198,
  "overageRuns": 0,
  "overageCredits": 0
}
```

The account's limits are reported, not enforced: a dry run answers `200` either way — as the first one above does, from an account with no credits — and says what the real submit would answer.

| the dry run says | the submit answers | what to do |
| --- | --- | --- |
| sufficientCredit: false | 402 insufficient_credit | top up, or send fewer items; estimatedCredits is what it needs |
| assetCountLeft below totalItems | 422 asset_count_exceeded | every output is a new asset: delete some, or upgrade |
| overageRuns above 0 | the runs past the Free plan's allowance are paid from your balance — part of estimatedCredits, so sufficientCredit covers them | nothing, if you mean to pay them; otherwise wait for the next period, or upgrade |

Credits are the unit of AI work; the catalogue's `creditsPerUsd` turns them into dollars. Deterministic ops are unlimited on paid plans; on Free they count against the monthly allowance, and the runs past it are paid in credits too. Every field of the answer is in dry-run fields.

## Waiting for it

A job is asynchronous; getting its result does not have to be a poll loop. The service waits for you: `wait` on the submit holds the response until the job is done — `200` with the finished job, or `202` with the same handle when the window closes first. Finished means `COMPLETED`, `FAILED` or `CANCELLED`, so read `status`. One call, one result, and still a job: charged, retried and replayable exactly as without it.

#### JavaScript

```js
// resolves with the finished job; throws JobFailedError if it ended any other way than COMPLETED
const job = await client.ops.removeBg("ast_1a2b", { wait: true });
```

#### Python

```python
job = client.ops.remove_bg("ast_1a2b", wait=True)  # raises JobFailedError unless it COMPLETED
```

#### CLI

```sh
# exit 0 COMPLETED · 1 FAILED or CANCELLED · 2 still running at --timeout (nothing is cancelled)
imagestep jobs submit --op remove_bg --asset-ids ast_1a2b --wait --timeout 120 -o json
```

#### curl

```sh
# 200 → the finished job · 202 → still running after 30 s, the same handle
curl -s https://api.imagestep.dev/api/v1/jobs -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"op":"remove_bg","assetIds":["ast_1a2b"],"wait":30}'
```

#### MCP

```text
# tool call — a write tool waits by default; when wait_seconds runs out the answer is the handle with timedOut: true
transform  {"op": "remove_bg", "asset_ids": ["ast_1a2b"], "wait_seconds": 60}
```

To wait on a job you already have — after a 202, after a batch, from another process:

#### JavaScript

```js
const job = await client.jobs.wait("<job-id>", { timeoutMs: 120_000, onProgress: (j) => console.log(j.status) });
```

#### Python

```python
job = client.jobs.wait("<job-id>", timeout=120, on_progress=lambda j: print(j["status"]))
```

#### CLI

```sh
imagestep jobs wait <job-id> --timeout 120 -o json
```

#### curl

```sh
# always 200: read "status". Repeat while it is not COMPLETED, FAILED or CANCELLED
curl -s "https://api.imagestep.dev/api/v1/jobs/<job-id>?wait=60" -H "Authorization: ApiKey $IMAGESTEP_API_KEY"
```

#### MCP

```text
# tool call
job_status  {"job_id": "<job-id>"}
```

- The service holds a request open for 60 s at most, and clamps a longer `wait` rather than refusing it. The SDKs and the CLI ask again until their own timeout, so `wait: true` can outlast a minute; bare REST repeats the read.
- A submit waits only for a job of one item — a batch is what a [webhook](https://base_url.placeholder/docs/webhooks) is for — and a batch simply answers `201` at once. The read waits on any job. How long one item usually takes is each op's `typicalSeconds` in [the catalogue](https://base_url.placeholder/docs/ops#entry).
- One account holds at most 8 waits open. A waited read past that is `429 rate_limited` (`details.reason` `account_concurrency`, with `Retry-After`), and a server holding all the waits it will is `503 provider_unavailable` — both retryable. A waited submit is never refused for either: the job exists, so it answers at once with the handle.
- A wait that runs out is not a failure: the job keeps running and is charged for what completes. Wait again with the same id; never submit the same work twice because a client timed out — see submitting exactly once.
- In [n8n](https://base_url.placeholder/docs/n8n) it is _Wait for Result_ on the node, or the ImageStep Trigger, which fires on the webhook and waits for nothing.

## Outputs

Each completed item has a `resultAssetId` — a new [asset](https://base_url.placeholder/docs/assets) with `lineage` pointing back at this job, its source asset and the preset version it ran; the asset a job read is never changed. Publish the ones you want a URL for. REST has no “outputs” endpoint: a run's products are [`GET /api/v1/assets?jobId=`](https://base_url.placeholder/docs/assets#filters), one page at a time, and the SDKs, the CLI and MCP collect them for you.

#### JavaScript

```js
const outputs = await client.jobs.outputs(job); // the result assets, in item order
const published = await client.assets.publish(outputs.map((a) => a.id)); // each now has a publicUrl
```

#### Python

```python
outputs = client.jobs.outputs(job)  # the result assets, in item order
published = client.assets.publish([a["id"] for a in outputs])  # each now has a publicUrl
```

#### CLI

```sh
imagestep jobs outputs <job-id> -o json            # [{item, assetId, name, status, dimension, publicUrl}, …]
imagestep jobs outputs <job-id> --download ./out   # …or the bytes
```

#### curl

```sh
# the run's products are one listing, filtered by job — past 100, follow meta.nextCursor
curl -s "https://api.imagestep.dev/api/v1/assets?jobId=<job-id>" -H "Authorization: ApiKey $IMAGESTEP_API_KEY" | jq -r '.data[].id'
curl -s https://api.imagestep.dev/api/v1/assets/update -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" \
  -d '{"ids":["<result-asset-id>"],"published":true}'
```

#### MCP

```text
# tool call
job_status  {"job_id": "<job-id>", "publish": true}
```

One completed item, as the job document carries it:

```json
{
  "index": 0,
  "status": "COMPLETED",
  "sourceAssetId": "ast_1a2b",
  "resultAssetId": "ast_9x8y",
  "startedAt": "2026-09-17T15:27:01.458315964Z",
  "finishedAt": "2026-09-17T15:27:01.467171214Z",
  "durationMs": 8,
  "credits": 0
}
```

**A job carries its first 100 items and no more.** A batch takes up to 10,000 inputs — more items still, with variants — so the document would otherwise be megabytes, re-sent on every `wait`. When there are more, the job says `itemsTruncated: true` and the rest are a page at a time from `GET /api/v1/jobs/{id}/items` (`?status=FAILED` for just the failures). Every item carries the `index` the rest of the API names it by, so a filtered page can still be acted on.

Two ops are different: `analyze` creates no asset — each item's answer is its `output`, and with the default schema its tags and objects also join the input's `tags`; `render_template` takes no input and makes one PNG asset per row.

## When items fail, and resume

A job that was accepted and then failed carries no error envelope — the request was fine. The verdict is on the items: each failed one has an `errorCode` from the closed set and that code's own `retryable`, and the job repeats the verdict at the top — `retryable: true` with that item's code when any failed item is retryable, otherwise the first failed item's code with `false`. Branch on that, never on the message.

```json
{
  "id": "job_0db4028c1f6245d6aac4ea5ff3f6c0ae",
  "type": "ai-edit",
  "op": "remove_bg",
  "model": "fal-ai/bria/background/remove",
  "status": "FAILED",
  "errorMessage": "2 of 3 items failed",
  "errorCode": "provider_unavailable",
  "retryable": true,
  "totalItems": 3,
  "completedItems": 1,
  "failedItems": 2,
  "actualCredits": 227,
  "attemptNumber": 1,
  "rootJobId": "job_0db4028c1f6245d6aac4ea5ff3f6c0ae",
  "items": [
    { "status": "COMPLETED", "sourceAssetId": "ast_1a2b", "resultAssetId": "ast_9x8y", "credits": 227 },
    {
      "status": "FAILED",
      "sourceAssetId": "ast_3c4d",
      "error": "The provider timed out after the in-job retries",
      "errorCode": "provider_unavailable",
      "retryable": true,
      "credits": 0
    },
    {
      "status": "FAILED",
      "sourceAssetId": "ast_5e6f",
      "error": "The provider refused this image",
      "errorCode": "provider_rejected",
      "retryable": false,
      "credits": 0
    }
  ]
}
```

| errorCode | retryable | on an item, it means |
| --- | --- | --- |
| provider_unavailable | yes | the provider failed or timed out after the in-job retries; a resume may succeed |
| provider_rejected | no | the provider refused this input — a 4xx, a moderation refusal, an answer with no image; the same input fails again |
| asset_not_found | no | the source asset was deleted after the job was accepted |
| invalid_state | no | the source asset has no stored image (it never finished processing), or the preset or template the job runs was deleted while the job waited in the queue |
| invalid_param | no | a step the image cannot satisfy — a crop outside it — or a render_template row or template that cannot render |
| unsupported_format | no | the processing worker cannot decode the image: corrupt, or bigger than its decode limit |
| asset_count_exceeded | no | your plan's asset ceiling filled up while the job ran — other uploads or jobs used the room it was checked against — so this item's image was not kept |
| internal_error | yes | our side: a publish that failed, a stalled item the reaper ended, a render worker that gave up |

A deterministic or render item a worker failed without naming a cause carries `error` alone — no `errorCode`, no `retryable`. Treat it as not retryable. When no failed item is retryable and one of them is unclassified, the job carries no verdict either.

`POST /api/v1/jobs/{id}/resume` retries every incomplete item — `FAILED` or `CANCELLED` — of a `FAILED` or `CANCELLED` job as a **new** job: a new id, the same configuration and the same preset version, linked to the original by `parentJobId`, `rootJobId` and `attemptNumber`. The original is left as it was. Like cancel, it has no MCP tool.

#### JavaScript

```js
const retry = await client.jobs.resume("<job-id>"); // a NEW job: wait on retry.id, not on the old one
```

#### Python

```python
retry = client.jobs.resume("<job-id>")  # a NEW job: wait on retry["id"], not on the old one
```

#### CLI

```sh
imagestep jobs resume <job-id>
```

#### curl

```sh
curl -s -X POST https://api.imagestep.dev/api/v1/jobs/<job-id>/resume -H "Authorization: ApiKey $IMAGESTEP_API_KEY"
```

The new job, for the two items of the one above that did not complete:

```json
{
  "id": "job_5f3a9c1e7b2d4e109a6f2c8d1b0e4a77",
  "status": "PROCESSING",
  "attemptNumber": 2,
  "parentJobId": "job_0db4028c1f6245d6aac4ea5ff3f6c0ae",
  "rootJobId": "job_0db4028c1f6245d6aac4ea5ff3f6c0ae",
  "totalItems": 2
}
```

Items that already completed are not run — or charged — again, and a chain job's item restarts at the segment it failed on, from the image the segment before it left. Otherwise a resume is a submit: priced like the original for each item it runs, with every check a submit makes. There is no dry run of a resume; to price one first, dry-run the original body over the failed items' `sourceAssetId`s — an upper bound for a chain, whose items restart part-way.

| when | the resume answers |
| --- | --- |
| the job is FAILED or CANCELLED and has an incomplete item | 201 — the new job |
| the job is still running, or COMPLETED, or has nothing incomplete | 400 invalid_state |
| a later attempt of it exists — only the latest can be resumed | 422 job_not_resumable, details.reason NOT_LATEST_ATTEMPT |
| one job gets 3 resumes, counted across its attempts, and they are used up | 422 job_not_resumable, details.reason RESUME_LIMIT_EXCEEDED |
| the source asset of every incomplete item has been deleted since (items whose source is gone are otherwise left out) | 404 asset_not_found, details.missing |
| the new job does not fit the account | 402 insufficient_credit · 422 asset_count_exceeded, as a submit would |

## Submitting exactly once

Send an `Idempotency-Key` with any submit you might send twice — after a timeout, a dropped connection, a crash between sending it and saving the id. The same key with the same body answers with the job the first call created instead of creating a second one; the same key with a different body is `409 idempotency_key_reuse`; the same key while the first is still in flight is `409 request_in_progress`, which is retryable. A submit that asked to `wait` replays with the job as it is now — and waits again while it runs — not as it was. The SDKs and the CLI send a key on every submit and reuse it across their own retries, so there is nothing to write; by hand, and from an agent, it looks like this:

#### curl

```sh
KEY=$(uuidgen)   # one key per logical submit; send the SAME key on every retry of it
curl -s https://api.imagestep.dev/api/v1/jobs -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" \
  -d '{"op":"remove_bg","assetIds":["ast_1a2b"]}'
```

#### MCP

```text
# tool call — only the agent knows that two calls are the same attempt
transform  {"op": "remove_bg", "asset_ids": ["ast_1a2b"], "idempotency_key": "cutout-ast_1a2b-1"}
```

A dry run ignores the key, so its submit can reuse it. Keys last 24 hours, and “the same body” means the same bytes — the full rules are on [errors & retries](https://base_url.placeholder/docs/errors#idempotency).

## What you are charged

- AI work is charged per completed item, at the price the dry run quoted, and debited once, when the job settles: each item's `credits` sum to the job's `actualCredits`, so the parts add up to the invoice. A failed or cancelled item costs nothing — except a chain job's item, which pays for the AI segments it got through.
- The dry run's `costPerItem` is the charge, not an estimate of it — an `analyze` is priced by its `maxOutputTokens` up front, never by what the answer turned out to cost.
- Deterministic and render items are free on a paid plan. On the Free plan each one handed to a worker counts once against the monthly allowance of 200, whether it then succeeds or fails, and the ones past it are paid from your balance at $0.002 each — held when the job is admitted, charged when it settles for the ones it dispatched. The dry run names them: `overageRuns` and `overageCredits`, already in `estimatedCredits`.
- [Usage](https://base_url.placeholder/docs/api/usage) adds up what was spent by op, key or day (`GET /api/v1/usage`, `imagestep usage`, `client.usage.get()`). The credit ledger — every movement, top-ups included — is the console's [Usage & billing](https://base_url.placeholder/usage) page: it is the account's money, and no API key can read it.
- A job record expires with the assets it produced (`expiresAt`, stamped at submit from your plan's retention window, or `retentionDays` from now when the submit asks for less) and cannot be deleted before then. The console's [/jobs](https://base_url.placeholder/jobs) page is the same list, for a person checking what an agent ran.

## Several sizes from one call

A deterministic job takes `variants`: up to 20 entries of `{name, parameters}`, each merged over the request's shared `parameters`. Two assets and two variants is four items, asset-major, each a new asset named after its variant — so the dry run, the quota, per-item failure and resume all behave as for any job. The synchronous endpoints have no form of this, and neither do AI ops, `render_template` or a preset — there it is `400 invalid_param`.

#### JavaScript

```js
const job = await client.ops.resize(["ast_1a2b", "ast_3c4d"], { fit: "cover", gravity: "attention" }, {
  variants: [{ name: "ig", parameters: { width: 1080, height: 1350 } }, { name: "og", parameters: { width: 1200, height: 630 } }],
  wait: true
});
```

#### Python

```python
job = client.ops.resize(["ast_1a2b", "ast_3c4d"], {"fit": "cover", "gravity": "attention"}, wait=True, variants=[
    {"name": "ig", "parameters": {"width": 1080, "height": 1350}},
    {"name": "og", "parameters": {"width": 1200, "height": 630}},
])
```

#### CLI

```sh
imagestep jobs submit --op resize --asset-ids ast_1a2b,ast_3c4d --params '{"fit":"cover","gravity":"attention"}' \
  --variants '[{"name":"ig","parameters":{"width":1080,"height":1350}},{"name":"og","parameters":{"width":1200,"height":630}}]' --wait
```

#### curl

```sh
curl -s https://api.imagestep.dev/api/v1/jobs -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" \
  -d '{"op":"resize","assetIds":["ast_1a2b","ast_3c4d"],"parameters":{"fit":"cover","gravity":"attention"},
       "variants":[{"name":"ig","parameters":{"width":1080,"height":1350}},{"name":"og","parameters":{"width":1200,"height":630}}]}'
```

#### MCP

```text
# tool call
transform  {"op": "resize", "asset_ids": ["ast_1a2b", "ast_3c4d"], "parameters": {"fit": "cover", "gravity": "attention"}, "variants": [{"name": "ig", "parameters": {"width": 1080, "height": 1350}}, {"name": "og", "parameters": {"width": 1200, "height": 630}}]}
```

A resume re-runs every variant that has an incomplete item, over every asset that has one — so when the failures are scattered, a few pairs that had already completed run again: free, and one more asset each.

## Cancelling

`POST /api/v1/jobs/{id}/cancel` on a `PENDING` or `PROCESSING` job answers with the job — already `CANCELLED` when nothing was in flight, `CANCELLING` otherwise. A job already finished or already cancelling answers `400 invalid_state`, with the state it is in under `details.status`. There is no MCP tool for it: an agent that wants out stops waiting, and the job is a person's to cancel.

#### JavaScript

```js
const job = await client.jobs.cancel("<job-id>"); // CANCELLING until what is in flight settles
```

#### Python

```python
job = client.jobs.cancel("<job-id>")  # CANCELLING until what is in flight settles
```

#### CLI

```sh
imagestep jobs cancel <job-id>
```

#### curl

```sh
curl -s -X POST https://api.imagestep.dev/api/v1/jobs/<job-id>/cancel -H "Authorization: ApiKey $IMAGESTEP_API_KEY"
```

What stops depends on what is in flight. An AI item not yet sent to a provider is dropped; one in flight finishes and is charged, and one caught between its in-job retries ends `FAILED`. A deterministic or render item already handed to a worker always completes, because the worker has no notion of cancelling and a refused result would be an orphaned object. A chain job's item finishes the segment it is on and stops there, so a resume picks it up at the next one. When all of that has settled the job is `CANCELLED`, with the outputs — and the charge — of whatever completed.

## Lifecycle

A job is in one of six statuses, and the last three are final: nothing moves a job out of them, and a resume is a new job.

1. `PENDING` — queued behind the 2 jobs of its type already running
2. `PROCESSING` — items handed out — in the submit's own answer when a slot is free
3. then, when its last item settles:

   - `COMPLETED` — no item failed
   - `FAILED` — at least one item failed — resume runs those as a new job
   - `CANCELLED` — a cancel came first; it waited in CANCELLING for what was in flight

| status | meaning |
| --- | --- |
| PENDING | queued behind your other running jobs of the same type (2 run at a time per type); it starts when one of them settles, however long that takes, and is never failed for waiting |
| PROCESSING | running: its items are going out to workers and their results are coming back |
| CANCELLING | you cancelled it; what a provider or a worker already has finishes (and is charged), the rest is dropped |
| COMPLETED | every item settled and none failed |
| FAILED | every item settled and at least one failed — the others' outputs are there, and charged |
| CANCELLED | you cancelled it and what was in flight has settled: the items that finished keep their outputs (and their charge), the rest are CANCELLED |

A cancel moves a `PENDING` or `PROCESSING` job to `CANCELLING`, and it is `CANCELLED` once what was in flight has settled, whatever those items did.

Items go `PENDING` → `PROCESSING` → `COMPLETED` / `FAILED` / `CANCELLED`; a chain job's item goes back to `PENDING` between its segments. Each carries its own trace: who ran it (`provider`, `model`), when (`startedAt`, `finishedAt`, `durationMs`), what it cost (`credits`), on an AI item where the time went (`timings`), and on failure an `error` sentence, an `errorCode` and `retryable`.

A running job that makes no progress for 20 minutes — no item settling, nothing being handed out — is ended by a reaper: the items still waiting fail with `internal_error`, which is retryable, so a resume runs them again; an item a worker still holds is left to finish. The job then settles like any other, charged for what completed. A queued `PENDING` job is never failed for waiting.

## Request fields

Everything a submit body may carry — and a dry run's, which is the same body:

| field | type | meaning |
| --- | --- | --- |
| op | `string` | what to run: an op from the catalogue. The service derives type, presetId and preset from it |
| presetId | `string` | …or a saved preset instead: slug, id, or slug@version. model, prompt and parameters then override its one AI step — a preset with none, or several, refuses them |
| steps | `PresetStep[]` | …or the steps a preset would store, inline: the same array, checked the same way, run once without saving one — one job, priced per segment. Nothing rides beside it but its subjects: an op, a preset or a request-level model is refused by name |
| subjects | `Subject[]` | with steps only: the subjects a consistency preset stores, for its generate and edit steps — checked as a preset's are and recorded on the job. Beside an op or a presetId it is refused: a preset carries its own |
| assetIds | `string[] ≤ 10000` | the inputs; resolved against your account before anything is created — a blank id is invalid_param, someone else's is asset_not_found with every missing id listed |
| imageCount | `integer 1–10000` | dry run only: images you have not uploaded yet, priced as that many more assetIds — the price never depends on the pixels. A submit needs the images, so it refuses the count |
| parameters | `object` | the op's parameters object, validated against its contract |
| prompt · model | `string` | for AI ops; a request's own model and prompt override a preset's one AI step |
| count | `integer 1–10` | how many images one generate makes |
| variants | `object[]` | deterministic ops only: several outputs per input, in one job — up to 20 entries (several sizes) |
| wait | `integer` | hold the response until a one-item job is done, 60 s at most (waiting for it) |
| collection | `string` | the collection the outputs go in; without one, an output made from an asset is in that asset's collection |
| retentionDays | `integer ≥ 1` | keep the outputs (and this record) this many days instead of your plan's retention — shorter only; more is kept for the plan's time |
| templateId · items | `string · object[] ≤ 500` | for render_template, which takes no assets — one object of variables per output image |
| type | `ai-generate \| ai-edit \| parse \| process \| render \| chain` | the job type. Leave it out: op, presetId and steps decide it, and chain is what several segments run as — a preset's or inline steps — never something to ask for. Sent alone with a model, it runs that model with no preset |
| preset | `PipelineData` | an unsaved pipeline of engine steps, for a process job — the older, process-only form of steps |

## Dry-run fields

Everything a dry run may answer with; a field that does not apply to the job type is absent:

| field | type | meaning |
| --- | --- | --- |
| type · model | `string` | the job type the request resolves to, and — for an AI op — the model that would run |
| totalItems | `integer` | one per input, times one per variant; count for a generate; one per row for a render |
| costPerItem | `integer` | the charge per completed item, in credits — not an estimate of it |
| estimatedCredits | `integer` | totalItems × costPerItem |
| creditBalance · sufficientCredit | `integer · boolean` | your balance now, and whether it covers estimatedCredits; false means the submit is 402 insufficient_credit |
| assetCountLeft | `integer` | how many more assets your plan's ceiling leaves room for — every plan has one; fewer than totalItems means the submit is 422 asset_count_exceeded |
| processCountLeft · overageRuns · overageCredits | `integer` | deterministic and render jobs only: the monthly allowance left (-1 when unlimited), and of this job's runs how many fall past it and what they cost — paid from your balance, not refused, and already in estimatedCredits. An AI job does not carry them |
| maxInputEdge | `integer` | AI jobs: the longest edge, in px, an input is scaled down to before this model sees it |
| prompt | `string` | AI jobs: the prompt the model would receive, with every {{subject.<name>}} already expanded (presets → subjects) |
| presetId · presetName · presetVersion | `string · integer` | the preset the request resolved to and the version it would run; op:<name> for a bare op |
| templateId · templateName · templateVersion | `string · integer` | render only: the template the call named and the version it would render |
| steps · processPerItem | `StepEstimate[] · integer` | several segments only — a preset's or inline steps — the price broken down per segment (presets → chains) |
| warnings | `StepWarning[]` | what the steps say that is legal and probably not meant, with the step indexes — advisory, and never a reason a submit would fail (presets → warnings) |
