API reference
Every public endpoint on https://realtimeavatar.ai/api/v1 — what it takes, what it returns, which scope it needs, and how it fails.
Base URL https://realtimeavatar.ai/api/v1. Every request carries Authorization: Bearer tic_…. A machine-readable spec for the realtime surface lives at /openapi.json.
curl https://realtimeavatar.ai/api/v1/credits/balance \
-H "Authorization: Bearer $REALTIME_AVATAR_API_KEY"import { RealtimeAvatar } from "realtime-avatar";
const rta = new RealtimeAvatar({ apiKey: process.env.REALTIME_AVATAR_API_KEY! });
await rta.creditBalance();import os, httpx
client = httpx.Client(
base_url="https://realtimeavatar.ai/api/v1",
headers={"Authorization": f"Bearer {os.environ['REALTIME_AVATAR_API_KEY']}"})
client.get("/credits/balance").json()Realtime
| Endpoint | Scope | Notes |
|---|---|---|
POST /realtime/livekit/session | realtime:write | Mint a session. Returns a grant, or 429 with the queue contract when capacity is full. See Calls. |
POST /realtime/livekit/session/release | realtime:write | Free the call's slot. Free of charge and idempotent, so calling it twice — or on a call that already ended — is safe. { session_id | queue_ticket_id, reason?, capacity_pool? } — one of the two ids is required. reason is a closed set: page_hide · disconnected · superseded · unmount · manual · idle_timeout. Anything else is a 422, and a rejected release frees nothing — the slot stays held until it expires on its own, so send one of these or omit the field. The SDK sends it for you. |
GET /realtime/livekit/capacity | realtime:write | Snapshot of one pool — the orchestrator default, named by capacity_pool in the body (primary). Mints fail over across pools and voice calls are not capacity-gated, so this does not predict whether a mint will be granted. Do not gate a call on it: mint, and treat the 429 as the queue. |
Scenes (preview)
Enabled per workspace; other workspaces get 403 scene_sessions_not_enabled. A scene is a clip you supply, prepared once and then played as a call's picture: mint with scene: { video_cache_id } and the avatar keeps her voice and persona. You can read back and mint only scenes your workspace prepared; any other id is 404 scene_not_found. A mint also needs the scene to be ready, otherwise it answers 409 scene_not_ready (retryable while it is still building). Prepared scenes are kept for a limited time: a mint of one that has expired answers 404 scene_not_found, and preparing it again rebuilds it. A workspace holds at most 2 scene calls at once unless support raises it; another answers 429 scene_concurrency_limit_reached, retryable once one ends.
| Endpoint | Scope | Notes |
|---|---|---|
POST /scenes | realtime:write | { source: { url, md5?, bytes? }, continuation?, profile: "scene-v0" }. The id comes from the clip's content, not its URL, so the same bytes always get the same id, and a clip already prepared answers ready at once when you send its md5 and bytes. Answers 202 while building and 200 once ready or failed. A failed state names why in error: source_unreachable (the clip could not be downloaded; retry a few times with backoff), source_unusable (use a different clip), source_mismatch (the bytes differ from your md5 or bytes) or build_failed. Its scene_id is "unresolved" when the download failed and you sent no md5 and bytes. A source the preparer refuses is 422 scene_source_rejected; 503 scene_builder_unavailable means preparation is not offered on this deployment. |
GET /scenes/{sceneId} | realtime:write | The build state of a scene your workspace prepared. ready carries video_cache_id and the frame counts a mint may pass through. |
Connection history
| Endpoint | Scope | Notes |
|---|---|---|
GET /sessions/{sessionId}/connection-history | usage:read | Read received client connection observations for a session in your workspace. Empty data means no observations were received. Enable collection in server session policy; the SDK owns uploads. |
Recordings
| Endpoint | Scope | Notes |
|---|---|---|
GET /recordings | recordings:read | List your workspace's recording metadata. Optional sessionId, limit, and cursor filters. |
GET /recordings/{recordingId} | recordings:read | Get the recording's current status and retention deadline. Media can finish processing after the call ends. |
GET /recordings/{recordingId}/access | recordings:read | Get a private playback URL for a ready recording. The URL supports seeking, lasts at most one hour, and can be renewed within retention. |
Avatars
| Endpoint | Scope | Notes |
|---|---|---|
GET /avatars | avatars:read | List the workspace's avatars (most recently updated first, capped at 100) as { data: [...] }. |
POST /avatars | avatars:write | { displayName, sourceAssetId?, motionPrompt?, voice?, defaultVoiceId?, llm?, settings?, metadata? } → 201 (preprocessing with a portrait attached; draft without one). Creation is image-only: sourceAssetId names an uploaded portrait and the platform generates the looping idle video plus a multi-clip motion library in the background — poll until status is ready. motionPrompt optionally art-directs the generated loop. Custom video upload is not supported. |
GET /avatars/{id} | avatars:read | Includes status: draft · preprocessing · ready · failed · disabled · deleted and idleVideoStatus: none · queued · generating · ready · failed (the background motion-generation progress). Mint only against ready; on failed, error says why. |
PATCH /avatars/{id} | avatars:write | Partial update; at least one field required. Profile fields allow 160 characters for the name, 4,000 for personality, 8,000 for background, 2,500 for art direction, and 1,000 for reply style. The merged stored profile, including custom metadata and the system template, must fit 16,000 characters of session instructions; exceeding that total returns 422 before the update is saved. Limits use JavaScript string length (UTF-16 code units). portraitUrl is in the schema but is dashboard-only — over this API it is always 400. To change the portrait, register the image with POST /assets or POST /assets/remote and PATCH the returned id as sourceAssetId, which is its own swap lane: it consumes anchorTimeMs as the new source's frame and is exclusive of every other field except sourceReview. sourceReview: { reason } records that a person watched the new loop and accepts it: the automated source checks still run and are audited, but they no longer refuse the swap. |
DELETE /avatars/{id} | avatars:write | Soft delete. |
GET /avatars/{id}/clips | avatars:read | The envelope is { data, avatarId, revision, anchorVersion, anchor, clipLibraryEligible, defaultSourceAssetId, behavior }. Each media/job row in data contains clipId, status, url, source, uploadAssetId, motionPrompt, durationSeconds, anchorVersion, poseCheck, error, createdAt, updatedAt; there is no row-level role, whenHint, or weight. behavior contains optional idle and on rules. The job's status is separate from the serving url, which can remain non-null during a replacement or after its failure. Map uploaded rows back to source: { assetId: uploadAssetId } and generated rows to source: { motionPrompt, durationSeconds } for PUT (omit a null duration); older generated rows with null direction require a new prompt. Behavior moves from the GET envelope to PUT's top level. Sessions always inherit the avatar's serving clips and declared behavior. Session HTTP inputs reject clip_library, clip_behavior, and choreography, including an empty clip library. |
PUT /avatars/{id}/loop | avatars:write | Re-direct the source loop from a new motionPrompt. This is the default resting video without an explicit idle pool; it is not privileged in idle selection. To reference a previous source asset separately, declare the defaultSourceAssetId returned by listClips(avatarId) under a new clip record id using source: { assetId } and reference it in idle. That record keeps the asset it references even after a source-loop update. Answers 202; the render takes minutes, during which she stays ready and keeps serving her previous loop (returned as servingUrl), then the swap publishes at once. Your clip library is untouched. Billed as one generation. Requires a portrait to re-animate — 422 loop_not_generatable (terminal) when she has none: a draft created without sourceAssetId, or a grandfathered avatar built from a supplied video. Do not gate on sourceKind: it reads video for every avatar once her generated loop attaches. 409 loop_pending means one is already in flight. The description is screened like a clip's (422 loop_prompt_rejected). |
POST /avatars/{id}/clips/review | avatars:write | Human quality acceptance for one uploaded clip. Send clipId, uploadAssetId,expectedRevision, expectedAnchorVersion and a reason (5–500 characters). Accepts a pose refusal or pending validation after review; keeps the original machine verdict, records the decision and prepares the serving cache. Returns 204. Changed generations return 409. Other processing failures and invalid media remain refused. Ten seconds is a maximum, not a required duration. Clip Library access is enabled by default for active workspaces and can be disabled by an operator. |
PUT /avatars/{id}/clips | avatars:write | Full replacement: { expectedRevision, clips, idle?, on?, actions? }. Required clips is a record keyed by clip id, each value { source }; source is exactly { assetId } or { motionPrompt, durationSeconds? }. Generated source.durationSeconds is an integer from 4–8 seconds, default 5. Optional idle is { clips, weight? }; on.userSpeechStarted contains { clips }; actions is a record keyed by action id whose values are { description, clips }. Every clips list is a nonempty array of declared clip ids. The same clip can serve several lists. Source changes queue media work; selection and action metadata changes do not re-render unchanged sources. Omitted records are retired and omitted behavior is removed. Required expectedRevision is a nonnegative integer for compare-and-set: stale writes get 409 revision_conflict with the current revision. Answers 202 with the GET envelope plus plan: { kept, queued, retired }. Enabled by default for active workspaces; an operator-disabled workspace receives 403 clip_library_not_enabled. Motion prompts and action descriptions are screened even on metadata-only writes: refusal is 422 clip_declaration_rejected; if the screen cannot run, the write fails closed with retryable 503 clip_screen_unavailable. |
Idle variations, events and actions
The source (primary) and authored idle clips form one weighted idle pool.idle.weights maps member IDs to finite weights from 0–100; each omitted member defaults to 1, zero disables it, and at least one must remain positive. Every member avoids immediately repeating when another is available; a sole available member loops completely. Do not combine this map with legacyidle.weight, which is converted into aggregate member weights. Lists are nonempty and cannot repeat a clip id. Unavailable members are filtered before selection and weights are renormalized. Ten idle variations are valid; there are no role-count or total-clip authoring caps beyond 32 per list. The 64 KB request-byte transport bound and worker admission remain independent constraints, not unlimited GPU capacity. If the source is disabled and every positive member is pending, mint returns retryable clip_library_not_ready.
userSpeechStarted plays one listening reaction, once per event, rather than holding a listening loop — a triggered reaction repeats freely. actions exposes the requestable menu: the language model reads each description to choose an appropriate action, then that action's clips list chooses a take; declaring an action never plays it by itself. The same clip can serve idle, listening and any action. Speaker lip-sync runs on the current playback cursor during full-duplex speech, without restarting the body on a separate talking clip.
Clip and action ids are 1–64 letters, digits, underscores, or hyphens; reserved keys are refused. Action descriptions are trimmed, nonempty, and at most 512 characters. Motion prompts are trimmed, nonempty, and at most 1,200 characters. Asset ids are trimmed, nonempty, and at most 160 characters. Strict objects reject unknown fields. Keep declaration writes server-owned; do not let caller-supplied prose bypass your application's policy or the platform's safety screening.
{
"expectedRevision": 0,
"clips": {
"sway": { "source": { "motionPrompt": "Sways gently, settles back to rest" } },
"nod": { "source": { "motionPrompt": "A gentle nod, then returns to rest", "durationSeconds": 5 } }
},
"idle": { "clips": ["sway", "nod"] },
"on": {
"userSpeechStarted": { "clips": ["nod"] }
},
"actions": {
"agree": { "description": "When agreeing with the user", "clips": ["nod"] }
}
}Replace the sample revision with the latest GET revision. This full replacement retires records not shown here; include all existing records and behavior you intend to keep.
Assets
| Endpoint | Scope | Notes |
|---|---|---|
GET /assets | avatars:read | List uploaded assets. |
POST /assets | avatars:write | Multipart: file, optional kind (image | video | audio) → 201. Video assets are accepted as uploaded motion-clip sources; they do not reopen custom-video Avatar creation. A clip declaration pose-validates video and requires it to fit the 18 MB QC ceiling. |
POST /assets/remote | avatars:write | { kind, remoteUrl, originalFilename?, metadata? } — the platform streams the file into storage. Video assets may be referenced by PUT /avatars/{id}/clips; prefer remote registration when the bytes already live in object storage. |
Billing and keys
| Endpoint | Scope | Notes |
|---|---|---|
GET /credits/balance | credits:read | Balance and currently reserved credits, in micros. Live sessions hold a reservation for their duration. |
GET /usage/sessions | usage:read | Per-session billing detail — when each session ran, how long it was billable for, and what it cost. This is what reconciles an invoice, or re-bills your own users; the dashboard aggregate can do neither. The window defaults to the trailing 30 days and is clamped to 90 — a wider range is served narrowed, not refused, so read back the returned from/to. Page with nextCursor. ?endUserId= narrows to one of your users when the call was tagged with client_metadata.user_id. Read status before you sum anything. Only released and failed are settled: released is the normal end of a call and carries the charge, failed is terminal and always billedCreditMicros: 0. The other two are not final — reserved is a slot that was held and never became a call, and started is still in flight. Both report billedCreditMicros: 0 provisionally: a started row settles when the session is released, so the same session read again tomorrow can carry a different number. Reconcile on the settled rows and treat the rest as pending, not as free. |
POST /api-keys | api_keys:write | { name, scopes?, spendLimitCreditMicros?, expiresAt? }. Only name is required. All new keys use tic_<keyId>_<secret>. The deprecated environment field is accepted but ignored. scopes defaults to realtime:write, credits:read and avatars:read, which starts calls and reads but writes no avatars. The creation response includes the full key. Workspace owners and admins can copy newly created keys again in Settings → API keys while they are active and unexpired; the list stays masked with a Copy action. Legacy keys stored as hashes only cannot be recovered if lost and need a replacement. Revoked or expired keys cannot be copied. |
Errors
Switch on code and HTTP status, not the wording of error. Correlated failures include a server-generated requestId, also in X-Request-ID; include it when contacting support. Session failures retain safe codes such as internal_error (500), capacity_unavailable and session_dispatch_failed (503), upstream_unreachable and invalid_upstream_response (502), upstream_timeout (504), and request_aborted (408). Internal diagnostics require separate operational access. A capacity queue 429 keeps its existing body; its request ID is in the header only.
| Status | Meaning | What to do |
|---|---|---|
| 401 | Missing, malformed, revoked, or expired key | Use a full, active, unexpired key. Owners and admins can copy eligible keys again in workspace Settings. Replace lost legacy keys, revoked keys, or expired keys. See Authentication. |
| 403 | The key lacks the scope for this operation, or its workspace is not active (error reads Tenant is not active) | Read error: mint a key with the scope (do not widen to *), or have the workspace reactivated — there is no self-serve switch for that |
| 402 | insufficient_credits or spend_limit_exceeded | Top up, or raise the per-key spend limit. Surface this as a paywall, not as a connection failure. |
| 409 | A conflict with state that already exists. Three kinds reach this API: no workspace on the account yet; an idempotency conflict (a key still in flight, or reused with a different body); and an avatar state conflict — it is disabled, already has a motion video, has no portrait to animate, or a change is still settling. | Read error — the three kinds want different things. A workspace is a one-time setup in the dashboard; an idempotency conflict wants a fresh Idempotency-Key (never the same one); an in-flight change wants a plain retry once it settles. |
| 404 | No such avatar, voice, or key — for this tenant. The message often reads "does not belong to this tenant", which sounds like a permission problem and is usually a wrong or already-deleted id. | Do not route this to your access-denied branch — 403 is the permission answer. Check the id, and remember a soft-deleted avatar is gone from every read. |
| 413 | The JSON body is over this route's cap | See the cap sizes under Conventions. This is almost always inlined media — send it as an asset and reference the id. |
| 422 | Schema rejection — an unknown, mis-cased, or invalid field | The wire schemas are strict. Check camelCase vs snake_case at the layer you are writing. |
| 429 | Plan concurrency ceiling, capacity full (session mint), or rate limited | A mint has two 429s, and they want opposite reactions. With code: "concurrency_limit_reached" the plan’s concurrent-session limit is reached. Starting and connecting sessions also count, even when no conversation is active. The top-level fields maxConcurrentSessions and liveSessions report the limit and occupied reservations; activeSessions, connectingSessions, and pendingSessions break down the count at refusal time. End a session or wait for pending starts to finish or be released before retrying. Avoid parallel duplicate starts, and release sessions on cancellation or unmount. Upgrade only when more simultaneous sessions are needed. Show the error to the caller and retain requestId for support. This refusal is not a queue position. Without a code, look at the body before deciding. A queue answer carries queue_size and recommended_retry_ms: the SDK returns a queued result (isQueued) with position and retryAfterMs — render the position and retry. Deliberately not auto-retried: a blind retry would burn the backoff to receive the same answer. A body with neither a code nor those fields is the per-key rate limit (see Conventions) — back off, do not treat it as a queue position. Use SDK 0.12.0 or later to preserve concurrency refusals as errors. Older SDKs, including 0.11.1 and 0.11.5, map every 429 to a queued result and can hide the refusal. A null queue position alone does not distinguish a refusal from a real queue. |
| 502 | An upstream generation or render failed | Retry; if it persists it is ours, not yours |
| 503 | A dependency this route needs is unavailable — a render queue that would not accept the job, a staging pipeline that is down, or (clip_screen_unavailable) the screen that vets clip descriptions. Never your request's fault. | Retry with backoff. Nothing was written, so a retry is safe and needs no Idempotency-Key to be correct — though sending one costs nothing. |
Conventions
- All
/api/v1responses areCache-Control: no-store. - JSON request bodies are size-capped per route — 4 KiB on the session release and clip review, 64 KiB on session creation and the clip-library
PUT, 128 KiB on avatar writes (create and update), and 8–32 KiB on other JSON routes. Field limits still apply within each byte budget. Over the body cap is a 413. Send media by URL, not inline. Idempotency-Keyis honoured on the two writes that cost money to repeat —PUT /avatars/{id}/clipsandPUT /avatars/{id}/loop. Replay returns the original response rather than doing the work twice. It matters most on the loop, where a repeated re-direct is a second billed render. A repeated clip declaration without the key has a staleexpectedRevisionafter acceptance and conflicts; re-reading and declaring unchanged sources does not re-render them. Reuse the same key with a different body and you get a 409 — a retry means the same request, so generate a fresh key for a changed one. No other endpoint reads the header.- 120 requests per 60 seconds, per key. The throttle is applied when the key is verified, so it covers every
/api/v1endpoint, and it counts per key rather than per workspace — splitting a workload across keys raises the ceiling. Over it is a 429 whose body carries nocodeand no queue fields, which is how you tell it from the other two. Worth sizing against: the creation flow asks you to pollGET /avatars/{id}untilstatusisready, and a render takes minutes — poll on the order of seconds, not milliseconds. - Ids are prefixed and stable:
ava_avatars,tic_API keys. - The SDK covers the integration path — calls, avatars, assets, usage, credits — with a surface that is deliberately narrower than this wire, though less narrow than it once was. Avatar update and delete are in it (
updateAvatar,deleteAvatar), as aregetAvatar,listAvatars,listClips,setClipLibrary,retimeAnchor,swapSourceanditerateSessions. Prefer it over hand-rolled fetches wherever it reaches. What it leaves out is key minting, the capacity snapshot and the asset listing — those three are a plain authenticated fetch against the endpoints above.