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

# REST API onboarding

> **Info**
>
> **Release status.** API `1.0.0-rc.1` is a release candidate: request and response shapes may still change before the stable release. SDK `0.2.2` is published as a Developer Preview (`@morphiaone/sdk` on npm and `morphiaone` on PyPI).

Every step here is plain HTTP and works without an SDK. If you can use one, the
[TypeScript](/sdks/typescript) and [Python](/sdks/python) SDKs do these steps for you.

## Set the two values every call needs

```bash
export BASE_URL="https://api.morphiaone.ai"
export API_KEY="pk_test_..."   # placeholder; use the key you created in the Platform
export IMAGE="./face.jpg"                    # a JPEG, PNG or WebP with one face
```

The key goes in an `X-API-Key` header on every call, and it is a server-side
secret. If the value above ends up in a browser bundle, a mobile binary or a
committed file, it is a leaked credential — see
[Serving a browser or mobile client](/guides/bff-pattern).

## 1. Upload the image

The image bytes are the whole request body. No multipart, no JSON wrapper.

```bash
curl --fail-with-body --silent --show-error \
  -H "X-API-Key: ${API_KEY}" \
  -H "Content-Type: image/jpeg" \
  --data-binary "@${IMAGE}" \
  "${BASE_URL}/v1/assets"
```

```json
{
  "id": "asset_6pa8ad0ds2t7g1z6pg4tcr4zqp",
  "purpose": "input",
  "media_type": "image/jpeg",
  "size_bytes": 482113,
  "width": 1200,
  "height": 1600,
  "created_at": "2026-09-24T10:00:00Z",
  "expires_at": "2026-09-25T10:00:00Z"
}
```

```bash
export ASSET_ID="asset_6pa8ad0ds2t7g1z6pg4tcr4zqp"   # the id you received
```

## 2. Submit the analysis

```bash
curl --fail-with-body --silent --show-error \
  --dump-header /tmp/submit.headers \
  -H "X-API-Key: ${API_KEY}" \
  -H "Idempotency-Key: quickstart-skin-001" \
  -H "Content-Type: application/json" \
  -d "{
        \"inputs\": [{\"role\": \"source\", \"asset_id\": \"${ASSET_ID}\"}],
        \"concerns\": [\"acne\", \"redness\", \"wrinkles\"],
        \"visualizations\": [{\"concern\": \"acne\", \"kind\": \"mask\"}]
      }" \
  "${BASE_URL}/v1/skin/analyses"
```

```json
{
  "id": "job_4ryn2an9hm2qxnb6xvbrav094b",
  "operation": "skin.analyze",
  "status": "queued",
  "created_at": "2026-09-24T10:00:05Z",
  "updated_at": "2026-09-24T10:00:05Z",
  "completed_at": null,
  "expires_at": "2026-09-25T10:00:05Z",
  "inputs": [{ "role": "source", "asset_id": "asset_6pa8ad0ds2t7g1z6pg4tcr4zqp" }],
  "output": null,
  "error": null
}
```

Three things in that response are load-bearing:

* **`202`, not `200`.** The job was accepted. Nothing has been analysed yet.
* **`Retry-After`** is how many seconds to wait before the first poll. It is advice
  from the server, and honouring it is cheaper for you than a tight loop that gets
  rate limited.
* **The `Location` header** holds the polling URL, so you do not have to build it:

```bash
export JOB_URL="$(awk 'tolower($1)=="location:" {gsub("\r","",$2); print $2}' /tmp/submit.headers)"
```

The `Idempotency-Key` is required, and it is what makes retrying this call safe.
Re-running the exact same submission answers `200` with `Idempotency-Replayed: true`
and the *same* job, rather than starting a second one and charging you twice. Reusing
the key with a different body answers `409 IDEMPOTENCY_KEY_REUSED` on purpose — see
[Idempotency](/concepts/idempotency).

## 3. Poll until the job is terminal

```bash
curl --fail-with-body --silent --show-error \
  -H "X-API-Key: ${API_KEY}" \
  "${BASE_URL}${JOB_URL}"
```

Repeat while `status` is `queued` or `running`, waiting the `Retry-After` each answer
gives you between polls. The terminal states are `succeeded` and `failed`, and there
are only four states in total — the set is closed, so a `switch` over it will not
encounter a fifth.

**A failed analysis is a `200`.** This is the detail worth reading twice:

```json
{
  "id": "job_w504fza4dp8k26bkr3thwkecfa",
  "operation": "skin.analyze",
  "status": "failed",
  "output": null,
  "error": {
    "code": "INPUT_UNPROCESSABLE",
    "message": "The input could not be processed as a face image (no face found, low quality, undecodable)."
  }
}
```

Your HTTP client will not raise on that. Code that treats `2xx` as success and
moves on will silently carry a failed job forward as if it had results.

## 4. Read the output and fetch the images

A succeeded job carries a typed `output`. Result images are assets, referenced by id
and `href`, not bytes:

```json
{
  "status": "succeeded",
  "output": {
    "type": "skin.analysis",
    "analysis_version": "skin-2026-09-draft.1",
    "concerns": [
      { "concern": "acne", "status": "measured", "score": 8.7, "unavailable_reason": null }
    ],
    "visualizations": [
      {
        "id": "asset_cnep5j6sw0kphca1v6gv6qwayc",
        "role": "mask.acne",
        "media_type": "image/png",
        "href": "/v1/assets/asset_cnep5j6sw0kphca1v6gv6qwayc/content"
      }
    ]
  }
}
```

```bash
curl --fail-with-body --silent --show-error \
  -H "X-API-Key: ${API_KEY}" \
  --output mask-acne.png \
  "${BASE_URL}/v1/assets/asset_cnep5j6sw0kphca1v6gv6qwayc/content"
```

Result assets expire with their job, at the `expires_at` each one carries. After
that the job answers `404 JOB_NOT_FOUND` and the image is gone. Fetch or copy what
you need before then rather than treating the `href` as durable storage.

## Hair color try-on (draft)

Hair color try-on takes a source Asset; the draft endpoint finds the hair and recolors
only those pixels. An optional same-size grayscale `hair_mask` Asset replaces its own
segmentation. It does not change a haircut. See the [Hair color try-on guide](/products/hair-try-on)
for its REST, Python, and TypeScript calls.

## What you have just built, and what is still missing

You have the whole interaction shape. What a production integration adds is not
more endpoints — it is four behaviours:

| Add this                                                       | Because                                          |
| -------------------------------------------------------------- | ------------------------------------------------ |
| An `Idempotency-Key` derived from your own order or request id | so a retry after a timeout cannot double-charge  |
| A backoff that honours `Retry-After`                           | so a `429` or `503` clears instead of amplifying |
| A branch on `error.code`, never on `message`                   | messages are human text and change; codes do not |
| A check that `status == "succeeded"` before reading `output`   | a failed job is a `200`                          |

[Production checklist](/guides/production) is those four in order, with
the rest.