Skip to navigation

TypeScript SDK

Use the Node.js client for server-side Skin, Makeup, Hair, Assets, Jobs, Usage and Credits calls.

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:

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.

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

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.

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.

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.

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

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.

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.

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

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.

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.

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.

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 for recovery guidance.

Lists, cancel, delete, credits and webhooks

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:

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

See Jobs, Assets, Usage and credits, Rate limits and Webhooks for what each call answers.