> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.morphiaone.ai/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.morphiaone.ai/_mcp/server.

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

```text
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

```text
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](#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.

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

```python
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:

| `status`            | `completed_at` | `output` | `error` |
| ------------------- | -------------- | -------- | ------- |
| `queued`, `running` | null           | null     | null    |
| `succeeded`         | set            | set      | null    |
| `failed`            | set            | null     | set     |

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

| `error.code`          | Do                                                              |
| --------------------- | --------------------------------------------------------------- |
| `INPUT_UNPROCESSABLE` | use a different image; the same one will fail the same way      |
| `INPUT_EXPIRED`       | upload the image again and submit under a new `Idempotency-Key` |
| `PROCESSING_FAILED`   | our 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](/concepts/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`:

```http
GET /v1/jobs/job_4ryn2an9hm2qxnb6xvbrav094b
If-None-Match: "a1b2c3d4"
```

```http
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](/concepts/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.

**`TypeScript`**

```ts title="TypeScript"
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);
}
```

**`Python`**

```python title="Python"
page = client.jobs.list(status=["queued", "running"], limit=50)
for job in page.data:
    print(job.id, job.status)

for job in client.jobs.list_all(operation="skin.analyze", max_items=500):
    print(job.id, job.status)
```

**`REST`**

```bash title="REST"
curl -sS -H "X-API-Key: $API_KEY" \
  "$BASE_URL/v1/jobs?status=queued&status=running&limit=50"
```

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

**`TypeScript`**

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

**`Python`**

```python title="Python"
job = client.jobs.cancel("job_4ryn2an9hm2qxnb6xvbrav094b")
print(job.status)
```

**`REST`**

```bash title="REST"
curl -sS -X POST -H "X-API-Key: $API_KEY" \
  "$BASE_URL/v1/jobs/job_4ryn2an9hm2qxnb6xvbrav094b/cancel"
```

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.

**`TypeScript`**

```ts title="TypeScript"
await client.jobs.delete("job_4ryn2an9hm2qxnb6xvbrav094b");
```

**`Python`**

```python title="Python"
client.jobs.delete("job_4ryn2an9hm2qxnb6xvbrav094b")
```

**`REST`**

```bash title="REST"
curl -sS -X DELETE -H "X-API-Key: $API_KEY" \
  "$BASE_URL/v1/jobs/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.