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

# Digitise and import shades

Everything the Platform's digitisation screens do is a public call. This page is the
order to call it in, written so a script or an AI agent can follow it without guessing.
An AI agent can also use these steps as tools, through the MorphiaOne MCP server or the
digitisation skill: see [Connect an AI agent](/guides/ai-agents).

## The rules that keep a run safe

* **List before you create.** Every shade you add is unique by its `external_ref` (your
  SKU). Read `GET /v1/catalogue/variants?external_ref=...` first; a taken reference
  answers `409 EXTERNAL_REF_TAKEN`.
* **Every write carries an `Idempotency-Key`.** Derive it from the row, not from a clock,
  so a rerun replays the first answer instead of creating a duplicate.
* **A SKU's reference and GTIN are fixed at creation.** To change a shade you already
  have, post a new revision of it. A revision never carries `external_ref` or `gtin`.
* **Review what you accept.** Accepting and staging send the `candidate_sha256` you read;
  a candidate that changed since is refused.
* **Colours are upper-case hex**, `#RRGGBB`.

## The key you need

The read calls below need `digitisation.read`. Writes need `digitisation.write`,
`digitisation.review` and the `catalogue.write` scope. Create the key in the Platform,
under **API keys**. A key without the scope is refused `403 INSUFFICIENT_SCOPE`.
If your account's review policy is on (**Settings → Review policy**), no key can
accept or reject, and a key stages only what a member accepted: those calls answer
`403 HUMAN_REVIEW_REQUIRED`, and the candidate's `review_url` is where a person decides.

## Browse what exists

Three list calls return the same page shape, `data`, `has_more` and `next_cursor`,
newest first. Send `next_cursor` back as `cursor` with the same filters to continue; a
cursor from another filter or account is `400 INVALID_CURSOR`. `limit` is 1 to 100.

| Call                               | Filters                                                                         |
| ---------------------------------- | ------------------------------------------------------------------------------- |
| `GET /v1/digitisation/evidence`    | `original_upload_sha256`: was this file already retained?                       |
| `GET /v1/digitisation/assessments` | `status`, repeatable: `queued`, `running`, `succeeded`, `failed`, `cancelled`   |
| `GET /v1/digitisation/candidates`  | `review_status`, repeatable: `pending`, `accepted`, `rejected`; `assessment_id` |

The review queue is the pending candidates:

```bash
curl -s "$MORPHIAONE_BASE_URL/v1/digitisation/candidates?review_status=pending&limit=20" \
  -H "Authorization: Bearer $MORPHIAONE_API_KEY"
```

An unknown or repeated parameter is `400 INVALID_REQUEST` naming it, never ignored.

### In the SDKs

Each list call has a `list_all_*` form that follows the cursors for you. The
`morphiaone digitisation candidates` command prints the same page as JSON.

#### Python

```python
for candidate in client.digitisation.list_all_candidates(review_status="pending"):
    print(candidate.id, candidate.review_status)

page = client.digitisation.list_assessments(status=["queued", "running"], limit=20)
```

#### TypeScript

```ts
for await (const candidate of client.digitisation.listAllCandidates({ reviewStatus: 'pending' })) {
  console.log(candidate.id, candidate.reviewStatus);
}

const page = await client.digitisation.listAssessments({ status: ['queued', 'running'], limit: 20 });
```

## From a photo to a staged shade

1. Retain the photo: `POST /v1/digitisation/evidence` with `asset_id` (an upload) or `url` (a public `https` image the API fetches).
   When someone else has the photo, `POST /v1/uploads` answers a single-use `upload_url`
   they send it to with no API key; `GET /v1/uploads/{upload_id}` answers its `asset_id`
   once it is `stored`.
2. Measure regions: `POST /v1/digitisation/assessments` answers `202 queued`; poll
   `GET /v1/digitisation/assessments/{assessment_id}` until it is settled.
3. Freeze one region for review: `POST /v1/digitisation/candidates`.
4. Review it in the queue above, then accept or reject by its digest.
5. Stage the accepted candidate into the catalogue as a draft revision.

Approving and publishing the draft is a separate step with its own scope, so nothing
renders in a try-on until you do.

## Add many shades from a table

There is no bulk route. Loop over your rows with the rule above: create the variant,
and on `409 EXTERNAL_REF_TAKEN` find the live variant by `external_ref` and post a
revision. Keep one result per row and carry on after a failure, so one bad row never
stops the file. The SDKs do exactly this and return `created`, `revised` or `failed` for
each row.

#### Python

```python
rows = [
    {
        "display_color": "#c27461",
        "applications": [{"application_id": "lips", "category": "lipstick"}],
        "external_ref": "SKU-1",
    },
]
for result in client.catalogue.import_shades(rows):
    print(result.row["external_ref"], result.outcome, result.error)
```

#### TypeScript

```ts
const rows = [
  {
    displayColor: '#c27461',
    applications: [{ application_id: 'lips', category: 'lipstick' }],
    externalRef: 'SKU-1',
  },
];
for (const result of await client.catalogue.importShades(rows)) {
  console.log(result.row.externalRef, result.outcome, result.error);
}
```

## Machine-readable sources

An agent should read the contract rather than this page: the OpenAPI document, `llms.txt`
and the generated scope table list every route, parameter and scope.