Skip to navigation

REST API onboarding

Upload an image, submit a job, and retrieve its result with HTTP.

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 and Python SDKs do these steps for you.

Set the two values every call needs

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.

1. Upload the image

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

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"
{
"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"
}
export ASSET_ID="asset_6pa8ad0ds2t7g1z6pg4tcr4zqp" # the id you received

2. Submit the analysis

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

3. Poll until the job is terminal

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:

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

{
"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"
}
]
}
}
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 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 thisBecause
An Idempotency-Key derived from your own order or request idso a retry after a timeout cannot double-charge
A backoff that honours Retry-Afterso a 429 or 503 clears instead of amplifying
A branch on error.code, never on messagemessages are human text and change; codes do not
A check that status == "succeeded" before reading outputa failed job is a 200

Production checklist is those four in order, with the rest.