# 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.

- Canonical: https://realtimeavatar.ai/docs/api-reference
- Updated: 2026-09-14

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](https://realtimeavatar.ai/openapi.json).

**curl**

```bash
curl https://realtimeavatar.ai/api/v1/credits/balance \
  -H "Authorization: Bearer $REALTIME_AVATAR_API_KEY"
```

**TypeScript**

```ts
import { RealtimeAvatar } from "realtime-avatar";

const rta = new RealtimeAvatar({ apiKey: process.env.REALTIME_AVATAR_API_KEY! });
await rta.creditBalance();
```

**Python**

```python
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](https://realtimeavatar.ai/docs/sessions). |
| `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. Requires the clip-library rollout. |
| `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 }`. Rolling out per tenant (`403 clip_library_not_enabled` until yours is on). 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 legacy`idle.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.

**JSON**

```json
{
  "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](https://realtimeavatar.ai/platform/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](https://realtimeavatar.ai/docs/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/v1` responses are `Cache-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-Key` is honoured on the two writes that cost money to repeat** — `PUT /avatars/{id}/clips` and `PUT /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 stale `expectedRevision` after 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/v1` endpoint, 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 no `code` and no queue fields, which is how you tell it from the other two. Worth sizing against: the creation flow asks you to poll `GET /avatars/{id}` until `status` is `ready`, 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 are `getAvatar`, `listAvatars`, `listClips`, `setClipLibrary`, `retimeAnchor`, `swapSource` and `iterateSessions`. 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.

---

Realtime Avatar — realtime AI avatar API & SDK. Docs: https://realtimeavatar.ai/docs · Agent guide: https://realtimeavatar.ai/llms.txt
