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

# TypeScript SDK

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

For a Node.js backend, use the SDK to upload images, submit product jobs, poll for
results, and download result Assets. It is one ESM package for TypeScript and JavaScript,
requires Node.js 20 or newer, and has no runtime dependencies.

## Install

Pin the exact version so an install cannot silently move to another 0.x release:

```bash
npm install --save-exact @morphiaone/sdk@0.2.2
```

## Keep authentication on your server

Load the API key and the API base URL from your server environment or secret manager.
The SDK requires both; it has no default API host. Its methods append the V1 routes to
`baseUrl`.

```ts
import { readFile, writeFile } from "node:fs/promises";
import {
  ApiError,
  MorphiaOne,
  MorphiaOneError,
  ProcessingError,
} from "@morphiaone/sdk";

const apiKey = process.env.MORPHIAONE_API_KEY;
const baseUrl = process.env.MORPHIAONE_BASE_URL;

if (!apiKey || !baseUrl) {
  throw new Error("Set MORPHIAONE_API_KEY and MORPHIAONE_BASE_URL");
}

const client = new MorphiaOne({ apiKey, baseUrl });
```

The key authorizes requests on your account. Do not put it in browser code, a mobile
app, or a public chatbot. If a browser needs these features, send its request through
your backend-for-frontend (BFF), which authenticates your own user before calling this
SDK. See [Authentication](/getting-started/authentication) and the
[BFF pattern](/guides/bff-pattern).

## Upload, submit, and wait

Every product call uses the same flow: upload input bytes once as an Asset, submit the
Asset id, then wait for the asynchronous Job. `readFile` returns bytes the SDK accepts;
it infers JPEG, PNG, or WebP from the file name or image bytes when `mediaType` is not
provided. The examples below reuse `client` from setup and `source` from this upload.

```ts
const source = await client.assets.upload(await readFile("portrait.jpg"));
const quickstartJob = await client.skin.analyze({
  assetId: source.id,
  concerns: ["acne", "redness"],
  idempotencyKey: "order-48213-skin",
});

// Persist job.id with your order before waiting, so another process can resume it.
const quickstartDone = await client.jobs.wait(quickstartJob.id);
```

`jobs.wait` polls and honors the server's `Retry-After`. It resolves with a succeeded
Job and throws `ProcessingError` if the Job reaches `failed`. A timeout ends this
process's wait; it does not cancel work on the server. Persist the Job id, then resume
with `client.jobs.get(jobId)` or `client.jobs.wait(jobId)`. See
[Asynchronous jobs](/concepts/jobs).

Jobs and result Assets expire. Download result content before the `expiresAt` on its
reference. `assets.getContent` downloads by Asset id through the configured API host;
it does not follow redirects or use a URL from the result as a credential destination.
See [Assets](/concepts/assets).

## Skin Analysis

Ask only for concerns and visualizations your product uses. A completed output can
contain an unavailable concern; check its `status` before using `score`. A higher score
means the concern is less visible. This is cosmetic analysis, not a medical diagnosis.

```ts
const skinJob = await client.skin.analyze({
  assetId: source.id,
  concerns: ["acne", "redness", "wrinkles"],
  visualizations: [{ concern: "acne", kind: "mask" }],
  idempotencyKey: "order-48213-skin-v1",
});
const skinDone = await client.jobs.wait(skinJob.id);

if (skinDone.output?.known && skinDone.output.type === "skin.analysis") {
  for (const result of skinDone.output.concerns) {
    if (result.status === "measured") {
      console.log(result.concern, result.score);
    } else if (result.status === "unavailable") {
      console.log(result.concern, result.unavailableReason);
    } else {
      console.log(result.concern, result.status);
    }
  }

  const mask = skinDone.output.visualizations.find((item) => item.role === "mask.acne");
  if (mask) {
    const { content } = await client.assets.getContent(mask);
    await writeFile("acne-mask.png", content);
  }
}
```

The output is an open union: check `known` and `type` before reading product fields.
See [Skin Analysis](/products/skin-analysis) for concern semantics and image details.

## Makeup Try-On

`products` is ordered; its order is the render layer order. Current categories are
`foundation`, `lipstick`, and `lip_liner`.

```ts
const makeupJob = await client.makeup.tryOn({
  assetId: source.id,
  products: [
    { category: "lip_liner", color: "#7B2E42", opacity: 0.8 },
    { category: "lipstick", color: "#C2185B" },
  ],
  idempotencyKey: "order-48213-makeup-v1",
  wait: true,
});

if (makeupJob.output?.known && makeupJob.output.type === "makeup.try_on") {
  const { content } = await client.assets.getContent(makeupJob.output.image);
  await writeFile("makeup.png", content);
}
```

`wait: true` waits for completion before the submit call resolves. Pixel parity with the
production browser renderer is not proven for any category; do not promise matching
pixels. See [Makeup Try-On](/products/makeup).

## Hair Color Try-On

Hair takes a source image; the server finds the hair and recolors it with a two-stop
gradient. It does not change a hairstyle or hair length. To use your own segmentation,
add `hairMaskAssetId`: a grayscale mask Asset with the same dimensions, zero for
background and nonzero for hair. This is a draft capability.

```ts
const hairJob = await client.hair.tryOn({
  sourceAssetId: source.id,
  colorGradient: {
    dark: { color: "#3B1F16", opacity: 0.9 },
    light: { color: "#C87943", opacity: 0.8 },
  },
  idempotencyKey: "order-48213-hair-v1",
  wait: true,
});

if (hairJob.output?.known && hairJob.output.type === "hair.try_on") {
  const { content } = await client.assets.getContent(hairJob.output.image);
  await writeFile("hair-result.png", content);
}
```

The gradient accepts `#RRGGBB` colors and optional opacity from `0` to `1`. See
[Hair Color Try-On](/products/hair-try-on) for its current capability limits.

## Read Usage

The usage report contains operational counts by UTC day and operation. Its date window
includes `startDate` and excludes `endDate`; it is not an invoice.

```ts
const report = await client.usage.get({
  startDate: "2026-09-01",
  endDate: "2026-09-15",
});

console.log(report.as_of, report.totals);
```

## Make submissions safe to retry

Every SDK product submission sends an `Idempotency-Key`. If you omit `idempotencyKey`,
the SDK generates one for that call and reuses it for that call's transport retries.
For recovery across process restarts, pass a key derived from your durable business
operation, then reuse it only with the same Asset ids and request fields. Use a new key
when the image or request changes. A reused key with a different body raises
`ConflictError`.

Asset uploads do not accept idempotency keys. Retrying an upload after an ambiguous
network failure may create an extra input Asset; unused inputs expire. See
[Idempotency](/concepts/idempotency).

The SDK retries transport failures, `408`, retryable `429`, `500`, `502`, `503`, `504`,
and `409 IDEMPOTENCY_IN_PROGRESS` within four attempts by default. It honors
`Retry-After`; it does not retry invalid input, exhausted quota, or exhausted credits.
Defaults are 10 seconds per request and 120 seconds for an operation such as waiting for
a Job. A per-call `timeout` is in seconds; on a submit with `wait: true`, it covers
submit plus polling. See
[Retries and backoff](/concepts/retries).

## Handle errors by class and code

Branch on `error.code`, never on the human-readable `message`. HTTP failures are
`ApiError` instances and include `status`, `code`, `requestId`, and `retryAfter` where
available. A failed Job is different: `ProcessingError` carries its `jobId` and job
error `code`, even though the poll itself returned successfully.

```ts
import { ApiError, MorphiaOneError, ProcessingError } from "@morphiaone/sdk";

try {
  const pending = await client.skin.analyze({
    assetId: source.id,
    concerns: ["acne"],
    idempotencyKey: "order-48213-skin-errors-v1",
  });
  await client.jobs.wait(pending.id);
} catch (error) {
  if (error instanceof ProcessingError) {
    console.error({ jobId: error.jobId, code: error.code });
  } else if (error instanceof ApiError) {
    console.error({
      status: error.status,
      code: error.code,
      requestId: error.requestId,
      retryAfter: error.retryAfter,
    });
  } else if (error instanceof MorphiaOneError) {
    console.error(error.name, error.message);
  }
  throw error;
}
```

`AuthenticationError` means 401; `PermissionDeniedError` means 403, with
`InsufficientScopeError` for a narrow grant. `ValidationError` covers invalid requests,
unsupported media types, and unusable images; other subclasses identify payload size and
unsupported features. `InsufficientCreditsError`, `NotFoundError`, `ConflictError`,
`RateLimitError`, and `ServiceUnavailableError` let you handle those cases explicitly.
Local `TransportError`, `ProtocolError`, and `DeadlineExceeded` derive from
`MorphiaOneError`. Invalid arguments throw `TypeError`; an upload above `maxImageBytes`
throws `RangeError` before it is sent. See [Errors](/concepts/errors) for recovery
guidance.

## Lists, cancel, delete, credits and webhooks

```ts
import { JobCancelledError, verifyWebhook } from "@morphiaone/sdk";

// One page, or every page by next_cursor.
const page = await client.jobs.list({ status: "running", limit: 20 });
for await (const asset of client.assets.listAll({ purpose: "input", maxItems: 200 })) {
  console.log(asset.id);
}

// Cancel a job that has not started processing; delete a finished one.
await client.jobs.cancel(page.data[0].id);
try {
  await client.jobs.wait(page.data[0].id);
} catch (error) {
  if (!(error instanceof JobCancelledError)) throw error;
}
await client.jobs.delete("job_4ryn2an9hm2qxnb6xvbrav094b");
await client.assets.delete("asset_6pa8ad0ds2t7g1z6pg4tcr4zqp");

// Credits, and the rate-limit reading of the last response that spent a policy.
const balance = await client.credits.balance();
console.log(balance.spendable_units, balance.enforced);
console.log(client.lastRateLimit);
```

Verify a webhook delivery against the raw request body before you parse it:

```ts
const event = verifyWebhook({
  payload: rawBody,
  signature: request.headers["morphiaone-signature"],
  secret: process.env.MORPHIAONE_WEBHOOK_SECRET!,
});
```

See [Jobs](/concepts/jobs), [Assets](/concepts/assets),
[Usage and credits](/concepts/credits), [Rate limits](/concepts/rate-limits) and
[Webhooks](/concepts/webhooks) for what each call answers.