Skip to content

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, 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.

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

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).

{
  "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 carries an example the service has validated, in every surface. The shapes that are about the job rather than the op:

tothe 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.

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

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:

{
  "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:

{
  "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 saysthe submit answerswhat to do
sufficientCredit: false402 insufficient_credittop up, or send fewer items; estimatedCredits is what it needs
assetCountLeft below totalItems422 asset_count_exceededevery output is a new asset: delete some, or upgrade
overageRuns above 0the runs past the Free plan's allowance are paid from your balance — part of estimatedCredits, so sufficientCredit covers themnothing, 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.

// 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 });

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

const job = await client.jobs.wait("<job-id>", { timeoutMs: 120_000, onProgress: (j) => console.log(j.status) });
  • 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 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.
  • 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 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 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=, one page at a time, and the SDKs, the CLI and MCP collect them for you.

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

One completed item, as the job document carries it:

{
  "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.

{
  "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
    }
  ]
}
errorCoderetryableon an item, it means
provider_unavailableyesthe provider failed or timed out after the in-job retries; a resume may succeed
provider_rejectednothe provider refused this input — a 4xx, a moderation refusal, an answer with no image; the same input fails again
asset_not_foundnothe source asset was deleted after the job was accepted
invalid_statenothe 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_paramnoa step the image cannot satisfy — a crop outside it — or a render_template row or template that cannot render
unsupported_formatnothe processing worker cannot decode the image: corrupt, or bigger than its decode limit
asset_count_exceedednoyour 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_erroryesour 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.

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

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

{
  "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' sourceAssetIds — an upper bound for a chain, whose items restart part-way.

whenthe resume answers
the job is FAILED or CANCELLED and has an incomplete item201 — the new job
the job is still running, or COMPLETED, or has nothing incomplete400 invalid_state
a later attempt of it exists — only the latest can be resumed422 job_not_resumable, details.reason NOT_LATEST_ATTEMPT
one job gets 3 resumes, counted across its attempts, and they are used up422 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 account402 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:

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"]}'

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.

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 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 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 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.

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
});

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.

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

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. PENDINGqueued behind the 2 jobs of its type already running
  2. PROCESSINGitems handed out — in the submit's own answer when a slot is free
  3. then, when its last item settles:
    • COMPLETEDno item failed
    • FAILEDat least one item failed — resume runs those as a new job
    • CANCELLEDa cancel came first; it waited in CANCELLING for what was in flight
statusmeaning
PENDINGqueued 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
PROCESSINGrunning: its items are going out to workers and their results are coming back
CANCELLINGyou cancelled it; what a provider or a worker already has finishes (and is charged), the rest is dropped
COMPLETEDevery item settled and none failed
FAILEDevery item settled and at least one failed — the others' outputs are there, and charged
CANCELLEDyou 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:

fieldtypemeaning
opstringwhat to run: an op from the catalogue. The service derives type, presetId and preset from it
presetIdstring…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
stepsPresetStep[]…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
subjectsSubject[]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
assetIdsstring[] ≤ 10000the 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
imageCountinteger 1–10000dry 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
parametersobjectthe op's parameters object, validated against its contract
prompt · modelstringfor AI ops; a request's own model and prompt override a preset's one AI step
countinteger 1–10how many images one generate makes
variantsobject[]deterministic ops only: several outputs per input, in one job — up to 20 entries (several sizes)
waitintegerhold the response until a one-item job is done, 60 s at most (waiting for it)
collectionstringthe collection the outputs go in; without one, an output made from an asset is in that asset's collection
retentionDaysinteger ≥ 1keep 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 · itemsstring · object[] ≤ 500for render_template, which takes no assets — one object of variables per output image
typeai-generate | ai-edit | parse | process | render | chainthe 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
presetPipelineDataan 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:

fieldtypemeaning
type · modelstringthe job type the request resolves to, and — for an AI op — the model that would run
totalItemsintegerone per input, times one per variant; count for a generate; one per row for a render
costPerItemintegerthe charge per completed item, in credits — not an estimate of it
estimatedCreditsintegertotalItems × costPerItem
creditBalance · sufficientCreditinteger · booleanyour balance now, and whether it covers estimatedCredits; false means the submit is 402 insufficient_credit
assetCountLeftintegerhow 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 · overageCreditsintegerdeterministic 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
maxInputEdgeintegerAI jobs: the longest edge, in px, an input is scaled down to before this model sees it
promptstringAI jobs: the prompt the model would receive, with every {{subject.<name>}} already expanded (presets → subjects)
presetId · presetName · presetVersionstring · integerthe preset the request resolved to and the version it would run; op:<name> for a bare op
templateId · templateName · templateVersionstring · integerrender only: the template the call named and the version it would render
steps · processPerItemStepEstimate[] · integerseveral segments only — a preset's or inline steps — the price broken down per segment (presets → chains)
warningsStepWarning[]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)