Enrolling a face
A two-step upload, and why the bytes never pass through the API.
Upload a recording, attest that the person agreed to it, and get back a face the agent can wear. Three requests, and the middle one does not touch the API.
The two-step upload#
POST /public/avatars → creates the row, returns a signed upload URL
PUT <that URL> → the browser sends the bytes straight to storage
POST /public/avatars/<id>/uploaded → we ask storage whether the object is really there
The obvious single-step design — post the file to the API, the API writes it to storage — holds a worker process for the whole transfer. On a phone on hotel wifi, a two-minute recording can occupy a worker for a minute, and a few concurrent uploads starve the API. The bytes have no reason to pass through it.
The cost of the split is that step two can be skipped: a browser can take a
ticket and never PUT. That is why nothing trusts status alone — the confirm
step asks storage whether the object exists rather than believing the client, and
an avatar that is never confirmed stays pending and is never callable.
Confirming before the PUT finished returns 409 with a hint, and leaves the row
alone. It is a recoverable state, not a failure: retry the PUT with the same
ticket and confirm again. Marking it failed would strand a perfectly good
upload.
Record video, not a photograph#
Accepted: video/mp4, video/webm, video/quicktime, and — for a lesser
result — image/jpeg, image/png, image/webp. Video up to 1 GB, images up to
12 MB.
This was images only at first, on the argument that the renderer needs a single still face and accepting video "only moves the failure later". Measurement inverted that. A still photograph is the one input that provably cannot reach the quality bar — every attempt to animate one was built and then reverted, and the verdict on the last of them was: the eyes are moving around crazy, not natural. HeyGen reaches the same conclusion from the other direction, requiring a continuous two-minute recording for a real-time avatar and offering photos only as a lesser tier.
So video is the path, and the failure that was feared would move later has moved to where it belongs: an acceptance check on the recording itself.
What makes a good take: two minutes, one person, face in frame throughout, even lighting, natural speech. The failures the enrolment worker reports are almost exactly the inverse of that list.
Creating one#
curl -sX POST "$LIVEGRID_API_URL/public/avatars" \
-H "Authorization: Bearer $CUSTOMER_JWT" \
-H 'content-type: application/json' \
-d '{
"display_name": "Dana — support",
"content_type": "video/mp4",
"consent": {
"subject_name": "Dana Okafor",
"subject_email": "dana@example.com",
"attestation": "Dana recorded this on 2026-09-08 and agreed to its use on customer calls."
}
}'
consent.subject_name and consent.attestation are required, and a request
without them is refused rather than defaulted. An avatar with no named subject is
a face nobody has claimed responsibility for, and the moment to catch that is
before it is stored — not after it has been on a sales call.
client_ref is optional and opaque to LiveGrid. It is what makes one reseller's
customers separable for reporting and — the part that matters — erasable
together when that customer leaves.
What happens next#
The recording is claimed by a GPU box, which enrols it and reports back. Two things about that fence are worth knowing:
- The GPU box never holds a database credential. It claims work and reports results over HTTP. Its whole job is running third-party model code downloaded from the internet, and a Postgres connection string on that machine is a bad trade for one saved round trip.
- A claim is an atomic update, not a select-then-update. Two boxes racing for the same row means exactly one wins and the loser gets nothing — otherwise both spend twenty GPU-minutes on the same face.
Poll GET /public/avatars/<id> until status is ready or failed. Full
shapes on Avatars API.