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.
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.xyzUnlike 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.
| Prefix | Served by | Available |
|---|---|---|
/api/*, /connect, /offer, /disconnect, /conversations/* | The GPU box | Only while a box is running |
/v1/* | The GPU box | Only 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:
| Policy | Accepts | Applies to |
|---|---|---|
public | No credential | GET /, GET /health, /static/*, and every OPTIONS preflight |
any | A full key or a stream only session token | Everything not listed below |
full | A full key only | The 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.
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.
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
| Code | Meaning in this API |
|---|---|
200 | Successful read, write, stop or delete that returns a body |
201 | Created: an API key, a consent attestation, a publication attestation, a portrait |
202 | Accepted, work queued: a clip generation, an avatar erasure |
400 | Invalid body, unknown tone, a setting this deployment refuses, a cursor that is not yours |
401 | missing_api_key, invalid_api_key, or insufficient_scope for a session token on a full route |
403 | consent_required on a clip generation |
404 | Not found, or not yours. The two are deliberately the same answer |
409 | Already in progress: a clip for that tone is already generating |
501 | feature_disabled: photo avatar creation or clip generation is off on this box |
502 | An upstream model, voice engine or store failed |
503 | A subsystem is not configured or not available in this environment |
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".
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
Mint a token, check readiness, drive and stop the live session, read and erase conversation history.
WebRTC signalling, box health, and the GPU instance lifecycle.
One settings record per caller, not a table of named personas.
Create, list, inspect, publish, erase. Two lists on two planes.
Emotion tone clips as asynchronous jobs. No script to MP4 rendering.
The caller's voice catalogue, cloning, previews, and the provider key vault.
The model catalogue, with residency and selectability flags.
Not served. What to use instead.
Tools run inside a turn. No REST surface.
Not served. Avatar publication is a different thing.
Not served.
Create, list and revoke the credentials every route above needs.