Asynchronous jobs
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.
The state machine is closed
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.
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.
The fields obey fixed invariants, so a client can rely on them:
A failed job’s error.code is one of three, and each tells you what to do next:
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:
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.
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.
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.
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.