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/…onhttps://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.
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 do | KeenFace v2 (as documented by them) | FaceNinja v1 |
|---|---|---|
| Upload a photo first | POST /v2/photos → ph_… | POST /v1/photos (raw image body) → ph_…, or POST /v1/photos/uploads for signed direct uploads |
| Create a head | POST /v2/heads, multipart photos parts or JSON photo ids | POST /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" |
| Wait | GET /v2/heads/{id}?wait=… | GET /v1/heads/{id}?wait=60: at most 60 s per call, so loop until completed or failed |
| Live updates | GET /v2/heads/{id}/events (SSE) | GET /v1/heads/{id}/events (SSE): head, status, progress events; reconnect with Last-Event-ID |
| Get a file | GET /v2/heads/{id}/model with format options; repeat on its retry event until it sends a link | POST /v1/heads/{id}/files {"format", "arkit"} → a link right away (or GET /v1/heads/{id}/files/{format}) |
| Download | the signed link, no API key | the signed link, no API key, valid 60 s to start |
| List, delete | GET /v2/heads, DELETE /v2/heads/{id}, DELETE /v2/photos/{id} | GET /v1/heads (cursor pages), DELETE /v1/heads/{id}, DELETE /v1/photos/{id} |
| Balance | GET /v2/account | GET /v1/account: credits, held_credits, prices |
| Retry safety | Idempotency-Key on create | Idempotency-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 want | send |
|---|---|
| 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 with400, 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:
?waitis 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.completedandhead.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.
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)link = s.post(f"{API}/v1/heads/{head_id}/files", json={"format": "glb", "arkit": True}).json()
download(link["url"])Checklist#
Make a key in Studio → API keys and point your client at
https://api.faceninja.net/v1.Rename the JSON photo list to
photo_ids(multipartphotosparts need no change), and drop options we do not have.Replace the file step with one
POST /v1/heads/{id}/files.Loop the wait in 60-second calls, or switch to a webhook.
Update webhook verification to
FaceNinja-Signature.Run one head with
?dry_runfirst, then a real one with your free credits.