Contents

Guide Migration

Moving from another provider

If you already call a photo-to-MetaHuman API, such as KeenFace's v2 API, your integration keeps its shape here: create a head, wait for it, ask for a file, follow a short-lived signed link. Names and a few details differ; this page lists them.

  • Same workflow, new paths: /v1/… on https://api.faceninja.net, with a FaceNinja API key.
  • No file preparation step. All formats are built with the head, so asking for a file returns a link at once. There is nothing to poll after the head is done.
  • More formats, all included: GLB, FBX, OBJ, STL, PLY and the MetaHuman ZIP, with ARKit blendshapes in GLB and FBX.
  • Extras a head already carries: a groom recipe (MetaHuman hair, brows, beard, with colour values), a hair preview and a web preview GLB.
  • 1,500 credits (USD 15) per head, every format included. Failed heads are refunded automatically.
Not here yet: scan mode.

We build heads from ordinary photos only. There is no calibrated multi-view scan reconstruction, no solved camera data and no raw scan texture. If you depend on those, keep that part of your pipeline where it is for now.

Call by call#

what you doKeenFace v2 (as documented by them)FaceNinja v1
Upload a photo firstPOST /v2/photos → ph_…POST /v1/photos (raw image body) → ph_…, or POST /v1/photos/uploads for signed direct uploads
Create a headPOST /v2/heads, multipart photos parts or JSON photo idsPOST /v1/heads: multipart photos parts (files or id text) the same way; JSON uses photo_ids (photos is accepted too)
Check without charging?dry_run?dry_run → object: "head_quote"
WaitGET /v2/heads/{id}?wait=…GET /v1/heads/{id}?wait=60: at most 60 s per call, so loop until completed or failed
Live updatesGET /v2/heads/{id}/events (SSE)GET /v1/heads/{id}/events (SSE): head, status, progress events; reconnect with Last-Event-ID
Get a fileGET /v2/heads/{id}/model with format options; repeat on its retry event until it sends a linkPOST /v1/heads/{id}/files {"format", "arkit"} → a link right away (or GET /v1/heads/{id}/files/{format})
Downloadthe signed link, no API keythe signed link, no API key, valid 60 s to start
List, deleteGET /v2/heads, DELETE /v2/heads/{id}, DELETE /v2/photos/{id}GET /v1/heads (cursor pages), DELETE /v1/heads/{id}, DELETE /v1/photos/{id}
BalanceGET /v2/accountGET /v1/account: credits, held_credits, prices
Retry safetyIdempotency-Key on createIdempotency-Key on create, same rules: 24 h, a different body under the same key is 409 idempotency_conflict

Ids: ph_… for photos, hd_… for heads. Head statuses are generating, queued, processing, then completed or failed: if your code only looks for the two final states, it needs no change.

Asking for files#

The biggest simplification. The files the head was built with are ready at once, so for them you ask once instead of asking for a variant and polling until it has been prepared:

you wantsend
GLB without blendshapes{"format": "glb", "arkit": false}
GLB with ARKit blendshapes (all 52, tongueOut included){"format": "glb", "arkit": true}
OBJ (ZIP with MTL and textures){"format": "obj"}
The MetaHuman ZIP{"format": "metahuman_zip"}
New here: FBX, STL, PLY{"format": "fbx", "arkit": true}, {"format": "stl"}, {"format": "ply"}

Textures are part of the file (4K PBR maps, colour as JPEG, normal map in OpenGL convention). A texture switch maps to the download options: "texture": "jpg" | "png" | "none", plus "normal_map": "opengl" | "directx" and "texture_size": "1k" | "2k" | "4k" for GLB, OBJ and FBX. A set other than the default is prepared on its first request: you get 202 with Retry-After until it is ready (or pass "wait": 25), then the same link answer; after that it is ready at once. Add "redirect": true to get a 302 to the file instead of JSON. Links last 60 seconds: ask for a new one each time rather than storing it.

Scale: our files are in real units and life size (GLB 1 unit = 10 cm), at the size of an average adult head; each GLB also reports an estimate of the person's own size from the iris in the photo (scale and size).

Options and modes#

  • Head options live in options (JSON) or as plain form fields (multipart): formats, arkit, groom, hair_preview, stubble. Unknown fields are refused with 400, so remove options that have no counterpart here instead of passing them through.
  • Scan-mode fields (calibrated captures, focal length, camera output, raw texture) have no counterpart and are refused.
  • Photo limits: 1–15 photos, JPEG, PNG, WebP, HEIC/HEIF or AVIF, up to 25 MiB each. HEIC/HEIF and AVIF are converted on our side.
  • Waiting: ?wait is capped at 60 seconds per call. Long single waits become a loop.

Errors and retries#

Errors have the familiar {"error": {"code", "message", "field"?, …, "request_id"}} shape; the optional advice is called hint here. The codes you are likely to branch on keep their names: invalid_request, insufficient_credits (402), head_not_ready (425), head_failed (409), variant_not_available, idempotency_conflict, rate_limited (429, with Retry-After), not_found. A failed head's error.code is one of no_face, multiple_people, not_same_person, content_rejected, generation_failed, timeout, internal; see the reference.

Webhooks#

webhook_url and webhook_secret on create work as you expect: one POST when the head completes or fails. Two things to check:

  • The event names are head.completed and head.failed, and the payload is {"id", "type", "created_at", "data": <head>}.
  • The signature is in FaceNinja-Signature: t=…,v1=…, an HMAC-SHA256 over "<t>.<raw body>". Update your verification code from the reference.

You can also register account-wide endpoints with POST /v1/webhooks, including a head.reference_ready event.

Before and after#

The usual integration, in Python. The only real change is the file step: no preparation loop.

before: ask for a variant, poll until prepared
link = None
while link is None:
    ev = get_model(head_id, mesh_format="glb", blendshapes="arkit")   # provider-specific events
    if ev.kind == "retry":
        time.sleep(ev.seconds)
    else:
        link = ev.url
download(link)
after: FaceNinja
link = s.post(f"{API}/v1/heads/{head_id}/files", json={"format": "glb", "arkit": True}).json()
download(link["url"])

Checklist#

  1. Make a key in Studio → API keys and point your client at https://api.faceninja.net/v1.

  2. Rename the JSON photo list to photo_ids (multipart photos parts need no change), and drop options we do not have.

  3. Replace the file step with one POST /v1/heads/{id}/files.

  4. Loop the wait in 60-second calls, or switch to a webhook.

  5. Update webhook verification to FaceNinja-Signature.

  6. Run one head with ?dry_run first, then a real one with your free credits.