Contents

API v1 Reference

FaceNinja API

Send photos of one person, get a MetaHuman head back: neck and shoulders, 4K skin maps, all 52 ARKit blendshapes (tongue included), a groom recipe, and the file formats your pipeline uses. Studio runs on this same API, so anything you can do there you can script.

A head takes a few minutes, so the API is asynchronous. You start a head, wait for it (long-poll, event stream or webhook), then ask for a file and download it from a link that works for one minute.

upload photos→ create a head→ wait→ ask for a file→ download
Base URL
https://api.faceninja.net
Auth
Authorization: Bearer fn_live_…
Price
1,500 credits (USD 15) per head, every format included
Format
JSON in and out, UTF-8

What happens to a head

statusmeaning
generatingAn image model turns your main photo into a neutral, evenly lit, front-facing reference portrait. About a minute. reference_ready turns true when it is done, and you can download it as reference before the head is finished.
queuedWaiting in line for our builder. queue.position is your place in line, queue.builder whether a builder is online, busy or offline, and queue.eta_s about how many seconds until the head is ready (null while no builder takes work). If nothing picks the head up within 48 hours, it fails with timeout and the credits come back.
processingBeing built: photo checks, 3D shapes, fitting the MetaHuman head and neck, 4K texture bake, exports. stage, progress (0–100) and message say where it is. A few minutes.
completedEvery format and preview is ready; ask for files.
failedFinal. error.code says why (see head errors). The credits are returned automatically.
deletedYou deleted it; its photos and files are gone.

Credits. A credit is a cent: one US dollar buys 100 credits and a head costs 1,500 (prices.head in GET /v1/account). The credits are reserved when you create the head (credits.state: held), charged when the head completes (captured) and returned when it fails or you delete it before it finishes (released). Files, repeat downloads, dry runs and reads are free. New accounts start with one free head; buy more in Studio → Billing.

Quick start#

Make an API key in Studio → API keys, then:

curl
export FACENINJA_KEY=fn_live_...

# 1. create a head from one or more photos of the same person (multipart: repeat "photos")
HEAD=$(curl -s https://api.faceninja.net/v1/heads \
  -H "Authorization: Bearer $FACENINJA_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -F photos=@front.jpg -F photos=@side.jpg -F name="Captain Vera" | jq -r .id)

# 2. wait: each call returns as soon as the head is finished, or after 60 s
while :; do
  STATUS=$(curl -s "https://api.faceninja.net/v1/heads/$HEAD?wait=60" \
    -H "Authorization: Bearer $FACENINJA_KEY" | jq -r .status)
  case $STATUS in completed|failed) break ;; esac
done

# 3. ask for a file and 4. download it (the link needs no key and works for 60 s)
curl -sL https://api.faceninja.net/v1/heads/$HEAD/files \
  -H "Authorization: Bearer $FACENINJA_KEY" \
  -d format=glb -d arkit=true -d redirect=true -o head.glb
Node 18+
const API = 'https://api.faceninja.net';
const auth = { authorization: `Bearer ${process.env.FACENINJA_KEY}` };
const fs = await import('node:fs/promises');

const form = new FormData();
for (const f of ['front.jpg', 'side.jpg']) form.append('photos', new Blob([await fs.readFile(f)], { type: 'image/jpeg' }), f);
form.append('name', 'Captain Vera');
let head = await (await fetch(`${API}/v1/heads`, {
  method: 'POST', body: form, headers: { ...auth, 'idempotency-key': crypto.randomUUID() },
})).json();
if (head.error) throw new Error(head.error.message);

while (!['completed', 'failed'].includes(head.status)) {
  head = await (await fetch(`${API}/v1/heads/${head.id}?wait=60`, { headers: auth })).json();
}
if (head.status === 'failed') throw new Error(`${head.error.code}: ${head.error.message}`);

const link = await (await fetch(`${API}/v1/heads/${head.id}/files`, {
  method: 'POST', headers: { ...auth, 'content-type': 'application/json' },
  body: JSON.stringify({ format: 'glb', arkit: true }),
})).json();
await fs.writeFile('head.glb', Buffer.from(await (await fetch(link.url)).arrayBuffer()));
Python 3 (requests)
import os, uuid, requests

API = "https://api.faceninja.net"
s = requests.Session()
s.headers["Authorization"] = f"Bearer {os.environ['FACENINJA_KEY']}"

files = [("photos", (n, open(n, "rb"), "image/jpeg")) for n in ("front.jpg", "side.jpg")]
r = s.post(f"{API}/v1/heads", files=files, data={"name": "Captain Vera"},
           headers={"Idempotency-Key": str(uuid.uuid4())})
r.raise_for_status()
head = r.json()

while head["status"] not in ("completed", "failed"):
    head = s.get(f"{API}/v1/heads/{head['id']}", params={"wait": 60}, timeout=90).json()
if head["status"] == "failed":
    raise SystemExit(f"{head['error']['code']}: {head['error']['message']}")

link = s.post(f"{API}/v1/heads/{head['id']}/files", json={"format": "metahuman_zip"}).json()
with requests.get(link["url"], stream=True) as dl, open(link["filename"], "wb") as out:  # no API key here
    for chunk in dl.iter_content(1 << 20):
        out.write(chunk)
Prefer a webhook in production.

Waiting in a loop is fine for scripts. A service should create the head, store its id, and act on the webhook.

Authentication#

Send your key in the Authorization header: Authorization: Bearer fn_live_…. Keys belong to your account; make and revoke them in Studio → API keys. A key is shown once when you make it; we store only its hash, so a lost key cannot be recovered, only replaced.

  • Never put a key in a URL, a browser or a mobile app. Call the API from your server.
  • Signed download and upload links need no key. Do not send one to them.
  • A revoked key stops working at once (401 unauthorized).

Errors#

Every error has the same shape. Branch on code; message is written for people and may change. field names the input at fault, hint suggests a fix. Every response, error or not, has a FaceNinja-Request-Id header with the same value as request_id: quote it when you contact us.

400 Bad Request
{
  "error": {
    "code": "invalid_request",
    "message": "Unknown format \"gltf\". Use glb, obj, stl, ply, fbx, metahuman_zip.",
    "field": "options.formats",
    "request_id": "req_Qm3t0aW1bXcV9eLp"
  }
}
HTTP · codewhen, and what to do
400 invalid_requestA field is missing, unknown or wrong (field says which). Unknown fields are refused rather than ignored. Fix the request; retrying it unchanged will not help.
400 too_few_photos, too_many_photosA head takes 1 to 15 photos.
400 photo_not_uploaded, photo_expiredA photo_ids entry has no bytes yet (PUT it first) or is older than 24 hours (upload it again).
401 unauthorizedNo key, a wrong key or a revoked key.
402 insufficient_creditsNot enough credits for a head. Nothing was reserved; buy credits and retry.
404 not_foundNo such head, photo or route on your account.
404 variant_not_availableThe head has no file of that format and ARKit choice; hint lists what it has.
400 field_not_applicableA download option that the format does not take (for example texture on metahuman_zip).
422 variant_failedThe file with these download options could not be made; asked again after 10 minutes, it is tried again.
429 too_many_variantsA head keeps at most 24 prepared download variants.
409 idempotency_conflictSee idempotency.
409 head_failed, photo_in_useA failed head has no files; a photo that belongs to a head is deleted with the head.
410 files_expiredThe head's files were deleted after the retention period.
411 · 413 · 415length_required, photo_too_large / request_too_large, unsupported_media_type: send Content-Length, stay under the limits, use JPEG, PNG or WebP.
425 head_not_readyYou asked for a file before the head completed. Wait for it (Retry-After is set).
429 rate_limitedSlow down; retry after Retry-After seconds.
500 internal_error, 503 service_unavailableOur side. Retry with back-off; for creates, keep the same Idempotency-Key.

Head errors

A head that fails has status: "failed" and an error. Its credits are already back on your account.

error.codemeaning
no_faceNo usable face: no readable image, no face found, or the face is too small. See the photo guidelines.
multiple_peopleThe main photo shows more than one person.
not_same_personThe photos do not all show the same person.
unreadable_photoA photo could not be decoded (a damaged file, or a format we do not read). Export it again as JPEG.
content_rejectedA photo broke our content rules (nudity, an apparent minor) or the image model refused it.
generation_failedThe reference portrait or the 3D shapes could not be made from these photos. Try other photos.
timeoutThe head took too long or waited too long in line. Retrying later usually works.
internalSomething broke on our side. Retry; quote the head id if it repeats.

Limits#

Photos per head1 to 15, of one person. The first is the main photo.
Photo filesJPEG, PNG, WebP, HEIC/HEIF (iPhone) or AVIF, up to 25 MiB each. HEIC/HEIF and AVIF are converted to JPEG on our side, with the camera's rotation applied. When the main photo is HEIC/HEIF or AVIF, the reference portrait is drawn during the build instead of before it, so it appears later.
Multipart createUp to 60 MB in one request. For more, upload the photos first (upload slots) and create with photo_ids.
Rate limits600 requests a minute per account; 30 head creations a minute; 20 file links a minute per head. Over the limit: 429 with Retry-After.
Waiting?wait holds a request for at most 60 s; an event stream stays open about 55 s, then reconnects.
LinksDownload links work for 60 seconds (a started download finishes); upload links for an hour.
Unused photosDeleted 24 hours after upload unless a head uses them.
Finished headsPhotos and files are kept 30 days after the head finishes (files_expire_at), then deleted. The head's record stays.

Photos#

You can send photos inside the create request (multipart), or upload them first and create the head with their ids. Uploading first is better for big or many photos and for retries: an uploaded photo is not sent again. A photo id is used by one head; once a head has it, it is deleted with that head.

Photos are never decoded on upload. Size and type are checked; faces, the number of people and sameness are checked when the head is built, and a problem shows as a head error, with the credits returned.

POST/v1/photos#

Upload one photo

The body is the image itself, with its Content-Type and Content-Length. Optional ?filename= is kept for your reference. Returns 201 with a photo object.

curl https://api.faceninja.net/v1/photos?filename=front.jpg \
  -H "Authorization: Bearer $FACENINJA_KEY" \
  -H "Content-Type: image/jpeg" --data-binary @front.jpg
201 Created
{
  "id": "ph_9QYt3m1Lc0aZx7RfWk2B",
  "object": "photo",
  "status": "ready",
  "content_type": "image/jpeg",
  "bytes": 2411864,
  "filename": "front.jpg",
  "head_id": null,
  "created_at": "2026-10-23T14:02:11.402Z",
  "expires_at": "2026-10-24T14:02:11.402Z"
}
POST/v1/photos/uploads#

Get direct upload links

Ask for up to 15 slots at once with each file's type and size. Each slot comes with an upload: PUT the bytes to upload.url with exactly the upload.headers given (the content type is part of the signature). The bytes go straight to storage, not through our API. The photo is awaiting_upload until the PUT has landed, then ready.

curl https://api.faceninja.net/v1/photos/uploads \
  -H "Authorization: Bearer $FACENINJA_KEY" -H "Content-Type: application/json" \
  -d '{"files": [{"content_type": "image/jpeg", "bytes": 2411864, "filename": "front.jpg"}]}'

# then, for each slot (no API key):
curl -X PUT "$UPLOAD_URL" -H "Content-Type: image/jpeg" --data-binary @front.jpg
201 Created
{
  "object": "list",
  "data": [{
    "id": "ph_b2RkQ0p7wE5nVt1sYc8M",
    "object": "photo",
    "status": "awaiting_upload",
    "content_type": "image/jpeg",
    "bytes": 2411864,
    "filename": "front.jpg",
    "head_id": null,
    "created_at": "2026-10-23T14:02:11.402Z",
    "expires_at": "2026-10-24T14:02:11.402Z",
    "upload": {
      "url": "https://…signed…",
      "method": "PUT",
      "headers": { "content-type": "image/jpeg" },
      "expires_at": "2026-10-23T15:02:11.000Z"
    }
  }]
}
GET/v1/photos/{id}#

Read a photo

Returns the photo object. status is awaiting_upload, ready, attached (a head uses it; head_id says which) or deleted.

DELETE/v1/photos/{id}#

Delete an unused photo

Deletes the photo and its bytes. A photo that belongs to a head is deleted with the head instead (409 photo_in_use).

Heads#

POST/v1/heads#

Create a head

Reserves the price of one head (1,500 credits) and starts it. Answers 202 Accepted with the head (status: "generating") right away, or waits first with ?wait (as in GET). Send an Idempotency-Key so a retried request cannot make two heads.

JSON with photo ids you uploaded before:

curl https://api.faceninja.net/v1/heads \
  -H "Authorization: Bearer $FACENINJA_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7f1c2c4e-3a7b-4b8e-9d0e-2a51c6f0b1aa" \
  -d '{
    "photo_ids": ["ph_9QYt3m1Lc0aZx7RfWk2B", "ph_b2RkQ0p7wE5nVt1sYc8M"],
    "name": "Captain Vera",
    "options": { "formats": ["glb", "fbx", "metahuman_zip"], "arkit": true },
    "webhook_url": "https://api.example.org/faceninja-events",
    "webhook_secret": "a-long-random-string-you-keep"
  }'

Multipart with the files themselves: repeat the photos part once per photo, in order (a text part may carry a photo id instead), and send options as plain fields: formats=glb,fbx, arkit=true.

fielddescription
photo_ids / photosrequired1–15 photos of the same person, main photo first. JSON: an array of ids. Multipart: file parts and/or id text parts.
nameYour label, up to 120 characters. Used in download file names.
options.formatsWhich download formats to build: any of glb, fbx, obj, stl, ply, metahuman_zip. Default: all six. The price is the same either way.
options.arkitDefault true: GLB and FBX come in two variants, with and without the 52 ARKit blendshapes. false builds only the static ones.
options.groomDefault true: a groom recipe (MetaHuman hair, brows, beard and moustache picks with colour values).
options.hair_previewDefault true: a light hair mesh for previews (hair_preview_glb).
options.stubblekeep (default) leaves short stubble in the skin texture; none cleans it off.
webhook_urlA public HTTPS URL that gets head.completed or head.failed for this head. See webhooks.
webhook_secret16–200 characters. When set, deliveries to webhook_url are signed with it. It is never returned.

?dry_run checks the request (photos, options, balance) and returns a quote (object: "head_quote") without reserving or storing anything.

202 Accepted (Retry-After: 10)
{
  "id": "hd_AzstNH0Hq8lfdiiHFmPP",
  "object": "head",
  "name": "Captain Vera",
  "status": "generating",
  "stage": "reference",
  "progress": 1,
  "message": "Starting",
  "queue": { "position": 1, "agents_online": 1, "builder": "online", "builder_note": null, "start_s": 60, "eta_s": 420 },
  "photo_count": 2,
  "options": { "formats": ["glb", "fbx", "metahuman_zip"], "arkit": true, "stubble": "keep", "groom": true, "hair_preview": true },
  "credits": { "cost": 1500, "state": "held" },
  "reference_ready": false,
  "files": [],
  "assets": [],
  "error": null,
  "source": "api",
  "created_at": "2026-10-23T14:05:00.122Z",
  "updated_at": "2026-10-23T14:05:00.122Z",
  "completed_at": null,
  "files_expire_at": null,
  "files_purged": false
}
GET/v1/heads/{id}#

Read a head, or wait for it

Returns the head object. With ?wait=N (seconds, at most 60; ?wait alone means 60) the request is held until the head completes or fails, or until N seconds pass, and then answers with the head as it is: check status and call again. While a head is running, responses carry Retry-After as a polling hint.

GET/v1/heads#

List heads

Newest first. limit 1–100 (default 20), status to filter, cursor from the previous page's next_cursor (null on the last page). List entries leave out files and assets; read a head for those. Deleted heads are listed only with status=deleted.

200 OK
{ "object": "list", "data": [ { "id": "hd_AzstNH0Hq8lfdiiHFmPP", "object": "head", "status": "completed", "…": "…" } ], "next_cursor": "1792764300122.hd_AzstNH0Hq8lfdiiHFmPP" }
GET/v1/heads/{id}/events#

Follow a head as it changes (Server-Sent Events)

The first event is head with the whole head. Then status events (a new status) and progress events (stage, progress, message) follow as they happen, with a : ping comment every 15 seconds. When the head completes or fails you get a last head event and the stream ends: do not reconnect then. Otherwise the stream ends after about 55 seconds; reconnect with the Last-Event-ID header (or ?last_event_id=) and you get what you missed.

Browsers' EventSource cannot send an Authorization header, so follow events from your server (or with a fetch-based client). Studio uses the same stream with its sign-in cookie.

the stream
retry: 3000

id: 4
event: head
data: {"id":"hd_AzstNH0Hq8lfdiiHFmPP","status":"queued","queue":{"position":2,"agents_online":1},…}

id: 5
event: status
data: {"status":"processing","stage":"starting","attempt":1}

id: 6
event: progress
data: {"stage":"shapes","progress":31,"message":"Generating 3D shapes"}
DELETE/v1/heads/{id}#

Delete a head

Deletes the head's photos, reference, files and previews from storage, and returns the head with status: "deleted". A head that had not finished stops, and its credits are returned.

The head object

idhd_…
status, stage, progress, messageSee what happens to a head. progress is 0–100 and null for failed or deleted heads.
queueWhile generating or queued: {position, agents_online, builder, builder_note, start_s, eta_s}. builder is online, busy or offline; builder_note says why it is busy (building, other_work, paused, updating); start_s and eta_s are estimates in seconds until the build starts and until the head is ready, from the line and the builders' recent build times (null while no builder takes work). Otherwise null.
reference_readytrue once the neutral reference portrait exists (download it as reference).
filesThe download files of a completed head: [{format, arkit, bytes, sha256, label}].
assetsPreview files: [{kind, bytes}]; see formats.
credits{cost, state}, state held → captured or released.
error{code, message} for a failed head, else null.
options, name, photo_count, sourceWhat you asked for; source is api or studio.
created_at, updated_at, completed_atISO 8601, UTC.
files_expire_at, files_purgedWhen the photos and files will be (or were) deleted; see retention.

Files#

Formats

Every head is built in all the formats you asked for at once, so a link is ready immediately and any number of downloads is included. The head mesh uses MetaHuman topology with neck and shoulders, plus eyes and teeth; textures are 4K.

formatwhat you get
glb (arkit true / false)Binary glTF with embedded PBR textures and clear corneas over the eyes; Y-up, 1 unit = 10 cm, life size. The ARKit variant carries all 52 ARKit blendshapes, tongueOut included (about 105 MB; static about 65 MB).
fbx (arkit true / false)FBX in centimetres, Y-up, MetaHuman vertex order and quads, textures embedded; the ARKit variant has the 52 blendshapes as shape keys. For Unreal, Maya, Blender.
metahuman_zipA ZIP for MetaHuman Creator: bring the head in and conform a MetaHuman to it, with the skin maps (PNG, 4K, normal map in DirectX convention, as Unreal expects).
objA ZIP with the OBJ (MetaHuman vertex order, quads; 1 unit = 10 cm), its MTL and the textures.
stlA mesh in millimetres, Z-up, life size, for printing previews. It is open at the neck, not watertight.
plyA triangle mesh with vertex colours, MetaHuman vertex order.

Preview assets (Studio shows them; you can fetch them the same way, by kind):

referenceThe neutral reference portrait, available from reference_ready on.
preview_glb, hair_preview_glbA light web GLB of the head (ARKit blendshapes, meshopt-compressed, WebP textures) and a hair preview mesh in the same space.
groom_json, groom_md, groom_sheetThe groom recipe: MetaHuman hair, eyebrow, beard and moustache choices with reasons and Melanin / Redness / Whiteness values; as JSON, as text and as an image with swatches.
compare, render_front, unreal_sheetYour photo next to renders of the head, a front render, and (when made) the head as a MetaHuman in Unreal Engine.

Get a download link

Body (JSON or form fields): format (a format or an asset kind), optional arkit (true / false, for glb and fbx; leave it out for the variant matching options.arkit), optional redirect (true: answer 302 with the link in Location, so curl -L downloads in one call). GET /v1/heads/{id}/files/{format}?arkit=&redirect= does the same.

The link needs no key, supports Range, and must be started within 60 seconds; a download in progress finishes. Store the head id, not the link, and ask again whenever you need the file.

200 OK
{
  "object": "file_link",
  "format": "glb",
  "arkit": true,
  "url": "https://…signed…",
  "expires_at": "2026-10-23T14:25:43.000Z",
  "bytes": 101583120,
  "sha256": "26e96b8b118821d2dd9e938662e27834f71a9d440d1da4520ab71c49d559fba1",
  "filename": "Captain_Vera-glb-arkit.glb"
}

Before the head completes: 425 head_not_ready (except reference); a failed head: 409 head_failed; after retention: 410 files_expired.

Download options

Add any of these fields to the same request (body, a nested options object, or query parameters on the GET form) to get the file in another form. Leaving a field out means its default, which is the file the build already made, so it is ready at once.

fieldvalues (default first)applies to
texturejpg: colour maps as JPEG, normal, roughness and specular maps as PNG (lossy compression would bend the normals) · png: every map PNG · none: no maps; each material keeps its map's average colour and roughness as plain valuesglb, obj, fbx
normal_mapopengl (green up, glTF's convention) · directx (green down, for engines and tools that expect it). A glTF viewer lights a DirectX map's relief inverted, so use it only where your tool wants itglb, obj, fbx
texture_size4k (as built: skin 4096², teeth 2048², eyes 1024²) · 2k · 1k: no map bigger than thatglb, obj, fbx
corneablend · transmission · none: see eyesglb (none also obj, fbx)
scaletemplate (life size, see scale) · iris: the geometry multiplied by the size estimate from the iris in your main photoglb, obj, fbx, stl, ply

Each option set is prepared once, on its first request, and then kept with the head's other files (same retention). While it is being prepared the answer is 202 with Retry-After; ask again (or pass wait, up to 25 seconds) and you get the usual file_link. Preparing takes from a few seconds to about a minute, depending on the format and on our builders' queue. Downloads stay included in the head's price. The MetaHuman ZIP takes no options: it is always PNG, DirectX normal map, 4K, template scale.

curl: a GLB with PNG maps, DirectX normal maps, 2K
curl -s https://faceninja.net/v1/heads/hd_AzstNH0Hq8lfdiiHFmPP/files \
  -H "Authorization: Bearer $FACENINJA_KEY" -H "Content-Type: application/json" \
  -d '{"format": "glb", "texture": "png", "normal_map": "directx", "texture_size": "2k", "wait": 25}'
202 Accepted (still being prepared; Retry-After: 15)
{
  "object": "file_variant",
  "id": "fv_Yw8u2nQd0e1KxR5tL7aB",
  "format": "glb",
  "arkit": true,
  "variant": "png-directx-2k-blend-template",
  "options": { "texture": "png", "normal_map": "directx", "texture_size": "2k", "cornea": "blend", "scale": "template" },
  "status": "preparing",
  "retry_after": 15
}

When it is ready, the same request returns the file_link above with options and variant added, and a file name that says what it is (…-glb-arkit-png-directx-2k.glb). A non-default value for a format it does not apply to is 400 field_not_applicable; a variant our builder could not make is 422 variant_failed (asked again after 10 minutes, it is tried again); a head keeps at most 24 prepared variants (429 too_many_variants).

Blendshapes and eyes

GLB and FBX with arkit carry all 52 blendshapes of Apple's ARKit face tracking, under ARKit's own names, so Live Link Face, ARKit and other ARKit-driven rigs animate the head directly. They were made from the MetaHuman face rig with Epic's own ARKit mapping and moved onto your face. tongueOut moves only the tongue; like a captured face, use it together with jawOpen (about 0.3) or the tongue passes through closed lips. At rest the tongue stays inside the mouth.

Each eye has a clear cornea: a transparent shell 0.2 mm over the eyeball that turns with it for every gaze shape and gives the eyes their reflections. No single glTF material shows such a shell well in every renderer, so you can choose:

cornea=blend (default)A 5 % blended shell whose reflection is raised by KHR_materials_specular to a real cornea's. Blender (Cycles and EEVEE, also the viewport) shows clear reflective corneas; a viewer without the extension shows a nearly invisible shell. three.js blends after tone mapping and shows little of the reflection.
cornea=transmissionKHR_materials_transmission with KHR_materials_ior 1.41: right in three.js, model-viewer, Babylon.js and Cycles. Blender EEVEE needs ray tracing on and the material's Thickness at 0, or the eyes look dark; a viewer without transmission shows white eyes.
cornea=noneNo corneas (four primitives: head, two eyes, teeth).

In three.js you can also keep the default file and switch the two cornea materials at load time: replace each material whose name starts with Cornea with a MeshPhysicalMaterial with transmission: 1, roughness: 0, ior: 1.41. Studio's viewer does this.

Scale and size

Every file is in real units: GLB, OBJ and PLY in units of 10 cm (multiply by 0.1 for metres), FBX and the MetaHuman ZIP in centimetres, STL in millimetres. The head is life size at the size of MetaHuman's default adult head (pupils 64 mm apart, the adult average is about 63 mm). Its proportions are the person's; its overall size is not measured from the photos, because a photo alone does not show how big someone is.

Every GLB says so in its extras, under faceninja_scale (in three.js gltf.scene.userData.faceninja_scale, in Blender a custom property of the scene), together with an estimate of the person's own size: iris_estimate.k is how much bigger (above 1) or smaller the person's head is than the file, from the iris in your main photo (an adult iris is about 11.7 mm across, give or take 0.5 mm), with confidence normal or low (a turned head, squinting, glasses). It is only reported; ask for scale=iris if you want the geometry multiplied by it.

How good the estimate is: on renders of our own heads, where the true size is known, it was within −7 % to +10 % for front views (50 mm lens at 50 cm, 85 mm at 1 m) and off by up to +22 % when the head was turned 20° (reported as low). Add the natural spread of iris size (about ±4 %), and expect roughly ±10 % on a sharp front photo with open eyes. Use it as a hint, for example to put a head next to a body of known height, not as a measurement.

Account and webhooks#

GET/v1/account#

Balance and prices

200 OK
{
  "object": "account",
  "id": "usr_7tq0WbKm2Lr9Zc1E",
  "email": "you@studio.example",
  "credits": 6000,
  "held_credits": 1500,
  "prices": { "currency": "USD", "head": 1500, "credits_per_usd": 100, "file": 0, "price_per_head": 15, "signup_credits": 1500,
              "packs": [{ "id": "heads5", "name": "Batch", "credits": 7500, "heads": 5, "price": 75, "featured": true }],
              "custom": { "id": "custom", "min": 15, "max": 5000, "step": 5, "credits_per_step": 500 },
              "referral_percent": 10, "confirmed": true, "note": "Prices are in US dollars. Sales tax or VAT is added at checkout where it applies." },
  "created_at": "2026-10-01T09:12:44.000Z"
}

credits can be spent now; held_credits are reserved by heads still being built. prices.head is the credits one head costs; buying credits happens in the Studio (Paddle checkout), not through the API.

Webhooks

Two ways to be told when a head is done, and you can use both:

  • Per head: webhook_url (and webhook_secret) on create. That URL gets head.completed or head.failed for that head.
  • Per account: endpoints you register with POST /v1/webhooks {"url", "events"}, list with GET /v1/webhooks (with the last deliveries) and remove with DELETE /v1/webhooks/{id}. Events: head.completed, head.failed (the default pair) and head.reference_ready. Each endpoint gets its own signing secret (whsec_…), shown once. At most five per account; Studio → API keys manages them too.
a delivery
POST /hooks/faceninja HTTP/1.1
Content-Type: application/json
User-Agent: FaceNinja-Webhooks/1
FaceNinja-Event: head.completed
FaceNinja-Delivery: whd_V1x8m2Qp0aLr6tYc
FaceNinja-Signature: t=1792764941,v1=5f0c4e…

{"id":"evt_Hq2s8Lk0Zx4mVb7N","type":"head.completed","created_at":"2026-10-23T14:15:41.000Z","data":{"id":"hd_AzstNH0Hq8lfdiiHFmPP","object":"head","status":"completed","files":[…],"…":"…"}}

data is the head object (for head.reference_ready: {id, status}). Answer any 2xx within 10 seconds and do the work afterwards. Anything else, or no answer, is retried after 1 minute, 5 and 30 minutes, 2, 6 and 12 hours (about 20 hours in all), then given up. The same event can arrive twice: use id (the event) or FaceNinja-Delivery (this delivery) to ignore repeats. Webhook URLs must be public HTTPS.

Checking signatures

FaceNinja-Signature is t=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256 with your secret over <t>.<raw request body>. Compute it over the exact bytes you received (before parsing JSON), compare in constant time, and refuse timestamps more than five minutes old.

Node
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(rawBody, header, secret, toleranceS = 300) {
  const parts = Object.fromEntries(String(header).split(',').map((p) => p.split('=')));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceS) return false;
  const want = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
  const got = Buffer.from(parts.v1 || '', 'hex');
  return got.length === want.length && timingSafeEqual(got, want);
}
Python
import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str, tolerance_s: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t = int(parts.get("t", "0"))
    if not t or abs(time.time() - t) > tolerance_s:
        return False
    want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, parts.get("v1", ""))

Idempotency

Send Idempotency-Key: <a unique value per intended head> (up to 255 characters; a UUID is ideal) with POST /v1/heads. For 24 hours, sending that request again under that key answers 200 with the head it already made, instead of making and charging a second one, even though its photos now belong to that head. The same key with a different request is 409 idempotency_conflict; so is a repeat that arrives while the first is still being accepted (retry after Retry-After). A request that failed (for example 402) does not use the key up.

Retention and privacy

  • Photos that no head uses are deleted after 24 hours.
  • When a head finishes, its photos, reference and files are kept for 30 days (files_expire_at), then deleted from storage. The head's record (status, options, dates) stays so your history still makes sense.
  • DELETE /v1/heads/{id} deletes them at any time.
  • To make a head, the photos are sent to the image and 3D generation models we use. See the privacy policy.
  • Only send photos of yourself or of adults who agreed to it (acceptable use).