Zeli AvatarDeveloper docs

API reference / Overview

API reference

Everything the Zeli Avatar service serves over HTTP, grouped by resource. This page states the base URL, the credentials, the error envelopes and the pagination rules once. Every group page after it documents only what this deployment actually serves, with the real path, the real field names and the real status codes.

Real surface, honestly grouped

The groups below are the ones a developer comparing avatar APIs expects to find. Where we serve nothing in a group, the page says so in a sentence and names the nearest real thing. Nothing on these pages is aspirational.

Base URL

Every request goes to your environment's host:

https://avatar.zelibot.xyz
There is no single /v1 prefix

Unlike most avatar APIs, our paths are not all under one version segment. Four prefixes are in service and each one is a different origin behind the same host: /api/* and /connect, /offer, /disconnect, /conversations/* reach the GPU box; /auth/* reaches the always on portal service; /v1/* is a small, newer namespace on the box. Use the path exactly as each group page prints it.

PrefixServed byAvailable
/api/*, /connect, /offer, /disconnect, /conversations/*The GPU boxOnly while a box is running
/v1/*The GPU boxOnly while a box is running
/auth/*The portal service (always on)Always

The box self terminates after about two hours, which is why the avatar library, consent, key management and voice provider settings are served from /auth/* rather than from the box. A read that has to work when the machine is off lives on the always on plane.

Authentication

There are two credential planes and they share no credential. A box API key is meaningless to /auth/*, and a portal session is meaningless to the box.

Plane A: API keys, for /api/*, /v1/* and the streaming routes

Present your key on every request. Three forms are accepted, in this order of preference:

# Preferred
curl "https://avatar.zelibot.xyz/api/avatars" -H "X-Api-Key: zsk_live_..."
 
# Also accepted
curl "https://avatar.zelibot.xyz/api/avatars" -H "Authorization: Bearer zsk_live_..."
 
# Query parameter, for browser WebSocket upgrades only, which cannot set headers
wss://<box-host>/api/session/ws?session_token=zsk_temp_...

Three route policies apply, resolved by method and path:

PolicyAcceptsApplies to
publicNo credentialGET /, GET /health, /static/*, and every OPTIONS preflight
anyA full key or a stream only session tokenEverything not listed below
fullA full key onlyThe nine mutating and minting routes

The nine full scope routes are POST /api/avatar, POST /api/avatar/delete, POST /api/upload, POST /api/voice/upload, POST /api/voice/prepare, POST /api/settings, GET /api/stats, POST /v1/streaming.create_token and POST /v1/photo_avatar.create. A session token on any of them returns 401 with code insufficient_scope.

Never ship a full key to a browser

Mint a short lived, stream only session token on your server with POST /v1/streaming.create_token and hand the browser that. A leaked token can drive a stream until it expires and can never mutate anything.

If neither ZELI_API_KEYS nor the key table is configured, the box runs open and every route is unauthenticated. That is correct for an embedded loopback box and wrong for a public one; startup refuses the combination when a public hostname is set. See Authentication for provisioning.

Plane B: a signed in person, for /auth/*

/auth/* resolves a request to an email, from either a main pool Cognito id token in Authorization: Bearer, or the portal's session cookie. GPU lifecycle routes take a second, narrower administrator gate on top of that.

The one bridge between the planes is POST /auth/session/credential, which mints a short lived box credential for a person who is already signed in to the portal.

/auth/* is not reachable cross origin

The portal API declares no CORS preflight, so /auth/* is callable from a browser only same origin, through the site's own host. Call it from a server, or from a page served on the same origin. The box's routes do send CORS headers and are callable from any origin you allow.

Field names and envelopes

Request and response bodies are JSON. Field names are snake_case throughout: expires_at, next_cursor, avatar_id, source_kind, idle_stand_in, emotion_responsive, transcript_unavailable.

The one exception is the SDK control WebSocket, which is camelCase (sessionId, correlationId, contentIndex, endOfSpeech). It sits on the same host as the snake_case HTTP routes. See Events and models.

Most routes return a bare object, not a wrapper. Three routes wrap their payload in data, and they are named where they appear: POST /v1/streaming.create_token, POST /v1/photo_avatar.create and POST /api/chat/reset.

Errors

Two error shapes are in service, split by plane.

Structured, used by every /auth/* route and by the newer box routes (clips, photo avatar, settings refusals, token minting):

{ "error": { "code": "avatar_not_found", "message": "No such avatar." } }

POST /v1/streaming.create_token returns the same two fields unwrapped:

{ "code": "mint_failed", "message": "..." }

Flat, used by the older box routes:

{ "error": "only uploaded avatars can be deleted" }

The code is the stable, machine readable half; branch on it rather than on the message, which is written for a person and may be reworded.

Status codes

CodeMeaning in this API
200Successful read, write, stop or delete that returns a body
201Created: an API key, a consent attestation, a publication attestation, a portrait
202Accepted, work queued: a clip generation, an avatar erasure
400Invalid body, unknown tone, a setting this deployment refuses, a cursor that is not yours
401missing_api_key, invalid_api_key, or insufficient_scope for a session token on a full route
403consent_required on a clip generation
404Not found, or not yours. The two are deliberately the same answer
409Already in progress: a clip for that tone is already generating
501feature_disabled: photo avatar creation or clip generation is off on this box
502An upstream model, voice engine or store failed
503A subsystem is not configured or not available in this environment
404 is also the refusal

A resource that exists but belongs to somebody else answers 404, never 403. A 403 would confirm the id exists, which turns a read into a probe for other developers' resources. Treat 404 as "no such resource of yours".

A 403 does not survive the edge

The CDN in front of this host rewrites every 403 into the portal sign in page, so a 403 would reach your client as HTML rather than JSON. Routes that would naturally answer 403 answer 409 instead, and that is a deliberate choice rather than a mistake.

Pagination

One endpoint in the whole surface paginates: GET /conversations. It takes limit and an opaque cursor, and answers next_cursor:

{
  "items": [ { "id": "...", "title": "...", "turns": 4 } ],
  "next_cursor": "eyJwayI6..."
}

limit defaults to 20 and is clamped to 50. next_cursor is null on the last page. The cursor is not trusted: it is decoded server side and its partition must equal your own, else 400.

Every other list route returns the complete, unbounded list and reads no limit, cursor, offset or page: GET /api/avatars, GET /api/videos, GET /api/voices, GET /api/avatar/clips, GET /api/audios, GET /auth/keys, GET /auth/avatars and GET /auth/avatars/public. Write your client so adding pagination later is not a breaking change for it.

Groups

Zeli Avatar · real-time avatars over WebRTC · self-hostable · AU data residency · source