Skip to navigation

Asynchronous jobs

The job is the contract. Submitting is not analysing, and failure arrives on a 200.

Skin Analysis, Makeup Try-On, and Hair Color Try-On submit work and answer immediately. Processing happens afterwards, and the Job is how you find out what happened.

POST /v1/skin/analyses 202 job accepted
POST /v1/makeup/try-ons 202 job accepted
POST /v1/hair/try-ons 202 job accepted
|
| Location: /v1/jobs/{job_id}
| Retry-After: 1
| X-Request-Id: ...
v
GET /v1/jobs/{job_id} 200 queued -> running -> succeeded
\-> failed
|
| output.*.href
v
GET /v1/assets/{asset_id}/content the result bytes

The state machine is closed

queued -> running -> succeeded
| \-> failed
\--------------------> cancelled

Five states, and that is the whole set. It is closed deliberately: a switch over it in your code is exhaustive and will stay exhaustive, because adding a state is a breaking change that arrives only with a new major contract version and a notice, not as a surprise on a Tuesday. cancelled is the state cancelling a job produces. Cancellation is enabled per environment, so a job may never reach it in yours; handle it anyway. A cancelled job carries neither output nor error, has completed_at set, and is never billed.

succeeded, failed and cancelled are terminal. Nothing leaves them.

Failure arrives on a 200

This is the single most common integration bug against this API, so it gets its own heading.

{
"id": "job_w504fza4dp8k26bkr3thwkecfa",
"operation": "skin.analyze",
"status": "failed",
"completed_at": "2026-09-24T10:00:08Z",
"output": null,
"error": {
"code": "INPUT_UNPROCESSABLE",
"message": "The input could not be processed as a face image (no face found, low quality, undecodable)."
}
}

That is an HTTP 200. Your HTTP client will not raise, your response.ok check will pass, and a try/except around the request will not fire. The only thing that catches it is reading status.

The reason it is a 200 rather than a 4xx: the poll succeeded. You asked for the state of a job and you got the state of the job, accurately. An HTTP error there would mean “your poll was wrong”, which it was not. Transport-level failures and work-level failures are different events, and collapsing them would make it impossible to tell a broken poll from a broken image.

job = poll(job_id)
if job["status"] == "failed":
handle(job["error"]["code"]) # branch on the code
elif job["status"] == "succeeded":
use(job["output"])

The fields obey fixed invariants, so a client can rely on them:

statuscompleted_atoutputerror
queued, runningnullnullnull
succeededsetsetnull
failedsetnullset

A failed job’s error.code is one of three, and each tells you what to do next:

error.codeDo
INPUT_UNPROCESSABLEuse a different image; the same one will fail the same way
INPUT_EXPIREDupload the image again and submit under a new Idempotency-Key
PROCESSING_FAILEDour side; a retry under a new Idempotency-Key may succeed

output is typed by output.type — skin.analysis, makeup.try_on, or hair.try_on — and is an open union: a later product adds a type, so keep an unknown one whole rather than failing. Job outputs is the detail.

Polling politely

Retry-After on the submission and on each poll says when to come back, in seconds. Honouring it is not courtesy — it is the difference between a poll that answers and a poll that earns 429 RATE_LIMITED.

Cache the ETag from each poll and send it back as If-None-Match:

GET /v1/jobs/job_4ryn2an9hm2qxnb6xvbrav094b
If-None-Match: "a1b2c3d4"
304 Not Modified

A 304 means “nothing changed, keep waiting” and costs a fraction of a full response. Reuse your cached job state.

Durability, and what a timeout does not do

The job id is the only handle to the work, and it outlives your process. Persist it next to whatever business record caused the submission — an order, a session, a row — before you start waiting. A worker that crashes mid-poll can then resume; a worker that kept the id in memory has lost the work it paid for.

A client-side wait timeout stops your waiting. It does not cancel the job. The analysis continues, the credits are spent, and the result becomes available on schedule. If you time out and resubmit without an Idempotency-Key, you have paid twice for the same image. With one, you have not — see Idempotency.

Listing jobs

GET /v1/jobs returns summaries of your jobs, newest first: the window GET /v1/jobs/{job_id} still answers, which is the last 24 hours by default. A list item carries no output; read a job’s result with jobs.get. Filter by status and operation (both repeatable), asset_id, idempotency_key, or a created_gte / created_lt window, and follow next_cursor for the next page. The list may trail the poll briefly; the poll is authoritative. Listing is enabled per environment.

const page = await client.jobs.list({ status: ["queued", "running"], limit: 50 });
for (const job of page.data) console.log(job.id, job.status);
for await (const job of client.jobs.listAll({ operation: "skin.analyze", maxItems: 500 })) {
console.log(job.id, job.status);
}

Cancelling a job

POST /v1/jobs/{job_id}/cancel takes no body and no Idempotency-Key; sending it twice is safe. A queued job is cancelled at once and answers 200 with status: "cancelled"; it is never billed and its credit hold is released. A job already picked up answers 202 with its current state: cancellation is honoured before processing starts, but once processing has started the job runs to completion and is billed, so keep polling. A job that already finished answers 409 JOB_NOT_CANCELLABLE. The key needs the scope of the job’s product. Cancellation is enabled per environment.

const job = await client.jobs.cancel("job_4ryn2an9hm2qxnb6xvbrav094b");
console.log(job.status); // "cancelled", or "queued"/"running" while the request is honoured

With SDK jobs.wait, a job that ends cancelled raises JobCancelledError.

Deleting a job

DELETE /v1/jobs/{job_id} deletes a terminal job and erases its input snapshot and result bytes now; it answers 204. A job still queued or running is 409 JOB_IN_PROGRESS: cancel it and wait first. After deletion the poll answers 404 JOB_NOT_FOUND, and a submission reusing the job’s Idempotency-Key answers 410 JOB_DELETED. Usage counts are kept, because they are aggregates, not image data. The key needs the scope of the job’s product. Deletion is enabled per environment.

await client.jobs.delete("job_4ryn2an9hm2qxnb6xvbrav094b");

Jobs and results expire

Every job carries expires_at, and so does each result asset in its output. It is the last moment you may rely on either: after it, the poll answers 404 JOB_NOT_FOUND and the result image is gone. Read the value rather than assuming a number.

A result href is a short-lived authenticated download, not storage. Fetch what you need and put it where you keep things. Do not treat it as a CDN link, do not paste it into client analytics, and do not hand it to a browser — it needs your key to resolve anyway.

A 503 on a poll does not mean the job is gone

503 SERVICE_UNAVAILABLE means ask again, after its Retry-After. Only 404 JOB_NOT_FOUND means there is nothing there. Treating a 503 as expiry throws away completed work.