Zeli AvatarDeveloper docs

API reference / Avatars

Avatars

An avatar is a face this service can render, prepared once and then streamed many times. Creating one is a paid, minutes long build, so the create routes answer immediately and the outcome arrives through a separate read. Reading your library works whether or not a GPU is running, because the library and the machine are deliberately not the same thing.

Two avatar lists, on two planes, with different shapes

GET /auth/avatars is your library, from the always on portal service, and it is what a person should see. GET /api/avatars is what is loaded on this box right now, grouped for a picker, and it exists only while a box runs. They do not share a credential and they do not share a shape. Use the first for a library view and the second only when you are driving a live box.

MethodPathPurposePlane
GET/auth/avatarsYour library, from durable storagePortal
GET/auth/avatars/publicAvatars other developers publishedPortal
GET/auth/avatars/tonesThe emotion vocabulary an avatar can be built withPortal
GET/auth/avatars/{id}/assetsOne avatar's images and clips as expiring URLsPortal
GET/auth/avatars/{id}/clipsOne avatar's emotion clips and last build, with no box runningPortal session or full key
GET/auth/avatars/{id}/clips/{tone}/videoA short lived URL to play one tone's clip, with no box runningPortal session or full key
POST/auth/uploadsPresigned S3 uploads for a new avatar's filesPortal session or full key
POST/auth/seated-stillStart a seated version of a staged photo, with no box runningPortal session or full key
GET/auth/seated-still/{jobId}Poll it; the finished picture is a presigned URLPortal session or full key
PUT/auth/avatars/{id}/visibilityPublish one of yours, or take it backPortal
DELETE/auth/avatars/{id}Erase one of yours. Works with no box runningPortal session or full key
GET/api/avatarsWhat this box has loaded, grouped by identityBox, any
GET/api/videosThe same set, per variant, with sizes and statusBox, any
GET/api/avatar/previewOne avatar's still frame, as image bytesBox, any
POST/api/avatarUpload an image or a video and prepare itBox, full
POST/api/avatar/deleteDeprecated. Delete a prepared avatar from one boxBox, full
POST/v1/photo_avatar.createBuild an avatar from one photographBox, full
Call an avatar by its id. The name is only a label.

Every avatar has a unique avatar_id (id on the box listing), and that id is the only thing sessions, the SDKs, LiveKit joins and deletes accept. Every listing also carries display_name, the name a person gave it. Names are not unique: two avatars, even two of yours, can share one. Show the name, store and send the id. Avatars created before names existed keep the ids they had.

Your library

GET/auth/avatars

Takes no parameters. The acting identity is the storage partition: no path segment, header or body field names an owner, which is what makes another developer's library unreachable rather than merely filtered.

{
  "avatars": [
    {
      "avatar_id": "8f1d...",
      "display_name": "Acme host",
      "name": "Acme host",
      "source_kind": "photo",
      "state": "ready",
      "created_at": 1769900000,
      "updated_at": 1769903600,
      "prepared_in_region": "ap-southeast-2",
      "weights_volume": "vol-0abc...",
      "detail": "",
      "visibility": "private"
    }
  ]
}
FieldValuesNotes
avatar_idstringThe unique id. The only thing to call the avatar by
display_namestringThe label to show. Not unique. For an avatar that was never named it is the id read as words
namestringWhat is stored, kept for older clients. For an avatar that was never named it is the id itself
source_kindphoto, image, video, unknownWhat it was made from
statepreparing, ready, failedThe last state a box observed
visibilityprivate, publicAlways present, never inferred
prepared_in_region, weights_volumestringWhere that observation was made
state is a past observation, not a present claim

ready means a box finished preparing this avatar and said so. It does not mean the avatar is streamable right now: the volume may since have been rebuilt, or may be in another region, and most of the time there is no box at all. Fuse this with a live box reading, or say "prepared, box offline", which is the true sentence.

There are no URLs on the record. Images and clips come from the asset route below, because they are expiring signatures and do not belong in a cached list.

Published avatars

GET/auth/avatars/public

Avatars other developers deliberately shared. This is a separate partition, not the route above with a looser filter: a private avatar was never in the answer rather than being removed from it. Same item shape, plus one field:

{ "avatars": [{ "avatar_id": "8f1d...", "visibility": "public", "mine": false }] }

mine marks your own rows in the shared set. No email addresses are on the wire. A shared avatar is a shared face, not a shared address book.

Still session gated: this is a developer portal, not an open gallery, and "the people who sign in here" is exactly what the person publishing is told.

The emotion vocabulary

GET/auth/avatars/tones

The tones an avatar can be built with. Derived rather than stored, and served from the always on plane so a create form is never empty just because no GPU happens to be running.

{ "tones": ["neutral", "confident", "warm", "curious"] }

One avatar's media

GET/auth/avatars/{id}/assets

Images and clips as short lived presigned URLs, with no box involved: this reads storage and signs locally, so it starts nothing and spends nothing.

This is the only portal route whose path carries an id the caller chose, so ownership is an explicit gate rather than a property of the partition. A refusal is a 404.

{
  "avatar_id": "8f1d...",
  "assets": [
    {
      "kind": "image",
      "filename": "portrait.png",
      "size_bytes": 482913,
      "updated_at": 1769900000,
      "url": "https://..."
    }
  ],
  "expires_in": 900
}

kind is image or video. expires_in is stated rather than implied, so a client can refresh before a URL dies and a stale signature does not render as breakage.

One avatar's emotion clips

GET/auth/avatars/{id}/clips

The same body as the box's GET /api/avatar/clips?avatar=<id> (avatar, clips, source, framing, capabilities), answered from storage with no box involved, plus build (the last build's outcome, or null) and served_by: "portal". A refusal is a 404 with the code unknown_avatar, the same answer for an avatar that is not yours and one that does not exist. The box route still works for older clients; the portal asks this one first and falls back to the box.

Play one clip

GET/auth/avatars/{id}/clips/{tone}/video

A presigned S3 GET for the clip a tone plays, answered with no box involved. Every built clip is archived when it is made, so this works while the studio is off. neutral is the avatar's base face.

{
  "url": "https://…/zeli-avatar/presenter/videos/presenter__happy.mp4?X-Amz-…",
  "expires_in": 900,
  "expires_at": 1790000900,
  "tone": "happy",
  "updated_at": 1790000000.0
}

Point a <video> element straight at url: it needs no header and no cookie. The URL lives for 15 minutes and every call signs a new one, so ask again rather than keeping it (a regenerated clip is never served stale; the answer is Cache-Control: no-store). A shared avatar plays for anyone signed in, like its listing. A refusal is a 404 with the code unknown_avatar, the same answer for an avatar that is not yours, one that does not exist and a tone that was never built. 503 clip_video_unavailable means this environment has no media storage wired. The box's GET /api/avatar/clip?avatar=<id>&tone=<tone> still answers the bytes for older clients.

Upload the files first

POST/auth/uploads

Gets one presigned S3 PUT per file, so a new avatar's photo and recorded takes never travel through a box. Name each file with the multipart field it would have used on POST /api/avatar:

{
  "files": [
    { "field": "file", "filename": "me.png", "content_type": "image/png", "size": 482913 },
    { "field": "take__happy", "filename": "happy.webm", "content_type": "video/webm", "size": 2048000 }
  ]
}

A 201 answers with an upload_id and, per file, a url and the headers to send with it (the Content-Type is part of the signature). PUT each file, then call POST /api/avatar with the text field upload_id in place of the file part and every take__* part. Refusals: 400 invalid_upload, 413 upload_too_large, 503 uploads_unavailable.

A seated version of a photo

POST/auth/seated-still

Recomposes an uploaded photo as a seated, vertical picture of the same person, with no box involved. Stage the photo first with POST /auth/uploads (field file, a PNG or JPEG up to 15 MB), then start a job:

{ "upload_id": "<from /auth/uploads>", "note": "a lighter desk", "look": { "outfit": "smart_casual" } }

A 202 answers with a job_id. Poll it:

GET/auth/seated-still/{jobId}

state is pending, running, done or failed. When done, image_url is a presigned GET of the PNG (15 minutes) beside identity_checked and identity_score; when failed, error and message say why (not_the_same_person, bad_request, generation_failed). Another person's job, and one older than a day, is a 404 unknown_job. A 503 seated_still_unavailable means this environment has no seated still worker; the box's POST /api/avatar/seated-still still answers there.

Publish or withdraw

PUT/auth/avatars/{id}/visibility
visibilitystringRequired

public or private.

Returns { "avatar": {} }, the updated record in the shape above.

Publishing requires a separate agreement and withdrawing never does. Publishing a face is a decision about other people seeing it, so it takes its own recorded attestation; taking it back is unconditional and works even when the consent ledger is unreachable, because nothing should stand between a person and un publishing their own face.

StatuscodeWhen
404avatar_not_foundNot yours, or no such avatar. The same answer for both
409publication_consent_requiredPublishing without a recorded agreement
503publication_consent_unavailableThe ledger is not configured here
409, and deliberately not 403

A 403 from behind this host is rewritten by the CDN into the sign in page, so it would reach your client as HTML. This route answers 409 so the refusal stays readable as JSON. See the overview.

Erase an avatar

DELETE/auth/avatars/{id}

Served from the always on plane, which is the entire point: a face has to be removable at any hour, not only during the couple of hours a GPU happens to be up. Nothing here starts, wakes or even asks a box.

Answers 202, never 200, because part of the erasure has not happened yet. The body names what is gone and what is still owed:

{
  "avatar_id": "8f1d...",
  "status": "pending",
  "erased": ["registry_record", "public_listing", "archived_media"],
  "pending": ["prepared_avatar"]
}

status is pending or complete. The artefact names describe what a person loses, not which service holds it:

ArtefactWhat it is
registry_recordThe row your library lists from
public_listingThe row in the shared partition, if it was ever published
archived_mediaThe portrait and the generated clips
prepared_avatarThe prepared face on a GPU box's weights volume. The one artefact no always on service can reach, so it is recorded as a standing order that every box carries out on its own volume when it starts and every 15 minutes while it runs

Asking again repairs anything the first request could not clear and answers the same receipt. prepared_avatar stays in pending: there can be more than one weights volume, and no single box can see that every one of them is clean, so the order never expires. A 404 means not yours, or no such avatar.

Three avatar ids cannot be erased here

tones, public and publication-terms are exact sub resource paths under /auth/avatars/. An avatar whose id is one of those three literals answers 404 on this route and has to be deleted from the box instead. Ids minted today are random, so this only affects avatars whose id came from an uploaded filename.

What a box has loaded

GET/api/avatars

Grouped by identity, so a picker shows one entry per person and the tone variants resolve per reply.

{
  "avatars": ["01-presenter-male", "01-presenter-male__warm"],
  "groups": [
    {
      "id": "01-presenter-male",
      "display_name": "Presenter Male",
      "variants": ["01-presenter-male", "01-presenter-male__warm"],
      "tones": ["neutral", "warm"],
      "preview": "01-presenter-male",
      "stock": true
    }
  ],
  "preparing": [],
  "active": "01-presenter-male",
  "active_group": "01-presenter-male",
  "builds": {}
}
FieldMeaning
avatarsA flat list of every loaded variant id
groups[].idThe avatar's id, which is what to send when you pick it
groups[].display_nameIts label, for showing. Not unique
groups[].previewThe variant that actually has a still on disk, as an id and not a URL, because fetching it needs the key header and so cannot go in an <img src>
groups[].stocktrue when nobody uploaded this face. A box is shared and cannot ask who owns a face it found on the volume, so "nobody uploaded it" is the strongest claim it can make
preparingVariants still being prepared
buildsThe tone build journal, keyed by avatar
Why the build journal rides back on a list

An upload answers in milliseconds and a build takes minutes, so the upload response structurally cannot carry the outcome. A client is already polling this route while an avatar prepares, so the answer arrives on a request it was making anyway. See Avatar videos.

The per variant view

GET/api/videos

The same set, one row per variant, for a management panel rather than a picker:

{
  "videos": [
    {
      "id": "01-presenter-male__warm",
      "status": "ready",
      "kind": "video",
      "bytes": 8421904,
      "identity": "01-presenter-male",
      "emotion": "warm",
      "active": true,
      "deletable": true,
      "registry_note": null
    }
  ],
  "active": "01-presenter-male"
}

deletable is false for bundled avatars, which have no uploaded source. bytes is null for the same reason. registry_note is null on every ordinary avatar; it carries a plain sentence only when the avatar is ready on this box but a developer library cap kept it out of the portal library, so the one channel a background build has left can still say why.

A still frame

GET/api/avatar/preview

Takes ?avatar=<id> and returns image bytes, not JSON. Authenticated like every other box route, which is why a browser fetches it into a blob rather than pointing an <img src> at it: the alternative is a live credential in a query string, and that lands in access logs, referrer headers and history.

A 404 has two meanings worth telling apart by their message: an unknown avatar, or one that exists but has not been prepared yet. The second is worth retrying.

Create from an image or a video

POST/api/avatar

Multipart. Requires a full key.

filefileRequired

A PNG or JPG image, or an MP4, MOV or WEBM video. The part must be named file. Anything else is a 400. Not needed when upload_id is sent.

upload_idstringOptional

The id from POST /auth/uploads, once every file is uploaded. Sent instead of the file part and the take parts; the box fetches them from storage.

namestringOptional

The avatar's display name. When it is present the box creates a new avatar with a new unique id, whatever the name, so the same name twice is two avatars. Without it the id is derived from the filename, as it always was. Both SDKs always send one.

tonesstringOptional

Comma separated tones to build.

consent_tokenstringOptional

Evidence that a human attested to this likeness. Required for a photo source; see the consent ledger below.

framingstringOptional

Crop and framing for the generated clips.

gesture_amplitudestringOptional

How much the avatar moves.

take__<tone>fileOptional

Footage for ONE tone, one part per take: take__happy for the first, take__happy__1 for the next. The file must be a .webm, .mp4, .mov or .mkv: a take is looked up by extension and anything else is stored and never found. tones should name exactly the tones sent here, because a tone with no footage behind it is not built.

ai_generatedstringOptional

The uploader's declaration that a video source was made by AI and shows no real person. Send true (the box reads a closed list: true, 1, yes, y, on), or omit the field entirely. Absent, empty and unreadable all mean no.

curl -X POST "https://avatar.zeligate.ai/api/avatar" \
  -H "X-Api-Key: zsk_live_..." \
  -F "file=@acme-host.png" \
  -F "name=Acme host" \
  -F "tones=neutral,confident" \
  -F "consent_token=ct_..."

Response 200, immediately:

{
  "avatar": "3f2a9c1e-1234-4abc-8def-0123456789ab",
  "display_name": "Acme host",
  "status": "preparing",
  "tones": ["neutral", "confident"],
  "note": "",
  "active": "01-presenter-male"
}

status is preparing or ready. The returned tones are the ones actually coming, which may be fewer than you asked for, and note says why. Poll GET /api/avatar/clips for the outcome.

Keep the id the box answers with

avatar is the new avatar's id, and it is the only thing to call it by afterwards. With a name it is a new unique id every time. Without one it is derived from the filename, so two such uploads called portrait.png are the same avatar and the second replaces the first.

Three fields are read only when clips are actually planned

consent_token, framing and gesture_amplitude are read only when tone builds are actually planned. With photo avatar creation disabled on the box, or on a video upload that carries neither takes nor ai_generated, all three are accepted from the client and never used, and the response's note mentions none of them. Check GET /api/avatar/clips rather than assuming they applied.

A video keeps the motion it was filmed with

Emotion clips are not generated from footage. A video upload gets its tones one of two ways, and neither of them is the box inventing motion for a real person's face: send a take__<tone> part per tone, or declare the video is AI generated with ai_generated=true, which is the uploader's statement and is not verified. Without either, the response's tones comes back as the base idle face alone and note says so.

Create from one photograph

POST/v1/photo_avatar.create

The generated portrait pipeline. Off unless the box enables it; when it is off every call is a 501 with code feature_disabled. Requires a full key.

Unlike POST /api/avatar, this one is synchronous on the box and holds the connection until every clip is rendered, which can take minutes.

filefileRequired

The photograph. An image, or a video declared with ai_generated=true. A video with no declaration is a 400 naming the declaration.

avatar_idstringOptional

The id to create. Send this or name. An explicit id is kept unless somebody else already holds it.

namestringOptional

The avatar's display name. Sent without avatar_id, the box creates a new unique id for it.

tonesstringOptional

Comma separated tones to generate.

consent_tokenstringOptional

The attestation for this photograph.

framingstringOptional

Crop and framing.

gesture_amplitudestringOptional

Motion amount.

This is the second of the three routes that wraps its payload in data:

{
  "data": {
    "avatar_id": "acme-host",
    "clips": [
      {
        "tone": "neutral",
        "template": "idle",
        "path": "/var/avatars/acme-host/neutral.mp4",
        "duration_s": 4.0,
        "fps": 25,
        "loop_frame": 100,
        "avatar_id": "acme-host",
        "queued": true
      }
    ]
  }
}

avatar_id on each clip is the id that clip is served under: the bare id for neutral, and <avatar_id>__<tone> otherwise. queued says whether preparation was queued for it.

Delete from a box (deprecated)

POST/api/avatar/delete
Deprecated: use DELETE /auth/avatars/{id}

This route only works while a box is running, and only removes what that one box holds. DELETE /auth/avatars/{id} erases the library rows for every tone, the stored media, the recordings and the consent record at once, with or without a box, and leaves each box an order it carries out at its next erasure sweep. It takes a portal session or a full scope X-Api-Key. The portal and the Live Studio no longer call the box route; it is kept so older clients keep working.

A POST with the id in the body, not a DELETE with it in the path. Requires a full key.

avatarstringRequired

The avatar id.

{ "deleted": "acme-host", "active": "01-presenter-male", "videos": [] }

Only uploaded avatars can be deleted, never bundled ones: a 400 says so. The registry delete is itself the ownership proof, so there is no second check after it.

To erase an avatar when no box is running, and to erase the stored media and the consent record with it, use DELETE /auth/avatars/{id} above. That is the route to prefer.

A legal weight audit trail with no vendor equivalent. The owner is never in the body and cannot be: it is the resolved portal identity.

Attest to an uploaded likeness

POST/auth/consent
sha256stringRequired

The digest of the exact bytes the human looked at and agreed to.

source_kindstringOptionaldefault: image

What was attested to.

Response 201. The record carries no owner, because echoing an email into a response body is how one ends up in a log or a browser history entry:

{
  "consent": {
    "consent_id": "cs_01H...",
    "sha256": "9f86d0...",
    "attested_at": 1769900000,
    "source_kind": "image"
  }
}

Bound to the digest, so an attestation cannot be reused for different bytes.

Read the publication wording

GET/auth/avatars/publication-terms
{
  "terms": {
    "version": "avatar-publication#v1",
    "text": "...",
    "sha256": "4c1a..."
  }
}

Served so the exact paragraph a stored attestation names can be fetched and hashed by somebody who does not hold this repository. That is what turns "they agreed to avatar-publication#v1" from a label into a checkable claim.

Agree to publish one face

POST/auth/avatars/{id}/publication-consent

A separate act from the upload attestation: that one is about use and names no audience. A POST rather than a PUT, because each call records a new agreement about a new moment rather than overwriting one row per user.

terms_versionstringOptional

The wording version being agreed to.

Response 201:

{
  "publication_consent": {
    "attestation_id": "pa_01H...",
    "avatar_id": "8f1d...",
    "terms_version": "avatar-publication#v1",
    "terms_sha256": "4c1a...",
    "attested_at": 1769900000,
    "withdrawn_at": null
  }
}

Synthetic portraits

Four routes for the generate, look, approve loop that runs before any paid clip generation. A full avatar is many seconds of video per emotion, so the face is seen and accepted first.

MethodPathWhat it does
POST/auth/portraitsGenerate one portrait from a persona description. 201
GET/auth/portraits/{id}Fetch it back to look at
POST/auth/portraits/{id}/approveA human says yes. Bound to the byte digest
POST/auth/portraits/{id}/regenerateAnother attempt, as a new id. 201

Three of the four are a POST, including the plain generate, because a GET is reachable by a link, a prefetch or an image tag, and generating costs money. Regenerating mints a new id rather than putting new bytes behind the old one, so an approval can never come to mean a face nobody looked at.

Not served in this group

  • GET /avatars/{id}. There is no single record read. The library list is the read, and the only id addressed avatar route returns media rather than the avatar.
  • Rename. There is no update path on the record other than visibility. A display name is fixed at creation.
  • Search, tags, render style filters and pagination on either avatar list. Both return the complete set.
Zeli Avatar · real-time avatars over WebRTC · self-hostable · AU data residency · source