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.
- 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
| status | meaning |
|---|---|
generating | An 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. |
queued | Waiting 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. |
processing | Being 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. |
completed | Every format and preview is ready; ask for files. |
failed | Final. error.code says why (see head errors). The credits are returned automatically. |
deleted | You 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:
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.glbconst 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()));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)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.
{
"error": {
"code": "invalid_request",
"message": "Unknown format \"gltf\". Use glb, obj, stl, ply, fbx, metahuman_zip.",
"field": "options.formats",
"request_id": "req_Qm3t0aW1bXcV9eLp"
}
}| HTTP · code | when, and what to do |
|---|---|
400 invalid_request | A 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_photos | A head takes 1 to 15 photos. |
400 photo_not_uploaded, photo_expired | A photo_ids entry has no bytes yet (PUT it first) or is older than 24 hours (upload it again). |
401 unauthorized | No key, a wrong key or a revoked key. |
402 insufficient_credits | Not enough credits for a head. Nothing was reserved; buy credits and retry. |
404 not_found | No such head, photo or route on your account. |
404 variant_not_available | The head has no file of that format and ARKit choice; hint lists what it has. |
400 field_not_applicable | A download option that the format does not take (for example texture on metahuman_zip). |
422 variant_failed | The file with these download options could not be made; asked again after 10 minutes, it is tried again. |
429 too_many_variants | A head keeps at most 24 prepared download variants. |
409 idempotency_conflict | See idempotency. |
409 head_failed, photo_in_use | A failed head has no files; a photo that belongs to a head is deleted with the head. |
410 files_expired | The head's files were deleted after the retention period. |
| 411 · 413 · 415 | length_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_ready | You asked for a file before the head completed. Wait for it (Retry-After is set). |
429 rate_limited | Slow down; retry after Retry-After seconds. |
500 internal_error, 503 service_unavailable | Our 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.code | meaning |
|---|---|
no_face | No usable face: no readable image, no face found, or the face is too small. See the photo guidelines. |
multiple_people | The main photo shows more than one person. |
not_same_person | The photos do not all show the same person. |
unreadable_photo | A photo could not be decoded (a damaged file, or a format we do not read). Export it again as JPEG. |
content_rejected | A photo broke our content rules (nudity, an apparent minor) or the image model refused it. |
generation_failed | The reference portrait or the 3D shapes could not be made from these photos. Try other photos. |
timeout | The head took too long or waited too long in line. Retrying later usually works. |
internal | Something broke on our side. Retry; quote the head id if it repeats. |
Limits#
| Photos per head | 1 to 15, of one person. The first is the main photo. |
|---|---|
| Photo files | JPEG, 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 create | Up to 60 MB in one request. For more, upload the photos first (upload slots) and create with photo_ids. |
| Rate limits | 600 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. |
| Links | Download links work for 60 seconds (a started download finishes); upload links for an hour. |
| Unused photos | Deleted 24 hours after upload unless a head uses them. |
| Finished heads | Photos 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.
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{
"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"
}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{
"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"
}
}]
}Read a photo
Returns the photo object. status is awaiting_upload, ready, attached (a head uses it; head_id says which) or deleted.
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#
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.
| field | description |
|---|---|
photo_ids / photosrequired | 1–15 photos of the same person, main photo first. JSON: an array of ids. Multipart: file parts and/or id text parts. |
name | Your label, up to 120 characters. Used in download file names. |
options.formats | Which download formats to build: any of glb, fbx, obj, stl, ply, metahuman_zip. Default: all six. The price is the same either way. |
options.arkit | Default true: GLB and FBX come in two variants, with and without the 52 ARKit blendshapes. false builds only the static ones. |
options.groom | Default true: a groom recipe (MetaHuman hair, brows, beard and moustache picks with colour values). |
options.hair_preview | Default true: a light hair mesh for previews (hair_preview_glb). |
options.stubble | keep (default) leaves short stubble in the skin texture; none cleans it off. |
webhook_url | A public HTTPS URL that gets head.completed or head.failed for this head. See webhooks. |
webhook_secret | 16–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.
{
"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
}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.
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.
{ "object": "list", "data": [ { "id": "hd_AzstNH0Hq8lfdiiHFmPP", "object": "head", "status": "completed", "…": "…" } ], "next_cursor": "1792764300122.hd_AzstNH0Hq8lfdiiHFmPP" }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.
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 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
id | hd_… |
|---|---|
status, stage, progress, message | See what happens to a head. progress is 0–100 and null for failed or deleted heads. |
queue | While 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_ready | true once the neutral reference portrait exists (download it as reference). |
files | The download files of a completed head: [{format, arkit, bytes, sha256, label}]. |
assets | Preview 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, source | What you asked for; source is api or studio. |
created_at, updated_at, completed_at | ISO 8601, UTC. |
files_expire_at, files_purged | When 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.
| format | what 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_zip | A 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). |
obj | A ZIP with the OBJ (MetaHuman vertex order, quads; 1 unit = 10 cm), its MTL and the textures. |
stl | A mesh in millimetres, Z-up, life size, for printing previews. It is open at the neck, not watertight. |
ply | A triangle mesh with vertex colours, MetaHuman vertex order. |
Preview assets (Studio shows them; you can fetch them the same way, by kind):
reference | The neutral reference portrait, available from reference_ready on. |
|---|---|
preview_glb, hair_preview_glb | A 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_sheet | The 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_sheet | Your 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.
{
"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.
| field | values (default first) | applies to |
|---|---|---|
texture | jpg: 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 values | glb, obj, fbx |
normal_map | opengl (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 it | glb, obj, fbx |
texture_size | 4k (as built: skin 4096², teeth 2048², eyes 1024²) · 2k · 1k: no map bigger than that | glb, obj, fbx |
cornea | blend · transmission · none: see eyes | glb (none also obj, fbx) |
scale | template (life size, see scale) · iris: the geometry multiplied by the size estimate from the iris in your main photo | glb, 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 -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}'{
"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=transmission | KHR_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=none | No 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#
Balance and prices
{
"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(andwebhook_secret) on create. That URL getshead.completedorhead.failedfor that head. - Per account: endpoints you register with
POST /v1/webhooks{"url", "events"}, list withGET /v1/webhooks(with the last deliveries) and remove withDELETE /v1/webhooks/{id}. Events:head.completed,head.failed(the default pair) andhead.reference_ready. Each endpoint gets its own signing secret (whsec_…), shown once. At most five per account; Studio → API keys manages them too.
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.
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);
}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).