# Anva API Documentation > Anva turns a photo into a live, conversational AI avatar you can embed in > your product over WebRTC and drive from your backend. This file is the > complete API documentation as plain markdown, generated from > https://anva.ai/docs at build time. ## Overview & Auth The public API is mounted at `/api/v2`. Use it to create conversation sessions — from a saved preset (embed) or from an `avatar_id` plus an inline persona (advanced) — embed those sessions, observe realtime state, and — on the Developer plan and above — replace the built-in LLM with your own controller. Base URL `https://anva.ai/api/v2` Authentication `Authorization: Bearer ` Body format `application/json` Errors `{"error":{"code","message"}}` API keys look like `anva_key_…` and are created in the dashboard under [API Keys](https://anva.ai/dashboard/keys) (Developer plan or above). The `X-API-Key: ` header is accepted as an alternative to the `Authorization` header. Keys are account-scoped and rate-limited per key — 120 requests per minute by default. On deployments where the API is not enabled, every call returns `503 api_disabled`. Your API key is a secret — treat it like a password. Keep it on your server, never in browser code or a public repo. Mint sessions on your backend and hand only the returned `embed_url` to the client. ## Quickstart (Requires Developer plan) Create a preset in Playground, choose a service mode, then create one session per visitor on your backend. Your frontend receives only its embed URL. You need an active Developer, Pro or Enterprise plan and an API key. Run the HTTP examples on your server; the Python examples use the requests package. 1. List your presets Terminal ``` curl https://anva.ai/api/v2/presets \ -H "Authorization: Bearer anva_key_..." ``` fetch ``` const res = await fetch("https://anva.ai/api/v2/presets", { headers: { "Authorization": "Bearer anva_key_..." } }); const { presets } = await res.json(); ``` requests ``` import requests r = requests.get( "https://anva.ai/api/v2/presets", headers={"Authorization": "Bearer anva_key_..."}, ) presets = r.json()["presets"] ``` 2. Create a session Terminal ``` curl https://anva.ai/api/v2/sessions \ -H "Authorization: Bearer anva_key_..." \ -H "Content-Type: application/json" \ -d '{"preset_id":"abc123","service_mode":"anva_standard"}' ``` fetch ``` const res = await fetch("https://anva.ai/api/v2/sessions", { method: "POST", headers: { "Authorization": "Bearer anva_key_...", "Content-Type": "application/json" }, body: JSON.stringify({ preset_id: "abc123", service_mode: "anva_standard" }) }); if (!res.ok) throw new Error("HTTP " + res.status); const session = await res.json(); // session.embed_url, session.events_ws_url, session.expires_at ``` requests ``` import requests r = requests.post( "https://anva.ai/api/v2/sessions", headers={"Authorization": "Bearer anva_key_..."}, json={"preset_id": "abc123", "service_mode": "anva_standard"}, ) r.raise_for_status() session = r.json() print(session["embed_url"]) ``` 3. Embed the avatar iframe ``` ``` DOM ``` const iframe = document.createElement("iframe"); iframe.src = session.embed_url; iframe.allow = "microphone; autoplay"; document.body.appendChild(iframe); ``` The `embed_url` above is a per-session grant — mint one on your backend for each visitor. Want a single, stable URL you can paste straight into an iframe with no backend at all? Use the [Test / Production URLs](https://anva.ai/docs#urls) for your embed instead. ## SDKs SDK 0.8.1 is available for Python, JavaScript / TypeScript and Go. The SDKs support the Speech API (text to an Anva voice plus mouth curves), queued host lines, service modes, capability and billing discovery, push-to-talk sessions, host lines with a per-line speaking rate, live instruction updates, retry-safe session creation, `speech_speed` and `wake_up` on creation, events without the face stream, PCM voice input, presentation controls and the Lipsync API. Start with the example below or the [REST quickstart](https://anva.ai/docs#quickstart). REST clients need no third-party dependencies; realtime uses a compatible WebSocket implementation. They connect to `https://anva.ai/api/v2` using your server-side API key. See the [SDK reference and examples](https://github.com/Anva-avatars/anva-sdk/tree/v0.8.1) for bidirectional event streams and PCM flow control. Raw REST and WebSocket examples are also available below. Voice agents on LiveKit Agents or Pipecat use the [framework packages](https://anva.ai/docs#integrations) instead. | Language | Install | Import | |---|---|---| | Python | `pip install anva==0.8.1` (live events: `pip install "anva[ws]==0.8.1"`) | `from anva import Anva` | | JavaScript / TypeScript | `npm install anva-sdk@0.8.1` | `import { Anva } from "anva-sdk"` | | Go | `go get github.com/Anva-avatars/anva-sdk/go@v0.8.1` | `import anva "github.com/Anva-avatars/anva-sdk/go"` | Create an Anva Realtime session with an explicit mode: JavaScript ``` import { Anva } from "anva-sdk"; // keep the key server-side — mint the session on your backend const client = new Anva(process.env.ANVA_API_KEY); const session = await client.createSession({ presetId: "abc123", serviceMode: "anva_standard" }); // hand session.embed_url to the browser and iframe it console.log(session.embed_url); ``` Python ``` from anva import Anva client = Anva("anva_key_...") session = client.create_session( preset_id="abc123", service_mode="anva_standard") print(session["embed_url"]) # iframe this in your page ``` Go ``` import ( "context" "fmt" "os" anva "github.com/Anva-avatars/anva-sdk/go" ) client := anva.New(os.Getenv("ANVA_API_KEY")) session, err := client.CreateSession(context.Background(), anva.CreateSessionParams{ PresetID: "abc123", ServiceMode: anva.AnvaStandard, }) if err != nil { panic(err) } fmt.Println(session.EmbedURL) // iframe this in your page ``` What 0.6.0 to 0.8.1 add, by language (0.8.0 adds the `anva_standard` mode, now the default, and the `speed_unsupported` error): | Feature | JavaScript | Python | Go | |---|---|---|---| | Speaking rate, waking up, retry-safe create | `createSession({…, speechSpeed, wakeUp, idempotencyKey})` | `create_session(…, speech_speed=, wake_up=, idempotency_key=)` | `CreateSessionParams{SpeechSpeed, WakeUp, IdempotencyKey}` | | Change instructions mid-call | `updateSession(id, {systemPrompt})`; on a socket `updatePrompt(text)` | `update_session(id, system_prompt=)`; on a socket `update_prompt(text)` | `UpdateSession(ctx, id, prompt)`; `Realtime.UpdatePrompt` | | A host line’s speaking rate | `say(id, text, {sayId, speed})` | `say(id, text, say_id=, speed=)` | `SayAtSpeed(ctx, id, text, sayID, speed)` | | Events without the face stream | `events(id, {controls: false})` | `events(id, controls=False)` | `EventsWSURL(id, anva.WithoutControls())` | | Speech API: text to voice and mouth curves (Enterprise) | `synthesize(text, {voiceId})`; streaming `connectSpeech({voiceId})` | `synthesize(text, voice_id=)`; streaming `connect_speech(voice_id=)` | `Synthesize(ctx, SpeechParams{…})`; streaming `NewSpeechStream` | | Queue a host line behind the current one | `say(id, text, {sayId, queue: true})` | `say(id, text, say_id=, queue=True)` | `SayWith(ctx, id, text, sayID, LineOptions{Queue: true})` | ## Plans & Pricing Developer and Pro include a monthly token allowance and full API access. Enterprise has custom terms. New accounts begin without a subscription or token grant; choose a plan before starting metered sessions. | Plan | Price / month | Tokens / month | API | Support | |---|---|---|---|---| | Developer | $25 | 25,000 | Full access | Discord community | | Pro | $89 | 100,000 | Full access | Prioritised support | | Enterprise | Custom | Custom | Full access | Dedicated support | ### Tokens by conversation mode Anva tokens are service units, separate from LLM input or output tokens. Active session time is charged at the mode’s rate, including listening and pauses. Token usage is proportional to session duration; a balance does not correspond to a fixed number of minutes across modes. | Mode | Tokens / minute | Availability | |---|---|---| | Anva Realtime Lite | 60 | Available | | Anva Realtime | 70 | Available | | Anva Realtime Expressive | 80 | Available | | Anva Realtime Max | 120 | Available | | BYO LLM | 50 | Available | | Avatar Only | 10 | Available | For example, 25,000 tokens cover about 357 minutes in Anva Realtime at 70 tokens/minute, 417 minutes in Anva Realtime Lite at 60 tokens/minute, or 208 minutes in Anva Realtime Max at 120 tokens/minute. Mixing modes uses the same balance. Avatar Only accepts your PCM voice audio and supplies avatar animation; check `GET /capabilities` for availability. BYO LLM accepts your AI’s text replies; Anva supplies the configured speech and avatar. ### Top-ups Buy 10,000 tokens for $10 USD from Account when checkout is enabled. Top-ups require an active Developer or Pro token plan. Purchased tokens remain on the account after cancellation, but an active eligible plan is required to use them. Monthly grants and purchased tokens are shown separately. ### Session admission Each API request checks the current plan. Accounts without API access receive `402 plan_required`. A depleted balance prevents or ends metered sessions; add tokens or wait for the next included grant. Developer and Pro have no plan-level concurrent connection limit. Sessions share available infrastructure capacity; guaranteed capacity is agreed through Enterprise. Legacy plans retain their existing limits. When a session is refused for plan reasons, the iframe shows a notice and posts `plan.limit` with payload `{message}` to its parent, followed by `session.failed`. See [Embed Events](https://anva.ai/docs#embed-events). ### Existing subscriptions Legacy subscriptions retain their existing credit units, allowances and overage terms. 100 legacy credits = 1 minute. Legacy balances are not relabelled as tokens. Contact us to migrate an active legacy subscription; a plan change does not silently convert its balance. ### Custom avatars Enterprise customers with creation enabled by an Anva administrator can upload a photo in the app or through the [avatar creation API](https://anva.ai/docs#avatar-creation). Built-in avatars are included with eligible plans. ## Service modes Set `service_mode` when you create a session. The mode determines its inference path and token rate, and is pinned to that grant. Reconnecting cannot change it. The create response, session inspection and `session.info` report the resolved mode and billing metadata. | Mode | Service ID | Tokens / connected minute | |---|---|---| | Anva Realtime Lite | `anva_light` | 60 | | Anva Realtime | `anva_standard` | 70 | | Anva Realtime Expressive | `anva_expressive` | 80 | | Anva Realtime Max | `elevenagents_max` | 120 | | BYO LLM | `byo_llm` | 50 | | Avatar Only | `avatar_only` | 10 | ### Which one should I pick? - Anva Realtime (`anva_standard`) is the default: Anva’s newest voice model, in 85 languages. It has no speaking-rate control. - Anva Realtime Lite (`anva_light`) has the lowest price and the speaking-rate control (`speech_speed`), in 32 languages. - Anva Realtime Expressive (`anva_expressive`) adds emotion-guided voice delivery; vocal direction may take a little longer to respond. Realtime, Realtime Lite and Realtime Expressive use Anva’s managed conversation stack. Realtime Max uses ElevenLabs Agents with an Anva avatar. BYO LLM accepts your text replies and supplies transcription, speech and animation; with `speech_input: "off"` it takes your own transcribed user turns instead, and `say` speaks host lines (see [External LLM](https://anva.ai/docs#external-llm)). Avatar Only accepts your PCM voice audio and supplies avatar animation; your application supplies any LLM or transcription. Call `GET /capabilities` before offering a mode. Its `modes` array reports `id`, `name`, `available`, `tokens_per_minute`, `input`, `transcription`, `presentation`, `speech_input`, `default`, `speech_speed` (whether the mode has a speaking-rate control) and any `reason`; the `avatar_only` row has no `default`, `speech_speed` or `speech_input`, and adds `voice` and `expressions`. Beside `modes` the response carries `audio_input`, `presentation`, `lipsync` and `speech` (each Enterprise API’s `available`, limits and `tokens_per_minute`), `billing_endpoint`, `avatar_creation_endpoint` and `compatibility`. Availability depends on the deployed core and provider configuration. API support does not guarantee rendering capacity. The public Avatar Only rollout remains gated by this capability check. Choose a mode — server-side JavaScript, no Anva SDK required ``` const base = "https://anva.ai/api/v2"; const headers = { Authorization: "Bearer " + process.env.ANVA_API_KEY, "Content-Type": "application/json" }; const available = await fetch(base + "/capabilities", { headers }); if (!available.ok) throw new Error("Capabilities: HTTP " + available.status); const capabilities = await available.json(); const mode = capabilities.modes.find(item => item.id === "anva_standard"); if (!mode?.available) throw new Error(mode?.reason || "Mode unavailable"); const response = await fetch(base + "/sessions", { method: "POST", headers, body: JSON.stringify({ preset_id: "YOUR_PRESET_ID", service_mode: mode.id }) }); if (!response.ok) throw new Error("Create session: HTTP " + response.status); const session = await response.json(); // Return only session.embed_url to your frontend. // Keep session.session_id on your backend for control and cleanup. ``` Omitting the mode defaults to `anva_standard` (Anva Realtime) unless a legacy inference option selects another path. One exception: a session that sets `speech_speed` and names no mode is created as `anva_light`, the mode with a speaking-rate control. Naming `anva_standard` together with `speech_speed`, or sending a per-line `speed` on `say` / `say.delta` to a session using its voice, is refused with `400 speed_unsupported` (“Anva Realtime (anva_standard) has no speaking-rate control; use anva_light”); on the events socket it arrives as an `error` event with that code. Specify a mode explicitly in new integrations. Session URL query parameters cannot override its mode or billing. For Max, Anva creates or reuses a managed ElevenLabs agent from the configured voice, language and instructions; an agent ID is not required. Managed conversation modes use Articulate speech animation by default. Mouth dynamics are applied automatically; you do not need to tune the lipsync model to start an integration. `dynamic_expressions: true` also enables explicit expression diagnostics for a conversational session. Avatar Only rejects this conversation-driven expression option; the public echo demo’s mood controls are not yet a standalone API contract. ### Moving to Anva Realtime (1 October 2026) - `anva_light` keeps working, at the same behaviour and price (60 tokens/minute). It is now displayed as Anva Realtime Lite. - Sessions created with no `service_mode` now use Anva Realtime (`anva_standard`) at 70 tokens/minute, where they used `anva_light` at 60. - Send `service_mode: "anva_light"` to keep the previous behaviour and price. - The voice performance modes are now `fast` (Lite), `standard` (Realtime) and `expressive`. `GET /billing` returns the account’s billing view: `enabled`, `billing_unit`, `status`, `unlimited`, `plan` (`id`, `name`, `monthly_price_cents`, `monthly_tokens`, `api_tier`, …), `balance` (`total`, `monthly` and `purchased`, each also in thousandths as `*_milli`), this account’s `mode_rates`, the public `plans` and `topup_packs`, `metering`, `period_start` and `current_period_end`. For session billing, use the server’s `billing.unit`, `tokens_per_minute`, `rate_version` and `basis`. `connected_time` includes listening and pauses. Legacy subscriptions retain their credit terms. Deprecated `llm_mode` aliases still work. New integrations should use `service_mode`. Conflicting aliases or performance options produce an error. Compatible `performance_options` and flat `conversation_provider`, `performance_mode`, `elevenlabs_agent_id`, `dynamic_expressions`, `speech_speed` and `wake_up` fields are validated against the selected mode; an invalid or conflicting value is `400 bad_service_mode`. ## BYO voice: PCM input Use `service_mode: "avatar_only"` only when capabilities report it available. Supply raw signed 16-bit little-endian PCM, 24,000 Hz, mono, without a WAV header. This path does not invoke Anva transcription, LLM or TTS. Attach the returned embed to a browser for real audio/video playback; an events socket alone is not a media player. PCM command sequence — JSON frames on your server’s events socket ``` // Create service_mode: "avatar_only" and attach the returned media embed. // Wait for session.live, then send: {"type":"speech.start","payload":{"turn_id":"voice-1","codec":"pcm_s16le","sample_rate":24000,"channels":1}} // Wait for speech.state with state:"started" and matching turn_id. // Example only: two silent samples. Replace with real base64 PCM bytes. {"type":"speech.append","payload":{"turn_id":"voice-1","seq":0,"start_sample":0,"data":"AAAAAA=="}} // Wait for state:"accepted" with accepted_samples:2 before finishing: {"type":"speech.done","payload":{"turn_id":"voice-1","total_samples":2}} // Keep reading until state:"finished" confirms playback completion. ``` A `turn_id` is 1–96 letters, digits, `_` or `-`. Utterances are limited to 120 seconds. Chunks contain at most 12,000 samples (24,000 bytes, half a second). Start `seq` at zero; increment it for every chunk. `start_sample` is the exact cumulative sample count, and `speech.done.total_samples` must match the accepted total. Keep at most five seconds of audio pending, using accepted and played sample counts to pace the producer. Raw command and acknowledgement ``` // Send over the events WebSocket, or POST /sessions/{id}/speech: {"type":"speech.append","payload":{"turn_id":"voice-1","seq":0,"start_sample":0,"data":"AAAAAA=="}} // Observe the result on the events socket: {"type":"speech.state","payload":{"turn_id":"voice-1","state":"accepted","generation":1,"accepted_samples":2,"played_samples":0,"next_seq":1}} ``` `speech.state` states are started, accepted, finished, cancelled or error. Errors include a `code`; `next_seq` is the `seq` the next chunk must carry. HTTP acceptance means the command was forwarded, while `finished` means playback completed. Stop a turn with `speech.cancel` or interrupt the session, discard old queued chunks, and use a fresh turn ID. Never fabricate playback receipts. Optional text is caption metadata (12,000 UTF-8 bytes maximum), not a speech-generation prompt. ## Presenter context Managed Anva modes can follow a registered set of pages and facts. The host sends full context whenever the visitor navigates; the presenter requests only registered page and highlight IDs. The host owns rendering and validates revision, generation and duplicate action IDs. This capability is not automatically provided by BYO LLM, PCM input or an ElevenLabs agent. Register context, then start the introduction ``` const context = { version: 1, demo: "product", revision: 1, page: "overview", highlight: "", brief: "Explain the product briefly and answer questions.", opening: "Hello. Would you like an introduction?", pages: [{ id: "overview", title: "Our product", items: [{ id: "value", text: "A verified product fact." }] }] }; // On an authenticated events WebSocket (see Events WebSocket): ws.send(JSON.stringify({ type: "context.update", payload: context })); let introRequested = false; ws.on("message", raw => { const event = JSON.parse(raw.toString()); const state = event.payload || {}; if (!introRequested && event.type === "presentation.state" && state.accepted && state.demo === context.demo && state.revision === context.revision) { introRequested = true; ws.send(JSON.stringify({ type: "presentation.start", payload: { version: 1, demo: context.demo, revision: context.revision }})); } // Continue handling events and validated navigation actions here. }); ``` REST equivalents are `POST /sessions/{id}/context` with the full context body and `POST /sessions/{id}/presentation` with the startup header. Both also accept commands over the events socket as `context.update` and `presentation.start`. Core acceptance arrives asynchronously as `presentation.state`. The dedicated start command is idempotent and does not insert a fake visitor message; do not replay a successful introduction when changing pages. `presentation.state` answers each `context.update` with `{version, accepted, demo, revision, reason}` (`reason` is empty when accepted), and each `presentation.start` with the same plus `event: "presentation.start"` and `started`. A refusal’s `reason` is one of `invalid_context`, `invalid_start`, `unsupported_conversation_provider`, `context_required`, `stale_context`, `presentation_already_started`, `conversation_already_started`, `standby` or `session_stopped`; the embed adds `channel_not_ready`. A `context.update` with a lower `revision` than the one in place is ignored without an answer; the same revision with different content is `invalid_context`. A context or start body is at most 16,000 bytes, `version` is 1, `revision` is a whole number from 0, and `demo` (like page, item and cue ids) matches `^[a-z][a-z0-9_-]{0,31}$`. A context holds up to 8 pages of 8 items, or cues alone. Presenter context applies to `anva_light`, `anva_standard` and `anva_expressive`. In `byo_llm` and `elevenagents_max` sessions a `context.update` is answered `accepted: false` with reason `unsupported_conversation_provider`, and `presentation.start` over REST or the socket is refused with `command_failed`; `avatar_only` refuses both with `command_failed`. The avatar moves between items with `presentation.action` `{version, id, demo, revision, generation, turn_id, page, highlight}`, at most once per reply. It is sent while the reply is written, before its audio plays, so it marks the reply rather than a word. ### Cues: visuals timed to the avatar’s words Register the visuals your page can show while the avatar speaks as `cues`, up to 16 of `{id, description}`; a context may carry cues and no pages. The avatar attaches at most one cue to each part of its reply, covering exactly that part’s words, and the session sends `presentation.cue` as they are heard. Cues and a cue event ``` // In the context: cues: [{ id: "cave", description: "Diagram of the allegory of the cave." }] // presentation.cue {"version":1,"id":"cue_…","demo":"academy","revision":1,"cue_id":"cave","index":0, "turn_id":"speech_…","generation":2,"phase":"start","spoken_offset_ms":1840,"estimated":false} ``` `phase` is `start` (the first word is heard), `end` (the last word has been heard) or `cancel` (the words stopped while it showed, with a `reason`). Each showing, identified by `turn_id` and `index`, has one start and then one end or cancel; a cue whose words were never reached sends nothing. `spoken_offset_ms` is the position in the reply’s audio. Ignore a start whose `generation` is older than the latest `control.interrupt`, so an interrupted answer never shows a late graphic. `estimated: true` marks a cue the voice left untimed. The events socket receives each cue as its words leave the server, about one audio buffer (typically 150–250 ms) before they are heard. The embed holds each cue for its measured audio buffer and posts it to your page as the words are heard, with `at_ms` (epoch milliseconds) and `playout_ms`; a cue on screen when the connection drops or the session ends is cancelled with `connection_lost` or `ended`. ## LiveKit Agents, Pipecat and Daily If your voice agent already runs on [LiveKit Agents](https://docs.livekit.io/agents/) or [Pipecat](https://docs.pipecat.ai/) (including Daily transports), give it an Anva face without changing its STT, LLM or TTS. Both packages run an [Avatar Only](https://anva.ai/docs#audio-input) session under the hood: your agent speaks, Anva animates the lips in time and delivers the picture and the voice together. Billing is the Avatar Only rate per connected minute, on the Developer plan or above. ### LiveKit Agents Anva joins your LiveKit room as an avatar participant, following LiveKit's standard avatar protocol: the agent's audio reaches Anva over the room's data stream, and interruptions and playback receipts use the `lk.clear_buffer`, `lk.playback_started` and `lk.playback_finished` RPCs. Your front end sees two agent participants; LiveKit components pick the avatar's tracks automatically (its `lk.publish_on_behalf` attribute names your agent). pip install livekit-plugins-anva ``` from livekit import agents from livekit.agents import Agent, AgentSession from livekit.plugins import anva, deepgram, openai, silero async def entrypoint(ctx: agents.JobContext): session = AgentSession( stt=deepgram.STT(), llm=openai.LLM(), tts=openai.TTS(), vad=silero.VAD.load(), ) avatar = anva.AvatarSession(avatar_id="av_...") # or preset_id="..." await avatar.start(session, room=ctx.room) await session.start(agent=Agent(instructions="You are a friendly guide."), room=ctx.room) ``` Set `ANVA_API_KEY` next to your `LIVEKIT_URL`, `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET`. `start()` returns once the avatar is in the room with its tracks published; the agent waits for the avatar's video before its first word. `max_duration_seconds` and `metadata` pass through to the Anva session, and the session ends when the job shuts down. Other frameworks can use the same room join directly: create an `avatar_only` session with a `livekit` block, and Anva connects to the room before answering. The token is one you mint for the avatar participant (kind `agent`, room join for that room, attribute `lk.publish_on_behalf` set to your agent's identity). The response carries `livekit.room` and `livekit.participant_identity` and no `embed_url`: the room is the viewer. POST /api/v2/sessions — join a LiveKit room ``` { "avatar_id": "av_...", "service_mode": "avatar_only", "livekit": { "url": "wss://your-project.livekit.cloud", "token": "" } } ``` A join that fails is reported at once: `livekit_join_failed` (502) when the room cannot be reached with that token, `no_capacity` (503, with `Retry-After`) when no render slot is free, `livekit_requires_avatar_only` (400) for any other mode. Nothing is billed for a refused join. ### Pipecat and Daily `AnvaVideoService` sits after the TTS in your pipeline. It sends the TTS audio to Anva and pushes back the avatar's video as `OutputImageRawFrame`s and its voice as `TTSAudioRawFrame`s, in sync, so the same pipeline works on a Daily room, a LiveKit room, a WebRTC page or any transport with video out. An `InterruptionFrame` stops the avatar mid-sentence. pip install pipecat-anva ``` from pipecat_anva import AnvaVideoService anva = AnvaVideoService(api_key=os.environ["ANVA_API_KEY"], avatar_id="av_...") pipeline = Pipeline([ transport.input(), stt, context_aggregator.user(), llm, tts, anva, # after the TTS, before the transport transport.output(), context_aggregator.assistant(), ]) # Daily: enable video out at the avatar's size DailyParams(video_out_enabled=True, video_out_width=480, video_out_height=480, ...) ``` The service connects to Anva the way the embed player does, over WebRTC, when the pipeline starts, and ends the session on `EndFrame` or `CancelFrame`. Sending is paced by Anva's playback reports, so a long answer is never front-loaded; mono or stereo audio at any sample rate is converted to the 24 kHz mono the lip-sync takes. Both packages live in the [anva-sdk repository](https://github.com/Anva-avatars/anva-sdk/tree/main/integrations) under `integrations/`, with tests that run against a stand-in for Anva and need no account. Shorter introductions to each, for someone who has not met Anva yet: [Anva for LiveKit Agents](https://anva.ai/integrations/livekit) and [Anva for Pipecat](https://anva.ai/integrations/pipecat). ## Create a custom avatar (Enterprise) Creation requires an active Enterprise token plan and an Anva administrator enabling it for your account. Upload a portrait in the app at [Create avatar](https://anva.ai/dashboard/avatars/new), or use the REST endpoints below. API keys keep their existing instance scope for sessions; creation requests and their resulting private avatars belong to the authenticated account. Use a still JPG, PNG or WebP under 10 MB, at least 512 pixels wide and high, and no more than 16 megapixels. We apply a centered 3:4 crop and check the photo for a single visible, front-facing face, suitable framing, sharpness and lighting. Content moderation must pass before a request can proceed. Permission to animate the person’s image is required; checking the box is your attestation, not identity verification. REST · portrait to avatar ``` # Check access and the current quote first. curl https://anva.ai/api/v2/avatar-creations/capabilities \ -H "Authorization: Bearer $ANVA_API_KEY" # Use the returned token_cost. Reuse this key only for retries of this request. curl https://anva.ai/api/v2/avatar-creations \ -H "Authorization: Bearer $ANVA_API_KEY" \ -H "Idempotency-Key: portrait-request-0001" \ -F "name=Alex" \ -F "image=@portrait.jpg" \ -F "rights_confirmed=true" \ -F "token_cost=12000" # Save the returned id, then check progress (every 10–15 seconds). curl https://anva.ai/api/v2/avatar-creations/CREATION_ID \ -H "Authorization: Bearer $ANVA_API_KEY" ``` `POST /api/v2/avatar-creations` takes a multipart upload with `image`, `name`, `token_cost` (the current quote) and `rights_confirmed=true`, plus an `Idempotency-Key` header (16–128 letters, digits, `-` or `_`), and returns `202` with the saved request. Without the header it answers `400 idempotency_key_required`; a missing name or quote is `409 quote_changed`. `GET /api/v2/avatar-creations` lists your requests; `GET /api/v2/avatar-creations/CREATION_ID` returns one request. Use `GET /api/v2/avatars` to find the usable avatar after publication. The SDKs do not include creation helpers yet; use REST for these endpoints. The same holds, in SDK 0.8.x, for [custom voices](https://anva.ai/docs#voice-creation), the voice catalogue’s filters, [prepared sessions](https://anva.ai/docs#standby) (`standby=1` and `activate`) and the `livekit` block on `POST /sessions`. ### Progress and billing States include `reserved`, `queued`, `animating`, `checking`, `preparing`, `trained`, `needs_attention`, `ready` and `failed`. Creation runs by itself: the idle animation is generated, checked automatically — it must stay still enough to animate, with the face unobstructed — and the face model is trained and published to your account without a staff step. An animation that moves too much is regenerated, up to three attempts; if none is usable, the request fails and its tokens are returned (`failed`, `refunded: true`). You can leave the page while work continues. Three creations may be in progress per account. The capabilities response supplies the agreed `token_cost`. The 12,000-token example is not a universal Enterprise contract price. Confirm the quote in your submission. Tokens are reserved once after validation, count as the creation charge when the avatar is published, and are returned if creation fails. Validation failures consume no tokens. Live session usage is billed separately. Reuse the same `Idempotency-Key` after a timeout; a different name, image or quote needs a new key. ### Actionable errors Errors use `{"error":{"code":"face_not_visible","message":"…"}}`. `422` identifies unsuitable input: `image_unreadable`, `image_format`, `image_resolution`, `face_not_visible`, `multiple_faces`, `face_too_small`, `face_cropped`, `face_not_frontal`, `image_blurry`, `image_lighting`, `image_not_allowed` or `rights_required`. Choose a different photo using the message provided. `403 creation_not_enabled` requires account approval; `402 insufficient_tokens` requires a top-up; `409 quote_changed` requires reviewing the new quote; `409 idempotency_conflict` means the same key was used for different inputs; `409 creation_in_progress` means the account already has three unfinished requests. `429` asks you to slow down. `503 image_checks_unavailable` is temporary: retry later without bypassing validation. ## Create a custom voice (Enterprise) Enterprise accounts can make their own voices, from a written description or from recordings of a speaker. A custom voice belongs to the account that made it: it comes first in that account’s `GET /api/v2/voices` (with `owned: true`), works in sessions, presets and the dashboard like any catalogue voice, and no other account can list or use it. An account may hold 10 custom voices; `GET /voices` reports `custom_voices: {enabled, limit, used}`. ### Design a voice from a description Describe the voice in 20–1,000 characters: age, gender, accent, tone and pace. Send `text` (100–1,000 characters) in the language the voice will speak to hear it in that language; without it the previews are in English. Each preview has a `generated_voice_id` and base64 MP3 audio (`audio_base64`). REST · design and save a voice ``` # 1. Describe the voice. Nothing is saved yet; listen to the previews. curl https://anva.ai/api/v2/voices/designs \ -H "Authorization: Bearer $ANVA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"description": "A warm Japanese woman in her sixties, calm and encouraging, unhurried."}' # 2. Save the preview you like, with the same description. curl https://anva.ai/api/v2/voices \ -H "Authorization: Bearer $ANVA_API_KEY" \ -H "Idempotency-Key: voice-request-0001" \ -H "Content-Type: application/json" \ -d '{"name": "Keiko", "generated_voice_id": "GENERATED_VOICE_ID", "description": "A warm Japanese woman in her sixties, calm and encouraging, unhurried."}' ``` ### Clone a speaker Upload 1–10 recordings in `files` fields: MP3, WAV, M4A, AAC, OGG, Opus, FLAC or WebM, each under 10 MB and 50 MB in all. One to three minutes of clear speech from one person, with no music, works best; `remove_background_noise=true` cleans up light noise. `rights_confirmed=true` is required: it is your attestation that the speaker has agreed to their voice being cloned and used in your product, not identity or consent verification. Do not clone anyone who has not agreed, including public figures. REST · clone from recordings ``` # The speaker must have agreed to their voice being cloned and used. curl https://anva.ai/api/v2/voices \ -H "Authorization: Bearer $ANVA_API_KEY" \ -H "Idempotency-Key: voice-request-0002" \ -F "name=Dr Patel" \ -F "rights_confirmed=true" \ -F "files=@sample1.mp3" \ -F "files=@sample2.mp3" ``` ### Using and managing voices Both requests answer `201` with `id`, `name`, `source` (`design` or `clone`), `state` and `voice_id`. Pass the `voice_id` to `POST /sessions`, a preset, or the [Speech API](https://anva.ai/docs#speech-api). A custom voice can speak any language the session supports and keeps its speaker’s accent. `GET /api/v2/voices/ID` reads one voice; `DELETE /api/v2/voices/ID` deletes it permanently, and sessions and presets that use it stop working, so move them to another voice first. Reuse the same `Idempotency-Key` after a timeout: an exact replay returns the same voice, and different inputs answer `409 idempotency_conflict`. A request that failed needs a new key. Designs are limited to 20 an hour and new voices to 10 an hour per account. ### Errors `403 voice_creation_not_enabled` means the account is not on an active Enterprise plan. `409 voice_limit` means the account holds 10 voices; delete one first. `422 rights_required`, `422 samples_required` and `413 sample_size` name the fix. `422 voice_rejected` means the description, preview or recordings could not be made into a voice; the message says what to change. `503 voice_capacity` and `503 voice_creation_unavailable` are temporary. Sessions, presets and the Speech API answer `400 invalid_voice` for another account’s voice. ## Lipsync API (Enterprise) Turn speech audio into mouth animation: the 24 ARKit mouth blendshape curves (`jawOpen`, `mouthClose`, `mouthFunnel`, …) at 30 frames per second, from the same model that animates Anva avatars in live calls. Drive your own rig or 3D character with them. The Lipsync API is available on the Enterprise plan (`403 enterprise_required` otherwise) and billed at 10 tokens per minute of audio processed. `GET /capabilities` reports `lipsync.available`. ### One clip POST `/lipsync` with the audio as the request body: WAV, FLAC or OGG as is, or raw 16-bit little-endian mono PCM with `Content-Type: audio/pcm` and `?sample_rate=`. A clip is at most 10 minutes and 64 MB, and a container may claim at most 48,000 Hz and 2 channels (`400 invalid_audio` otherwise). `?preset=lowlat` answers with a shorter look-ahead at a small cost in accuracy; the default is `hybrid`. The clip is priced before it runs: `402 insufficient_tokens` when the balance cannot pay for it, `402 credits_exhausted` when the balance is empty, and `503 billing_unavailable` when it could not be read (retry shortly). Request ``` curl -X POST https://anva.ai/api/v2/lipsync \ -H "Authorization: Bearer anva_key_..." \ -H "Content-Type: audio/wav" --data-binary @line.wav ``` Response ``` { "id": "lipsync_...", "fps": 30, "frame_count": 90, "duration_s": 3.0, "channels": ["jawOpen", "mouthClose", "mouthFunnel", "..."], "preset": "hybrid", "model": "audio_head_v15_hybrid", "frames": [[0.0123, 0.0, 0.0412, ...], ...], "billing": {"unit": "tokens", "basis": "audio_time", "seconds": 3.0, "tokens_per_minute": 10} } ``` Frame `n` describes the mouth `n / 30` seconds into the audio; each row follows `channels` and holds ARKit blendshape weights (0 is at rest). A 10-second clip takes about 0.2 s to compute with either preset; most of the round trip is the upload. ### Channels Always these 24, in this order, for clips and streams alike (`tongueOut` is not included): channels ``` jawOpen, mouthClose, mouthFunnel, mouthPucker, mouthLeft, mouthRight, mouthSmileLeft, mouthSmileRight, mouthFrownLeft, mouthFrownRight, mouthDimpleLeft, mouthDimpleRight, mouthStretchLeft, mouthStretchRight, mouthRollLower, mouthRollUpper, mouthShrugLower, mouthShrugUpper, mouthPressLeft, mouthPressRight, mouthLowerDownLeft, mouthLowerDownRight, mouthUpperUpLeft, mouthUpperUpRight ``` ### A stream Open WS `/lipsync/stream?sample_rate=16000` (or `24000`, the default) with the same bearer key. The first frame is `ready`, with `fps`, `channels`, `input_sample_rate`, `delay_ms`, `preset`, `model` and `release`. Send binary frames of 16-bit little-endian mono PCM (at most 192 KB each); curves arrive as `{"type": "frames", "start": n, "values": [...]}` as soon as the model has settled them, `delay_ms` behind the audio. Send `{"type": "flush"}` at the end of an utterance for the remaining frames and a `flushed` frame; the next audio starts again at frame 0. `{"type": "close"}` ends the stream. `delay_ms` is about 33 ms with `preset=lowlat` and 133 ms with `hybrid`. Keep one stream open across lines and `flush` between them: opening a socket costs a TLS and WebSocket handshake, flushing costs nothing. A stream that carries no audio or command for 60 seconds is closed after a `{"type": "error", "code": "idle_timeout"}` frame; open a new one for the next line. Closing without a flush is fine. A stream carries at most 7,200 seconds of audio in all; past that it answers `audio_too_long`. Error frames are `{"type": "error", "code", "message"}` with `invalid_audio`, `audio_too_long`, `bad_request`, `credits_exhausted` or `idle_timeout`. ### Limits and billing - An account runs at most 8 Lipsync jobs at once, clips and open streams together; the next answers `429 lipsync_concurrency_limit` with `Retry-After`. Ask us if you need more. - When the serving node is at capacity the API answers `503 lipsync_busy` with `Retry-After`; retry after that many seconds. - Billing is pro rata on audio time, to a thousandth of a token: nothing is rounded up per request or per minute. A stream is billed for the audio you send, not for how long it stays open, settled every ten seconds. It does not open without a second’s balance (`402 insufficient_tokens`), and it ends with `credits_exhausted` and close code 1008 once the balance cannot pay for the next second. - Refused and failed requests (`4xx`, `lipsync_busy`, `lipsync_failed`) are never billed. The [live lipsync demo](https://anva.ai/lipsync) shows the same model driving an Anva avatar from your microphone. [Try the live demo](https://anva.ai/lipsync) [Join our Discord](https://discord.gg/JyV4uY5xq) ## Self-hosting (Enterprise) Run your Anva avatars on your own hardware: speech audio in, avatar video frames out, with the same face stack that animates Anva’s live calls (the [V15 lipsync model](https://anva.ai/docs#lipsync-api), the behaviour layer for blinks, gaze and head motion, and each avatar’s own face model). Everything runs on CPU, with no GPU and no network access at run time. Bring your own voice: the kit does not include TTS, speech recognition or a language model. Self-hosting is available on the Enterprise plan under a self-hosting agreement. Anva enables it per account; ask us to set it up. Until then, the endpoint below answers `403 self_host_not_enabled`. Downloads stop if the Enterprise plan lapses. ### What you get - The inference kit (`anva-selfhost`, Python): the `fetch`, `check`, `list` and `render` commands, an `AvatarStream` class for live audio, examples and full instructions. - The V15 lipsync model: the same model the Lipsync API serves. - Your avatars’ face models: every avatar your account owns, about 45 MB each. Built-in avatars are not included. Requirements: Python 3.10 or newer on Linux x86-64 or macOS, and ffmpeg to add sound to rendered MP4s. One avatar at 24 fps needs about two CPU cores (roughly 10–25 ms per frame). ### Install and fetch Download the kit from the dashboard docs page (shown once self-hosting is enabled) or with `anva-selfhost fetch --kit-dir .`, then: shell ``` tar xzf anva-selfhost-1.0.0.tar.gz && cd anva-selfhost-1.0.0 python3 -m venv .venv && . .venv/bin/activate pip install --index-url https://download.pytorch.org/whl/cpu torch==2.13.0 torchaudio==2.11.0 pip install . export ANVA_API_KEY=anva_key_... anva-selfhost fetch # your lipsync model and avatars, checksummed anva-selfhost render --avatar "Ava" speech.wav out.mp4 ``` `fetch` checks every file’s SHA-256 and only downloads what changed, so run it again after an avatar is retrained or a new lipsync model is released. It also tells you when a newer kit is available. ### Stream frames python ``` from anva_selfhost import AvatarStream stream = AvatarStream("Ava") # load once frame = stream.next_frame() # RGB frame; call every 1/24 s, speaking or not stream.begin_speech() # your TTS or voice agent starts a line stream.push_audio(chunk, 24000) # each audio chunk as it arrives stream.end_speech() # after the last chunk ``` The kit’s README covers sync with your audio player, interruptions and sizing. ### The artifacts endpoint GET `/self-host/artifacts` lists everything your account may download, each file with a link that expires after about 15 minutes, its size and its SHA-256. `anva-selfhost fetch` calls it for you; call it directly to mirror the files into your own storage. Each call is recorded against the account and the API key that made it. Request ``` curl https://anva.ai/api/v2/self-host/artifacts \ -H "Authorization: Bearer $ANVA_API_KEY" ``` Response ``` { "account": "acme", "expires_at": "2026-10-18T07:15:00Z", "kit": {"version": "1.0.0", "file": {"name": "anva-selfhost-1.0.0.tar.gz", "url": "https://…", "sha256": "…", "size": 774806}}, "lipsync": {"release": "audio_head_v15_hybrid", "version": "v15-20260915", "file": {"name": "…", "url": "https://…", "sha256": "…", "size": 9992800}}, "avatars": [ {"id": "…", "name": "Ava", "files": [{"name": "best.onnx", "url": "https://…", "sha256": "…", "size": 10432051}, …]} ] } ``` An avatar that cannot be downloaded right now (for example while it is being retrained) is listed with `unavailable` and no `files`. Errors: `403 self_host_not_enabled`; `503 self_host_unavailable` is temporary, so retry shortly. The kit and models are licensed to your organisation under its agreement. They may not be redistributed. ## Speech API (Enterprise) Turn text into speech and mouth animation in one call: Anva's TTS audio (24 kHz, 16-bit mono) plus the same 24 ARKit mouth curves as the Lipsync API, measured on that audio and on the same clock. Lines are spoken with the Anva Realtime Lite voices, so a line sounds and moves as `say` does in a default `byo_llm` session. The Speech API is available on the Enterprise plan (`403 enterprise_required` otherwise) and billed at 120 tokens per minute of audio generated. `GET /capabilities` reports `speech.available`. ### One line POST `/speech` with a JSON body: `text` (at most 2,000 characters), `voice_id` (a catalogue voice from `GET /voices`, or one of your own [custom voices](https://anva.ai/docs#voice-creation)), and optionally `speed` (0.7 to 1.2, default 0.85), `preset` (`hybrid`, the default, or `lowlat`) and `format` (`wav`, the default, or `pcm` for raw 16-bit little-endian samples). Request ``` curl -X POST https://anva.ai/api/v2/speech \ -H "Authorization: Bearer anva_key_..." -H "Content-Type: application/json" \ -d '{"text": "Welcome back.", "voice_id": "elevenlabs:JBFqnCBsd6RMkjVDRZzb"}' ``` Response ``` { "id": "speech_...", "voice_id": "elevenlabs:JBFqnCBsd6RMkjVDRZzb", "sample_rate": 24000, "duration_s": 1.212, "audio": {"format": "wav", "data": "UklGR..."}, "curves": {"fps": 30, "channels": ["jawOpen", "mouthClose", "..."], "frame_count": 37, "preset": "hybrid", "model": "audio_head_v15_hybrid", "frames": [[0.0123, 0.0, ...], ...]}, "alignment": {"characters": ["W", "e", "..."], "starts": [0.0, 0.07, ...], "ends": [0.07, 0.12, ...]}, "billing": {"unit": "tokens", "basis": "audio_time", "seconds": 1.212, "tokens_per_minute": 120} } ``` Sample `s` plays at `s / 24000` seconds and frame `n` describes the mouth `n / 30` seconds into the audio, exactly as for `POST /lipsync`: a line has `frame_count = ceil(samples / 800)` frames, each following `channels` (the 24 Lipsync channels, in the same order). `alignment`, when the voice provider returns it, times each character in seconds on the same clock. ### A stream Open WS `/speech/stream` with the same bearer key (`?voice_id=`, `?speed=` and `?preset=` set defaults). After `ready`, send `{"type": "speak", "id": "line-1", "text": "..."}` (optionally with `voice_id`, `speed`, `preset`). For each line you receive `curves` (`start`, `values`), `audio` (`start_sample`, `samples`, base64 PCM in `data`), `alignment` and finally `done` (`total_samples`, `frame_count`), each carrying your `id`. Curves always arrive before the audio they describe. One line is spoken at a time: a `speak` while a line is running is refused with `busy_line`, not queued. `{"type": "cancel", "id": "line-1"}` stops a line (it ends with `cancelled`); without `id` it stops the current line, and an unknown or finished `id` is ignored; `{"type": "close"}` ends the stream. A stream with no command for 60 seconds while nothing is being spoken is closed after an `idle_timeout` error. ### Voices, limits and billing - `voice_id` must be a catalogue voice or one of your account’s own custom voices, as `GET /voices` lists them; any other id (another account’s custom voice, or a provider library id outside the catalogue) answers `400 invalid_voice`. A voice the provider refuses answers `422 voice_unavailable`. - A line is at most 2,000 characters and 180 seconds of audio (`413 text_too_long`). - An account holds at most 4 Speech requests and streams at once; the next answers `429 speech_concurrency_limit` with `Retry-After`. A busy serving node answers `503 speech_busy` with `Retry-After`. - Billing is pro rata on the audio generated. A line is priced before synthesis at the most audio its text may become; when the balance cannot pay that, the answer is `402 insufficient_tokens`, and a stream ends with `credits_exhausted` (close 1008) when the balance runs out. Refused and failed requests are never billed. ## Languages & Voices Every preset has a `language_code` (speech recognition) and a `voice_id` (speech synthesis) — set on `POST /presets`, changed with `PATCH /presets/{id}`. The preset is the default: `POST /sessions` can override `language_code`, `voice_id`, and `system_prompt` for that one session, so a single scenario preset can run in any target language (the same tutor drilling German in one session and Japanese in the next). Enumerate valid values at runtime with `GET /languages` and `GET /voices` instead of hardcoding. ### Speech recognition (what the avatar hears) Recognition runs in one of three tiers. Fastest and fast use a conversational turn-taking model (natural interruptions, lowest response latency); standard uses a general model with broader coverage and slightly higher latency. The default, `multi`, auto-detects between English, Spanish, French, German, Hindi, Russian, Portuguese, Japanese, Italian, and Dutch mid-conversation — pin a single code when you know the language: it is faster and more accurate than auto-detect. | Tier | Codes | |---|---| | (Fastest) | `en` | | (Fast) | `multi` (auto-detect) · `de` German · `fr` French · `es` Spanish · `it` Italian · `nl` Dutch · `pt` Portuguese · `hi` Hindi · `ru` Russian · `ja` Japanese | | (Standard) | `sv` Swedish · `no` Norwegian · `da` Danish · `fi` Finnish · `pl` Polish · `cs` Czech · `sk` Slovak · `ro` Romanian · `bg` Bulgarian · `hr` Croatian · `uk` Ukrainian · `el` Greek · `hu` Hungarian · `zh` Chinese · `ko` Korean · `id` Indonesian · `tl` Filipino · `ms` Malay · `ta` Tamil · `vi` Vietnamese · `ar` Arabic · `tr` Turkish | Unknown codes never fail — they fall back to `multi`. The response of `GET /languages` carries the tier per code in its `recognition` field. ### Speech synthesis (how the avatar sounds) Synthesis is natively multilingual across all languages listed above — the avatar speaks whatever language the reply text is in. The `voice_id` controls timbre and accent; for the most native-sounding result in a specific language, pick (or bring) a voice recorded for it. The catalogue is at `GET /voices`, after your account’s own voices; Enterprise accounts can [design or clone custom voices](https://anva.ai/docs#voice-creation). Every voice has a preview URL, a description, and search facets: `language`, `accent`, `gender`, `age` and `tones` (natural, warm, calm, authoritative, bright, storyteller). Narrow the list with `?language=ja&gender=female&tone=calm` or `q=` words; `facets` counts how many voices each value holds. Pass a `voice_id` from `GET /voices`, or any ElevenLabs voice as `elevenlabs:`; any other value is `400 invalid_voice`. Avatar Only sessions take no `voice_id` (`400 bad_request`): your audio is the voice. Set the speaking rate with `speech_speed` on `POST /sessions`: 0.7 (slowest) to 1.2, where 1 is the voice’s natural pace and Anva’s default is 0.85. Slow it down for beginners, or per line with `say`’s `speed`. It applies to `anva_light` (Anva Realtime Lite) and `byo_llm` voices. Anva Realtime (`anva_standard`) has no speaking-rate control: `speech_speed` with `anva_standard`, or a per-line `speed` on a session using its voice, is refused with `400 speed_unsupported`. A session that sets `speech_speed` and names no mode is created as `anva_light`. The expressive voice (`anva_expressive`) ignores the rate, and an ElevenLabs agent (`elevenagents_max`) uses its own configured rate. A rate outside 0.7–1.2 is `400 bad_service_mode`. ### Reply language (what the avatar says) The built-in LLM replies in the user's language by default. To pin it — or to build deliberate mixed-language behavior, e.g. a language tutor that explains in English but drills in German — say so in the preset's `system_prompt`, or take full control with `service_mode: "byo_llm"` (Developer+). ## Test vs Production URLs The quickest way to ship an avatar is a stable embed URL you drop straight into an iframe — no per-request API call, no backend. When you create an embed (a saved preset: an avatar + voice + prompt) in the [Playground](https://anva.ai/dashboard/playground) under Create embed URL, you get two URLs for it, and the difference is the single most important thing to understand before you go live: Test URL `works on localhost + any domain` Production URL `only works on allow-listed domains` | | Test URL | Production URL | |---|---|---| | Where it works | `localhost`, `127.0.0.1`, and any domain | Only domains you allow-list (`allowed_hosts`); every other origin gets `403` | | Use it for | Local development & staging | Your live product | | Rate limit | Rate-limited — never ship to production | Full production capacity | | Active when | Always | Only after you add at least one domain (otherwise `403` — "no allowed hosts configured yet") | Both are ordinary URLs of the form `https://anva.ai/embed/?session_url_token=…`. During development you use the Test URL; when you deploy you allow-list your domain(s) and switch the iframe to the Production URL. 1. Create the embed In the [Playground](https://anva.ai/dashboard/playground), tune the avatar/voice/prompt and click Create embed URL. You immediately get a Test URL and a Production URL. Leave the domain field empty for now — you only need the Test URL to start. (An embed is a preset under the hood, so you can also create one with [`POST /presets`](https://anva.ai/docs#reference).) 2. Develop on localhost with the Test URL Paste the Test URL into an iframe on your local page — it just works: localhost — index.html ``` ``` No domain setup, no allow-list — the Test URL is the one for local dev. It is rate-limited and must never be shipped to real users. 3. Add your production domain(s) When you're ready to deploy, allow-list the domain(s) that will host the iframe. Do it in the dashboard (the embed's Manage → edit page) or over the API with `PATCH /presets/{id}`. A bare host matches that host exactly; a `*.example.com` wildcard matches any subdomain. Entries are hostnames: a pasted URL is reduced to its host and ports are ignored, and an entry that is not a dotted hostname (such as `localhost`) is silently dropped, so check `allowed_hosts` in the response. From then on the per-session `embed_url`s you create from this preset are locked to the same domains. Terminal ``` curl -X PATCH https://anva.ai/api/v2/presets/abc123 \ -H "Authorization: Bearer anva_key_..." \ -H "Content-Type: application/json" \ -d '{"allowed_hosts":["example.com","*.example.com"]}' ``` fetch ``` await fetch("https://anva.ai/api/v2/presets/abc123", { method: "PATCH", headers: { "Authorization": "Bearer anva_key_...", "Content-Type": "application/json" }, body: JSON.stringify({ allowed_hosts: ["example.com", "*.example.com"] }) }); ``` requests ``` import requests requests.patch( "https://anva.ai/api/v2/presets/abc123", headers={"Authorization": "Bearer anva_key_..."}, json={"allowed_hosts": ["example.com", "*.example.com"]}, ) ``` 4. Go live with the Production URL Swap the iframe `src` to the Production URL and deploy: production — served from example.com ``` ``` Loaded from an allow-listed domain it runs at full capacity. Loaded from anywhere else it returns `403` — see [Common Questions](https://anva.ai/docs#faq) if your Production URL 403s. Two ways to embed, pick one. These Test / Production URLs are stable and reusable — best when you just want an avatar on a page. The [per-session `embed_url`](https://anva.ai/docs#embed) from `POST /sessions` is short-lived and single-session — best when your backend mints one per visitor and you want the events WebSocket or webhooks. Both open the same avatar. A per-session `embed_url` made from a preset follows that preset’s `allowed_hosts`: once the preset lists any domain, its per-session embeds load only on those domains. Develop against a preset without allowed domains, or create the session with `avatar_id`, which is never host-locked. ## Embed Sessions (Requires Developer plan) POST `/sessions` returns a short-lived session grant. Put `embed_url` in an iframe to start a voice/avatar session inside your application — managed modes handle the conversation. BYO LLM also needs your reply controller; Avatar Only needs your audio producer. Response ``` 201 Created { "session_id": "apisess-...", "session_token": "anva_gr_...", "status": "pending", "instance_id": "inst_...", "preset_id": "abc123", "avatar_id": "", "service_mode": "anva_standard", "speech_input": "on", "language_code": "", "voice_id": "", "billing": {"unit":"tokens","tokens_per_minute":70,"rate_version":"2026-09-10","basis":"connected_time","service_mode":"anva_standard"}, "expires_at": "2026-09-10T18:20:00Z", "max_duration_seconds": 7200, "metadata": {}, "embed_url": "https://.../embed/?session_url_token=anva_gr_...", "events_ws_url": "wss://.../api/v2/sessions/apisess-.../events" } ``` The response also echoes `llm_mode` and the resolved performance options (`conversation_provider`, `performance_mode`, `dynamic_expressions`, `speech_speed`, `wake_up`). `status` is `pending` until the visitor’s embed connects; a session that joins a [LiveKit room](https://anva.ai/docs#integrations) answers `active`, with a `livekit` block instead of `embed_url`. An embed session may pass per-session overrides of the preset — `voice_id`, `system_prompt`, and `language_code` — which apply to that session only (omit to inherit the preset default). The grant must be redeemed (the iframe opened) within 15 minutes. Once the session is live, the grant stays valid until that session ends, so page reloads and reconnects keep working. Grant records persist server-side; live media and conversation state do not survive a worker restart. If the session has ended, create a fresh grant instead of reusing its URL. Creating a session runs the plan admission check — see [Plans & Pricing](https://anva.ai/docs#plans) for the `402` refusals. ## Session lifecycle Create a fresh session for each visitor. The session starts when the visitor’s browser opens `embed_url`, and billing runs while that embed is connected (a [`standby=1`](https://anva.ai/docs#standby) call bills from its activation); the events socket never starts a session on its own. Wait for the embed’s `ready` event or the controller socket’s `session.live` before sending commands. If the visitor’s connection drops, billing pauses, and the session ends unless they reconnect within about 30 seconds. A session also ends, with reason `idle`, after 10 minutes without input: nothing recognised from the visitor and no message, host line, speech or other command from your server. When the visitor leaves, close the iframe and control socket, and call `DELETE /api/v2/sessions/{id}` from your server. Listen to the socket’s close/error events. Reconnect with bounded backoff while the grant is live; after `session_ended` or an expired grant, create a new one. Do not automatically recreate sessions in an unbounded loop. Every session also ends on its own `max_duration_seconds` after it first goes live; reconnects do not restart the clock. Pass it on `POST /sessions` (60–7200), or omit it for the 2-hour ceiling. Once the session is live, `GET /sessions/{id}` and `session.info` report `ends_at`. At the limit the session ends with reason `max_duration` on the socket and the `session.ended` webhook, and billing stops. Still call `DELETE` when the visitor leaves: the limit is a backstop, not a timer. When your server ends a session — `DELETE`, the duration limit, an exhausted balance — the embed closes on a neutral “This conversation has ended” screen and posts `session.ended` `{session_id, reason}` to the parent page, so you can show your own next step. Word the screen yourself with `end_message` on the embed URL, for example `embed_url + "&end_message=" + encodeURIComponent("Lesson complete — see you tomorrow!")` (up to 160 characters of plain text; see [Embed URL options](https://anva.ai/docs#embed-url-options) for the others). A plan refusal posts `plan.limit` and then `session.failed`; otherwise only a genuine failure shows an error and posts `session.failed`. To bill exactly, read `GET /sessions/{id}` after the session ends: its `usage` is `{seconds, tokens, unit}` from Anva’s own meter — what the session was actually charged, not an estimate — and it stays readable for 7 days. While the session runs it reports usage so far. A session that never went live, or one on an unmetered account, has no `usage`. Make creation safe to retry: send an `Idempotency-Key` header (16–128 letters, digits, `-` or `_`) on `POST /sessions`. A retry with the same key and body within 24 hours returns the original session instead of creating another, with the header `Idempotent-Replayed: true`; the same key with a different body gets `409 idempotency_conflict`. Only a successful create is remembered, so a failed attempt can be retried under the same key. Keys are scoped to your API key, the body must match byte for byte (re-serialising the JSON can change it), and a malformed key is `400 bad_request`. Keys are held in memory at Anva’s edge, so a deploy can forget one: this protects against retry storms, and is not a record to rely on for days. Serve the embedding app over HTTPS (localhost is suitable for development) and include `allow="microphone; autoplay"` on the iframe. The embed starts the call and asks for the microphone as soon as it loads, and has no start, mute or end buttons of its own: to tie the permission prompt to the visitor’s action, add the iframe when they choose to talk, and use the `mute` and `disconnect` commands or `DELETE` to control it. Avatar Only uses supplied audio and does not need to capture the visitor’s microphone, and neither does a session created with `speech_input: "off"`. A managed call’s voice is audible through the media player. End a visitor’s session — server-side ``` await fetch("https://anva.ai/api/v2/sessions/" + session.session_id, { method: "DELETE", headers: { Authorization: "Bearer " + process.env.ANVA_API_KEY } }); ``` ## Embed Events (postMessage) The iframe posts session events to its parent window, so the embedding page can react without any server code. Events are `{source: "anva", type, payload}`: Listener ``` const frame = document.querySelector("#anva-avatar"); const embedOrigin = new URL(frame.src).origin; window.addEventListener("message", (e) => { if (e.origin !== embedOrigin || e.source !== frame.contentWindow) return; if (e.data && e.data.source === "anva") { console.log(e.data.type, e.data.payload); } }); ``` | Type | Payload | Meaning | |---|---|---| | `ready` | `{session_id}` | The session is connected and live. Posted again after every automatic reconnect. | | `media.first_frame` | `{session_id, at_ms, width, height}` | The first frame of this connection reached the screen, at `at_ms` epoch milliseconds (comparable with your `Date.now()`). Sent again after a reconnect. | | `session.standby` / `session.activated` | `{session_id, expires_at_ms}` / `{session_id}` | A [`standby=1`](https://anva.ai/docs#standby) call is prepared (video ready, nothing billed; `expires_at_ms` may be `null`) / was activated. | | `presentation.cue` | `{cue_id, index, turn_id, generation, phase, spoken_offset_ms, at_ms, …}` | A registered visual starts, ends or is cancelled as its words are heard — see [Cues](https://anva.ai/docs#presentation-cues). | | `state` | `{state, speech_paused, user_speaking}` | Idle, listening, thinking, or speaking. | | `transcript` | `{role, text, final, say_id?, interrupted?}` | User speech heard, or assistant reply text. A host line’s captions carry its `say_id`. | | `turn.complete` | `{spoke, ttfa_s, first_text_s, timing_origin, say_id?}` | The assistant finished a turn (the same payload as on the events WebSocket); `say_id` names a host line. | | `line.ended` | `{say_id, status, reason, spoken_text, spoken_until_ms?}` | A host line (`say`) ended — see [Host lines](https://anva.ai/docs#host-lines). | | `control.speech_state` | `{speaking, content_s, generation, barge_in}` | Avatar audio started (`speaking: true`) or stopped playing. `content_s` is the position in the current audio generation: it resets after an interrupt and is not per line. | | `control.interrupt` | `{generation}` or `{reason}` | Playback stopped (barge-in, a new line, an interrupt): `{generation}`. An interrupt sent through the API is also announced at once as `{reason: "api"}`, without a generation, and one from the visitor’s page can arrive as `{reason: "manual"}`. | | `session.ended` | `{session_id, reason}` | The session ended on purpose (your `DELETE` is `ended_by_host`; also `viewer_left`, `max_duration`, `idle`, `credits_exhausted` — see [end reasons](https://anva.ai/docs#end-reasons)); the embed shows its end screen. A `core_disconnected` ending arrives as `error` and then `session.failed` instead. | | `session.closed` | `{session_id, reason}` | Your page ended the call with the `disconnect` command (`reason: "client_disconnect"`). No end screen is shown. | | `session.failed` | `{message}` | The session failed and the embed shows an error. Also follows `plan.limit`. | | `session.reconnecting` / `session.reconnected` | `{attempt, reason}` / `{attempts}` | The visitor’s connection dropped and is being restored (up to three attempts, then `session.failed`). | | `avatar.warming` | `{retries, progress, eta_seconds}` | The avatar is being loaded onto a session node; the embed retries by itself. | | `session.at_capacity` | `{retries}` | No session capacity is free yet; the embed retries by itself (up to 12 times, then `session.failed`). | | `plan.limit` | `{message}` | The session was refused for plan reasons (balance exhausted, concurrency cap, plan too low). The iframe shows its own notice and then posts `session.failed`; react to `plan.limit` first. | | `feedback.changed` | `{indicator, words}` | The call line’s settings, in answer to `feedback.configure` or after the visitor toggles their words. | | `presentation.state` / `presentation.action` | object | Presenter context accepted or refused / the avatar moved between its items. | | `error` | `{code, message, say_id?}` | Session or engine error — see [error events](https://anva.ai/docs#error-events). The embed adds its own: `microphone_unavailable` or `unsupported_browser` when the microphone cannot be opened, and `activate_failed` when an `activate` could not reach the call (try again). | The embed also forwards traffic most pages can ignore: `expression.state`, and `ambient` `{top, bottom}` — colours sampled from the avatar’s background every two seconds, for pages that want to blend their surroundings into it. Filter on `type` and ignore anything you do not use; new types may be added. The avatar’s face channels (`controls`) are not posted to the page; read them from the events WebSocket. If the embed URL is refused — an expired or unknown grant, or a page that is not on the preset’s allowed domains — the iframe shows a static error page and posts nothing, so time out if `ready` never arrives. Events go only to the embedding page’s origin, which the player reads from the page’s referrer. If your page sends no referrer (`Referrer-Policy: no-referrer`), post any command to the player first, for example `feedback.configure`. Until then it sends only `ready`, `session.failed`, `session.ended`, `session.closed`, `session.reconnecting`, `session.reconnected`, `session.at_capacity`, `avatar.warming`, `plan.limit` and `feedback.changed`, without `session_id`, and drops everything else: transcripts and other conversation events, and also `media.first_frame`, `session.standby`, `session.activated`, the `presentation.*` events and `ambient`. When your first command arrives during a live call, it sends `ready` again with `session_id`. ### Drawing your own interface: `ui=none` Add `ui=none` to the embed URL when your page draws everything around the avatar. The player then shows the avatar’s video and nothing else at any point — no status line, captions, connecting portrait or chip, reconnect notice, end or error card — and its page is transparent, so what your page shows behind the iframe stays visible until the first frame, during a reconnect and after the end. Every state still arrives as an event above for your interface to show. `feather=N` (0–160 CSS pixels) fades the frame’s edges into what is behind it; add `feather_shape=oval` to fade it as a soft ellipse, which leaves no corner or straight edge where the video meets your page. `ambient=1` sends the `ambient` event (the colours of the video’s own backdrop) so your page can paint its background to match. Size the iframe to the avatar’s aspect ratio (`width`/`height` in `media.first_frame`; 3:4 for custom avatars) to fill it edge to edge. Media only ``` ``` The player draws no AI label in any mode. Where the law or your own policy requires telling people they are talking to an AI, say so in your interface, as Anva’s terms require. ### Preparing the next avatar: `standby=1` Add `standby=1` to the embed URL to prepare a call before the visitor needs it. The avatar’s video connects and it idles, ready to show, but no microphone opens, the conversation does not start and the session is not billed. The iframe posts `session.standby` `{session_id, expires_at_ms}` once the video plays (`expires_at_ms` may be `null` when the player does not know the window); on the events socket, `session.info` and `session.live` carry `standby: true` and `standby_expires_at` until activation (`GET /sessions/{id}` does not). When the visitor arrives, post `activate` (or call `POST /sessions/{id}/activate`, or send `activate` on the events socket): the same connection goes live. The iframe asks for the microphone first when the session listens — if it is refused the call stays prepared and unbilled, and the iframe posts `error` `{code: "microphone_unavailable", message}` — then the conversation, microphone and billing start and `session.activated` is posted. A prepared session not activated within 120 seconds ends with reason `standby_expired`. An account holds at most two at once and prepares at most 30 in a rolling hour, activated or not (`402 standby_limit`, posted as `plan.limit`), each counting towards the plan’s simultaneous sessions; `anva_light`, `anva_standard`, `anva_expressive` and `byo_llm` can be prepared. Until activation, `message`, `say` and `say.delta` (queued lines too; nothing is held for later) and `presentation.start` answer `409 session_standby` (an `error` frame with that code on the socket), so send the opening line after `session.activated`. Other commands pass: `context.update` is accepted so the presenter context can be ready first. Activating a live session changes nothing: `POST /sessions/{id}/activate` answers `200` `{status: "active"}`, and the embed’s `activate` only posts `session.activated` again. `speech_input` is fixed when the session is created; preparing only defers the microphone to activation. `DELETE` before activation ends the session with `ended_by_host` and bills nothing. The iframe also accepts commands from the parent window. It acts on a command only when it comes from its parent window, from the embedding page’s origin, with `source: "anva"`: Commands ``` const target = new URL(frame.src).origin; frame.contentWindow.postMessage({source: "anva", type: "message", payload: {text: "Hello!"}}, target); frame.contentWindow.postMessage({source: "anva", type: "interrupt"}, target); frame.contentWindow.postMessage({source: "anva", type: "mute", payload: {enabled: true}}, target); frame.contentWindow.postMessage({source: "anva", type: "feedback.configure", payload: {indicator: true, words: false}}, target); frame.contentWindow.postMessage({source: "anva", type: "disconnect"}, target); frame.contentWindow.postMessage({source: "anva", type: "activate"}, target); // a standby=1 call frame.contentWindow.postMessage({source: "anva", type: "context.update", payload: context}, target); frame.contentWindow.postMessage({source: "anva", type: "presentation.start", payload: {version: 1, demo: "product", revision: 1}}, target); ``` `message` needs a live call (send it after `ready`) and is not acknowledged. `mute` without a payload mutes. `disconnect` ends the call and the session and posts `session.closed`. `activate` takes a [`standby=1`](https://anva.ai/docs#standby) call live; on a live call it only posts `session.activated` again. `context.update` and `presentation.start` take the same payloads as on the events socket (see [Presenter context](https://anva.ai/docs#presentation-api)) and are answered with `presentation.state`; if the call’s channel is not open yet, the player answers `presentation.state` `{accepted: false, reason: "channel_not_ready"}` itself. `feedback.configure` shows or hides the call line under the portrait (`indicator`) and the visitor’s own latest words (`words`); the iframe answers `feedback.changed` with the current settings, so an empty payload asks for them. The choice is remembered in the visitor’s browser. Reloading the iframe during a call rejoins the same call. The same embed URL opened in a second frame or tab of the same site takes the call over, and the first shows “Session opened elsewhere”. ### Embed URL options Append these to `embed_url`, or to a Test or Production URL: | Parameter | Effect | |---|---| | `end_message` | Your wording for the end screen: plain text, up to 160 characters (see [Session lifecycle](https://anva.ai/docs#session-lifecycle)). | | `overlay=0` or `overlay=1` | Hide or show the voice indicator, the line under the portrait that moves with the conversation. | | `words=0` or `words=1` | Hide or show the visitor’s latest words. No effect in `avatar_only` sessions or without speech recognition. | | `chrome=0` | Hide the whole call line: indicator, words, their toggle and the status text. The end and error screens still show. | | `ui=none` | Show the avatar’s video and nothing else, on a transparent page: no call line, captions, connecting portrait, reconnect notice, end or error screen. Every state still arrives as an event (see [Drawing your own interface](https://anva.ai/docs#ui-none)). | | `feather=N` | With `ui=none`: fade the frame’s edges over `N` CSS pixels (0–160) into what is behind the iframe. | | `feather_shape=oval` | With `ui=none`: fade the frame as a soft ellipse instead of four straight edges (a quarter of the frame’s shorter side, or `feather` pixels). | | `ambient=1` | With `ui=none`: send the `ambient` event every 2 s so your page can match its background to the video’s backdrop. | | `standby=1` | Prepare the call: video connects and the avatar idles, with no microphone, conversation or billing until you send `activate` (see [Preparing the next avatar](https://anva.ai/docs#standby)). | `overlay` and `words` override, for that load only, the choice the visitor’s browser remembers; without either, both are on. ## Advanced Sessions (Requires Developer plan) Bring your own app instead of a stored preset: create the session with an `avatar_id` plus an inline persona (`system_prompt`, `voice_id`, `language_code`) — nothing is saved. Drive it over the [events WebSocket](https://anva.ai/docs#events) (Developer+) or the REST `messages` / `interrupt` / `actions` endpoints, read state and transcript with `GET /sessions/{id}`, and add `service_mode: "byo_llm"` (Developer+) to run your own LLM — see [External LLM](https://anva.ai/docs#external-llm). Advanced create ``` curl https://anva.ai/api/v2/sessions \ -H "Authorization: Bearer anva_key_..." \ -H "Content-Type: application/json" \ -d '{"avatar_id":"av_abc","service_mode":"anva_standard","system_prompt":"You are a terse support agent.","voice_id":"elevenlabs:oPU3sXizBk7l2JHYrFra","language_code":"en"}' ``` Send either `preset_id` (embed) or `avatar_id` (advanced), never both — sending both is `400 bad_request`. The avatar must be one your account is allowed to use, otherwise `403 avatar_not_allowed`. The session starts when the visitor’s browser opens `embed_url`; the events WebSocket observes and controls it. ### Send a message after the session is ready Use the authenticated [events WebSocket](https://anva.ai/docs#events) and wait for `session.live`, which arrives once the visitor’s browser has opened `embed_url`. The control socket carries events and commands; it never starts the session. Node.js controller (npm install ws) ``` import WebSocket from "ws"; // session is your POST /sessions response. Keep the API key server-side. const ws = new WebSocket(session.events_ws_url, { headers: { Authorization: "Bearer " + process.env.ANVA_API_KEY } }); let sent = false; ws.on("message", raw => { const event = JSON.parse(raw.toString()); if (event.type === "session.live" && !sent) { sent = true; ws.send(JSON.stringify({ type: "message", payload: { text: "How do I reset my password?" } })); } if (event.type === "transcript") { console.log(event.payload.role, event.payload.text); } // Handle session.ended, errors and close; see Session lifecycle. }); ws.on("error", error => console.error(error.message)); ws.on("close", () => { /* stop producers and handle cleanup */ }); ``` `message` injects visitor text; a managed mode generates the reply. In `byo_llm`, handle `turn.request` and supply your reply using `turn.delta` and `turn.done`. Avatar Only accepts PCM speech commands instead of visitor text. ## Startup & readiness A `201` session response creates authorization for a call; it is not a media-readiness signal. The embed handles media setup and reports `ready` to its parent. A controller WebSocket sends `session.info` at once and `session.live` when the visitor’s media is connected. Keep a loading state visible while those steps complete. Startup depends on the selected avatar, model cache, provider and available capacity. Do not assume an official avatar is always warm. The media player reports warming, capacity and connection failures to its parent page (`avatar.warming`, `session.at_capacity`, `session.failed`; see [Embed Events](https://anva.ai/docs#embed-events)). Respect `Retry-After` where provided, allow cancellation, and use bounded retries with backoff. Reuse the existing live grant for a reconnect. If startup fails permanently, end that session before creating a replacement; do not mint new sessions repeatedly while waiting for readiness. Standalone browser code should embed the supplied URL rather than call the internal `/offer` transport directly. ### Waking up Pass `wake_up: true` on `POST /sessions` (in `performance_options`, or flat) to start the call with the avatar’s eyes closed. About a second after the viewer’s video starts they open, as if the avatar were waking up to greet them. It is off by default and works in every service mode. Only avatars that can hold a convincing closed-eye pose wake up: Anva checks each avatar’s own renders, most photoreal avatars pass, and some stylised or illustrated ones start with their eyes open instead. `session.live` reports `wake_up: true` when the avatar started asleep and `false` when it could not (so does `session.info`, for a controller that connects after the viewer). The embed skips its open-eyed connecting portrait for every `wake_up` session, so the viewer never sees the avatar awake before it wakes. The eyes open at the latest 8 seconds after the avatar starts, for example in a LiveKit room, which has no WebRTC viewer; an avatar that begins speaking while asleep wakes quickly first. ## Events WebSocket (Requires Developer plan) Connect to WS `GET /sessions/{id}/events` (the `events_ws_url` from session create) with the same bearer key. Accounts without an eligible API plan get `402 plan_required`. The socket never starts the session: the visitor’s embed does, and billing runs only while it is connected. Connect before or after the visitor. The first frame is `session.info` (`status: "pending"` until the visitor connects; once they are, it also carries `wake_up`, `standby` and `standby_expires_at` when they apply), and `session.live` follows once they are connected; send commands after it. A command sent earlier, or while the visitor reconnects, returns an `error` frame with code `session_not_connected`. Use a server-side WebSocket client that supports bearer headers and automatically replies to ping frames (the server pings every 20 seconds and expects activity within 60 seconds). Browser WebSocket constructors cannot set this authorization header; keep the controller and API key on your backend: First frames ``` {"type": "session.info", "payload": { "session_id": "apisess-...", "status": "pending", "preset_id": "abc123", "avatar_id": "", "service_mode": "anva_standard", "llm_mode": "", "speech_input": "on", "state": null, "actions": [], "max_duration_seconds": 7200, "ends_at": null, "metadata": {}, "billing": {"unit": "tokens", "tokens_per_minute": 70, "rate_version": "2026-09-10", "basis": "connected_time", "service_mode": "anva_standard"}, "commands": ["message", "interrupt", "action", "context.update", "activate", "session.update", "presentation.start"]}} {"type": "session.live", "payload": { "session_id": "apisess-...", "status": "active", "state": "listening", "actions": [], "max_duration_seconds": 7200, "ends_at": "2026-09-15T20:00:00Z"}} ``` Connect from Node.js (npm install ws) ``` import WebSocket from "ws"; // session is the JSON returned by POST /api/v2/sessions. const ws = new WebSocket(session.events_ws_url, { headers: { Authorization: "Bearer " + process.env.ANVA_API_KEY } }); ws.on("message", raw => { const event = JSON.parse(raw.toString()); if (event.type === "session.live") { // The visitor is connected; commands are accepted from here on. } // Read events continuously; handle turn.request here for BYO LLM. }); ws.on("error", error => console.error("Anva connection:", error.message)); ws.on("close", () => { /* stop producers and handle reconnect/cleanup */ }); ``` Then every session event streams as an envelope shaped `{"type", "payload"}`: | Type | Payload | Meaning | |---|---|---| | `session.live` | `{session_id, status, state, actions, max_duration_seconds, ends_at, wake_up?, standby?, standby_expires_at?}` | The visitor is connected; commands are accepted (a prepared session refuses `message`, `say`, `say.delta` and `presentation.start` with `session_standby` until it is activated). | | `session.ended` | `{session_id, reason}` | The session ended ([end reasons](https://anva.ai/docs#end-reasons)), or its grant expired with no visitor (`reason: "expired"`). Every host line still open gets its `line.ended` first; the socket then closes. | | `state` | `{state, speech_paused, user_speaking}` | Idle, listening, thinking, or speaking. | | `transcript` | `{role, text, final, say_id?, interrupted?}` | User speech heard, or assistant reply. A host line’s captions carry its `say_id`; a line cut short ends with a final caption of what was heard, `interrupted: true`. | | `turn.complete` | `{spoke, ttfa_s, first_text_s, timing_origin, say_id?}` | The assistant finished a turn; `say_id` names a host line. | | `line.ended` | `{say_id, status, reason, spoken_text, spoken_until_ms?}` | A host line ended — `completed`, `interrupted` or `failed` — with why and what the visitor heard (`byo_llm`; see [Host lines](https://anva.ai/docs#host-lines)). | | `control.speech_state` | `{speaking, content_s, generation, barge_in}` | Avatar audio started (`speaking: true`) or stopped playing. `content_s` is the position in the current audio generation: it resets after an interrupt and is not per line. | | `control.interrupt` | `{generation}` or `{reason}` | Playback stopped (barge-in, a new line, an interrupt): `{generation}`. Your `interrupt` is also announced at once as `{reason: "api"}`, without a generation. | | `turn.request` / `turn.cancel` | see [External LLM](https://anva.ai/docs#external-llm) | Your turn to reply / stop writing a reply that was interrupted before your `turn.done` (`byo_llm`). A barge-in is followed by a new `turn.request`. | | `speech.state` | `{turn_id, state, generation, accepted_samples, played_samples, next_seq, code?}` | Progress of a PCM speech turn (`avatar_only`; see [BYO voice input](https://anva.ai/docs#audio-input)). | | `presentation.state` / `presentation.action` | object | Presenter context (`POST /sessions/{id}/context`) accepted or refused / the avatar moved between its items (see [Presenter context](https://anva.ai/docs#presentation-api)). | | `presentation.cue` | object | A registered visual starts, ends or is cancelled with its words ([Cues](https://anva.ai/docs#presentation-cues)). | | `session.activated` | `{session_id}` | A prepared ([`standby=1`](https://anva.ai/docs#standby)) session was activated: its conversation and billing started. | | `expression.state` | object | The avatar’s current expression and gesture (only with `dynamic_expressions`); at most 4 a second. | | `controls` | `{start_ms, fps, values}` | The avatar’s face channels: `values` holds 15 frames per message, one row of channel weights per frame, at up to 24 frames a second. The channel names are not published. Connect with `?controls=false` to leave them out. Never sent to the embed page. | | `error` | `{code, message, say_id?}` | Something failed or was refused — see [error events](https://anva.ai/docs#error-events). | A server-side controller rarely wants the face stream: connect to `events_ws_url + "?controls=false"` and every other event still arrives. `turn.request`, `turn.cancel`, `speech.state` and `controls` come only on this socket; the embed page receives the other conversation events as `postMessage`. New event types may be added; ignore types you do not handle. Send commands on the same socket to inject text, stop speech, trigger an avatar action, or change the instructions of a managed session: Commands ``` {"type": "message", "payload": {"text": "What's the weather?"}} {"type": "interrupt", "payload": {}} {"type": "action", "payload": {"name": "wave"}} {"type": "session.update", "payload": {"system_prompt": "The learner is a beginner; speak slowly."}} ``` ### Change a session’s instructions mid-call Replace a running session’s `system_prompt` with `session.update` on the socket or `PATCH /sessions/{id}` with `{"system_prompt": "..."}` — a learner’s new setting, a support note for the next answer. It takes effect from the avatar’s next reply; the reply in progress finishes as it began. Before the visitor connects, `PATCH` updates the prompt the session will start with (the response says `"applies": "session_start"`, otherwise `"next_reply"`). In `anva_light`, `anva_standard` and `anva_expressive` the new prompt replaces the old; an ElevenLabs agent (`elevenagents_max`) keeps its configured prompt and receives yours as an added instruction. `byo_llm` and `avatar_only` have no Anva prompt to change and answer `400`. A session’s `system_prompt`, at creation or later, replaces the preset’s prompt — the two are never merged. In `byo_llm` sessions the socket also carries the `turn.*` reply commands and `say` host lines; see [External LLM](https://anva.ai/docs#external-llm). Closing the socket never ends the session: the visitor leaving, or `DELETE`, does. A slow consumer is disconnected rather than silently losing acknowledgements. Keep reading events promptly and handle socket closure; do not assume an interrupted PCM turn can resume at its previous sequence number. Rejected commands come back as an `error` envelope `{code, message}` whose code is `command_failed` (a bad payload, an unknown action or command, or one this service mode does not take), `session_not_connected`, `session_standby` or `speed_unsupported`. ## Webhooks (Requires Developer plan) Pass `webhook_url` (and optionally `webhook_secret`) when creating a session. Accounts below the Developer plan get `402 plan_required`. Request body ``` { "preset_id": "abc123", "webhook_url": "https://example.com/hooks/anva", "webhook_secret": "shh" } ``` Anva POSTs a JSON body shaped `{"event", "event_id", "seq", "session_id", "time", "metadata", "data"}` for these events. `event_id` is unique per event and repeated by every retry of it; `seq` counts 1, 2, 3… per session so you can order events and spot gaps; `metadata` echoes what you passed at creation. Deliveries are strictly ordered per session. The startup webhook retains the legacy `llm_mode` field; read `GET /sessions/{id}` or `session.info` for the resolved service mode and billing: | Event | Data | When | |---|---|---| | `session.started` | `{preset_id, avatar_id, llm_mode, actions}` | The session went live. | | `transcript` | `{role, text, final}` | User speech heard, or assistant reply. | | `state.changed` | `{state, speech_paused, user_speaking}` | Conversation state changed. | | `turn.completed` | `{spoke, say_id?, …}` | The assistant finished a turn (the `turn.complete` payload). | | `interrupted` | `{generation}` or `{reason}` | Playback was interrupted (the `control.interrupt` payload). | | `error` | `{code, message, say_id?}` | Engine error ([error events](https://anva.ai/docs#error-events)). | | `session.ended` | `{session_id, duration_seconds, reason}` | The session ended (`reason`: `ended_by_host`, `client_disconnect`, `viewer_left`, `max_duration`, `idle`, `credits_exhausted`, … — see [end reasons](https://anva.ai/docs#end-reasons)). | `webhook_url` must be reachable on the public internet: a URL that resolves to an address that is not public (private, loopback, link-local and the like) is accepted when you create the session, but every delivery to it is blocked, so use a public tunnel to test from your own machine. Each attempt waits up to 10 seconds for your response. Every delivery carries `X-Anva-Event` with the event name. `line.ended` and the other socket-only events are not sent as webhooks. When you supply a `webhook_secret`, every delivery carries three headers: `X-Anva-Signature: sha256=HMAC_SHA256(secret, body)` (legacy, body only), `X-Anva-Timestamp` (unix seconds) and `X-Anva-Signature-V2: v1=HMAC_SHA256(secret, timestamp + "." + body)`. Verify V2 against the raw request body and reject timestamps older than five minutes to defeat replay. A failed delivery is tried 8 times in all, waiting 1, 2, 4, 8, 16, 32, 64 seconds between attempts (about two minutes); respond promptly with 2xx and tolerate duplicates (same `event_id`). Webhooks are best-effort: for billing, reconcile against `GET /sessions/{id}`, whose `usage` is the metered figure and stays readable for 7 days after the session ends. The events socket carries session/control events; rendered audio/video use WebRTC. Verify (Node) ``` import { createHmac, timingSafeEqual } from "node:crypto"; export function verifyAnvaWebhook(req, rawBody, secret) { const ts = req.headers["x-anva-timestamp"]; const sig = req.headers["x-anva-signature-v2"] ?? ""; if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const want = "v1=" + createHmac("sha256", secret).update(ts + "." + rawBody).digest("hex"); return want.length === sig.length && timingSafeEqual(Buffer.from(want), Buffer.from(sig)); } ``` ## External LLM (Requires Developer plan) Create the session with `service_mode: "byo_llm"` and connect the events WebSocket. Anva still does speech recognition, turn-taking, barge-in, TTS, lipsync, and video — but instead of calling its own LLM it asks you for each reply: Turn flow ``` ← received {"type": "turn.request", "payload": { "turn_id": "turn-3", "user_text": "tell me a joke", "history": [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}]}} → sent back {"type": "turn.delta", "payload": {"turn_id": "turn-3", "text": "Why did the "}} {"type": "turn.delta", "payload": {"turn_id": "turn-3", "text": "scarecrow win an award? "}} {"type": "turn.delta", "payload": {"turn_id": "turn-3", "text": "He was outstanding in his field."}} {"type": "turn.done", "payload": {"turn_id": "turn-3"}} ``` - Stream deltas as they come out of your LLM; Anva chunks them into sentences and starts speaking the first sentence while the rest still generates. - If the reply is interrupted (a barge-in, a new message, your `interrupt`, a failure) before you sent `turn.done`, you receive `control.interrupt` and `{"type": "turn.cancel", "payload": {"turn_id": "turn-3", "reason": "cancelled"}}`: stop generating for that `turn_id`; its late deltas are ignored. A reply you had already finished is not called off. When the visitor said something, a new `turn.request` with a new `turn_id` follows. To abandon a reply yourself, send `{"type": "turn.cancel", "payload": {"turn_id": "turn-3"}}`. - If no reply text arrives for about 45 seconds, the turn fails: you receive an `error` event with code `conversation_failed` and the turn ends. - Use only actions currently listed in session.info.actions. Available gestures depend on the selected avatar’s published clips; an empty list means no action can be requested. Another name answers `400 unknown_action` on `POST /sessions/{id}/actions` and a `command_failed` error frame on the socket. - Commands wait for `session.live`: the visitor’s embed starts the session and carries its audio and video. ### Push-to-talk: `speech_input: "off"` If your application records and transcribes the user itself — push-to-talk, for example — create the session with `"speech_input": "off"`. The embed then never asks for the microphone and Anva runs no speech recognition. Send each transcribed turn as `message` (or `POST /sessions/{id}/messages`); in `byo_llm` it comes back as a `turn.request` like a spoken turn. A message sent while the avatar is speaking takes the floor: the avatar stops and the new turn begins. `speech_input` defaults to `"on"`, is echoed on the session and `session.info`, and also works with `anva_light`, `anva_standard` and `anva_expressive`; Avatar Only and Anva Realtime Max (`elevenagents_max`) reject `"off"` with `400 bad_service_mode`. Create a push-to-talk session ``` curl https://anva.ai/api/v2/sessions -H "Authorization: Bearer anva_key_..." \ -H "Content-Type: application/json" \ -d '{"avatar_id":"av_abc","service_mode":"byo_llm","speech_input":"off","voice_id":"elevenlabs:oPU3sXizBk7l2JHYrFra"}' ``` ### Host lines: `say` Make the avatar speak any line in the session’s voice without a `turn.request` — a lesson’s opening, a hint, a reply generated elsewhere. Send `say` on the events socket, or `POST /sessions/{id}/say` with `{"text", "say_id"?, "speed"?, "queue"?}` (the response returns the `say_id`; one is generated if you omit it). `speed` (0.7–1.2) sets this line’s speaking rate — slower for a beginner — and otherwise the session’s `speech_speed` applies (a `byo_llm` session created with `performance_mode: "standard"` has no rate control and answers `speed_unsupported`); on a streamed line, put `speed` and `queue` on the first `say.delta`. Stream a line as your model writes it with `say.delta` pieces and a closing `say.done`: Host lines ``` {"type": "say", "payload": {"text": "Welcome to lesson three.", "say_id": "lesson-3"}} {"type": "say.delta", "payload": {"say_id": "hint-7", "text": "Try the verb "}} {"type": "say.delta", "payload": {"say_id": "hint-7", "text": "in the past tense."}} {"type": "say.done", "payload": {"say_id": "hint-7"}} ``` A line uses the same voice, lipsync and events as a reply — `state` changes, the assistant `transcript` (whose captions carry the line’s `say_id`), and a closing `turn.complete` whose payload includes the `say_id` — and joins the history sent with the next `turn.request`. Every line ends with exactly one `line.ended` event, `{say_id, status, reason, spoken_text, spoken_until_ms}`. `spoken_text` is what the visitor actually heard, whole words only, and is the authoritative text of the line — captions are snapshots on the way to it; `spoken_until_ms` is how far into the line’s audio playback got. Key “what did the learner hear” on `line.ended`. | `status` | `reason` | When | |---|---|---| | `completed` | `completed` | The whole line played; `turn.complete` with the `say_id` follows. | | `interrupted` | `host_interrupt` | Your `interrupt` (socket or `POST /sessions/{id}/interrupt`), or the visitor’s stop control. | | `interrupted` | `new_line` | A `say` without `queue` took over. | | `interrupted` | `user_message` | A `message` or recognised speech took the floor. | | `interrupted` | `barge_in` | The visitor spoke over the avatar. | | `interrupted` | `playback_unconfirmed` | The audio was sent but the visitor’s player never confirmed playing it (it dropped and was reconnecting). | | `failed` | `failed` | The voice or the line failed; an `error` comes first (`conversation_failed` or `external_reply_limit` with the `say_id`, or `audio_buffer_overflow`). | | `interrupted` | `session_ended` | The session ended mid-line, or before a queued line started. Sent just before `session.ended`, with the last caption heard as `spoken_text` and no `spoken_until_ms`. | A line that did not complete has no `turn.complete`. If any of it was heard, its last `transcript` is `{role: "assistant", final: true, interrupted: true, say_id, text}` with the heard text, just before its `line.ended`. Queueing. A new `say_id` interrupts whatever the avatar is saying. Send `"queue": true` to have the line wait until the current one (a host line or a reply) has finished instead; queued lines play in order, and a queued line can stream its `say.delta`s while it waits. At most 8 lines wait: one more is refused with an `error` `{code: "say_queue_full", say_id}` and never plays. `interrupt`, a `say` without `queue`, a user message or a barge-in clears the queue: each waiting line gets `line.ended` with `status: "interrupted"`, the same `reason` and empty `spoken_text`. After a `failed` or `playback_unconfirmed` line, the next queued line plays. `say_id`s are single-use. Deltas for a line that was interrupted while you were still streaming it are ignored. Sending a `say_id` whose line you had already finished (`say.done`, or a complete `say`) returns an `error` `{code: "say_id_reused", say_id}` and plays nothing. A line holds at most 12,000 characters. `say` is available in `byo_llm`, billed as the session’s connected time. ## Instances & Usage (Requires Developer plan) Every API key belongs to exactly one instance — the project / usage bucket its sessions are billed against. `POST /sessions` attributes the session and its `total_call_seconds` to the key's `instance_id`, and presets are scoped per-instance: a key only sees the presets in its own instance. The account owner can list and manage all their instances with `GET/POST /instances` and `GET/PATCH/DELETE /instances/{id}`. An instance object is (timestamps are Unix seconds): Instance ``` { "id": "inst_...", "name": "Production", "is_default": true, "created_at": 1789000662, "updated_at": 1789000662, "total_call_seconds": 1820.5 } ``` Deleting an instance that still holds presets or API keys fails with `409 instance_not_empty` (delete its presets and revoke its keys first); the default instance, or an unknown id, fails with `400 delete_failed`. Instances require a database; without one every instance call returns `503 instances_unavailable`. ## REST Reference All endpoints live under `/api/v2` and require a bearer key. `service_mode: "byo_llm"` and `webhook_url` on session create require Developer or above. | Endpoint | Plan | Body | Result | |---|---|---|---| | GET `/capabilities` | Any valid key | — | Deployment service modes, audio protocol and commands. Starts no session. | | GET `/billing` | Any valid key | — | Account billing view: plan, balance units and per-mode rates. | | POST `/sessions` | Developer+ | `{preset_id, voice_id?, system_prompt?, language_code?} (embed) or {avatar_id, system_prompt?, voice_id?, language_code?} (advanced), plus {service_mode?, llm_mode?, speech_input?, performance_options?, speech_speed?, wake_up?, webhook_url?, webhook_secret?, max_duration_seconds?, metadata?, livekit?: {url, token}}; header Idempotency-Key? (a retry returns the first session)` | Create a session grant (embed tier with preset_id, or advanced tier with avatar_id and a persona). With livekit, the avatar joins that LiveKit room as the viewer before the call returns. (`201`) | | GET `/sessions/{id}` | Developer+ | — | Session status (pending, active, ended, expired), state, transcript, actions and metadata. | | DELETE `/sessions/{id}` | Developer+ | — | End the session and stop billing. | | POST `/sessions/{id}/messages` | Developer+ | `{text}` | Inject a user text message; the avatar replies. (`202`) | | PATCH `/sessions/{id}` | Developer+ | `{system_prompt}` | Replace a managed session's system_prompt while it runs; it applies from the next reply (anva_light, anva_standard, anva_expressive; added as context for elevenagents_max). | | POST `/sessions/{id}/say` | Developer+ | `{text, say_id?, speed?, queue?}` | Speak a line in the session voice without a turn.request (byo_llm). Interrupts the current line, or with queue: true waits behind it. Ends with line.ended. (`202`) | | POST `/sessions/{id}/interrupt` | Developer+ | — | Stop the avatar's current speech. (`202`) | | POST `/sessions/{id}/activate` | Developer+ | — | Activate a prepared (standby) session: its conversation, the viewer's microphone and billing start. A live session answers 200 and nothing changes. (`202`) | | POST `/sessions/{id}/actions` | Developer+ | `{name}` | Queue an advertised avatar action by name. (`202`) | | POST `/sessions/{id}/speech` | Developer+ | `{type, payload}` | Forward a PCM speech command (Avatar Only sessions); observe speech.state. (`202`) | | POST `/sessions/{id}/context` | Developer+ | `full registered context` | Replace the host-owned presenter context and selection. (`202`) | | POST `/sessions/{id}/presentation` | Developer+ | `{version, demo, revision}` | Start the presenter introduction after context acceptance. (`202`) | | WS `/sessions/{id}/events` | Developer+ | `WebSocket upgrade` | Realtime events and control over a WebSocket; commands wait for session.live. (`101`) | | GET `/presets` | Developer+ | — | List the presets in this key's instance. | | POST `/presets` | Developer+ | `{name, avatar_id, system_prompt?, voice_id?, language_code?}` | Create a preset (name and avatar_id required). (`201`) | | GET `/presets/{id}` | Developer+ | — | Fetch one preset. | | PATCH `/presets/{id}` | Developer+ | `{name?, avatar_id?, system_prompt?, voice_id?, language_code?, greeting_text?, greeting_enabled?, barge_in?, proactive_questions?, allowed_hosts?}` | Partially update a preset; applies to sessions created afterwards. | | DELETE `/presets/{id}` | Developer+ | — | Delete a preset. | | GET `/instances` | Developer+ | — | List the account's instances (usage buckets). | | POST `/instances` | Developer+ | `{name}` | Create an instance. (`201`) | | GET `/instances/{id}` | Developer+ | — | Fetch one instance. | | PATCH `/instances/{id}` | Developer+ | `{name?}` | Rename an instance. | | DELETE `/instances/{id}` | Developer+ | — | Delete an empty, non-default instance. | | GET `/avatars` | Developer+ | — | Avatars this account may use (built-in and granted), with readiness. | | GET `/avatar-creations/capabilities` | Enterprise | — | Avatar creation access, the current token_cost quote and image limits (Enterprise plan, creation enabled by Anva). Starts nothing. | | GET `/avatar-creations` | Enterprise | — | List the account's custom avatar creations (Enterprise plan, creation enabled by Anva). | | POST `/avatar-creations` | Enterprise | `multipart image + {name, token_cost, rights_confirmed=true}; header Idempotency-Key (required)` | Start a custom avatar creation from a portrait (Enterprise plan, creation enabled by Anva). (`202`) | | GET `/avatar-creations/{id}` | Enterprise | — | Fetch one avatar creation and its progress (Enterprise plan). | | GET `/languages` | Developer+ | — | Supported language_code values with recognition tiers. | | GET `/voices` | Developer+ | — | This account's own voices (owned: true) first, then the voice_id library with labels, previews and search facets (language, accent, gender, age, tones). Narrow it with ?language=, accent=, gender=, age=, tone=, q= (words) or owned=true; facets counts every voice. custom_voices {enabled, limit, used} says whether the account may make more. | | POST `/voices/designs` | Enterprise | `{description (20-1000 chars), text? (100-1000 chars, in the voice's language)}` | Design a voice from a written description (Enterprise plan): returns candidate previews to save with POST /voices. Nothing is created yet. | | POST `/voices` | Enterprise | `JSON {name, description, generated_voice_id} to save a design, or multipart {name, description?, rights_confirmed=true, files (1-10 recordings), remove_background_noise?} to clone; header Idempotency-Key` | Create a custom voice (Enterprise plan), private to this account: save a design preview, or clone a speaker from recordings with their consent. Use its voice_id in sessions and presets. (`201`) | | GET `/voices/{id}` | Developer+ | — | Fetch one of this account's custom voices. | | DELETE `/voices/{id}` | Developer+ | — | Delete one of this account's custom voices. Sessions and presets using it stop working. | | GET `/self-host/artifacts` | Enterprise | — | Download links for self-hosting (Enterprise plan, enabled by Anva): the inference kit, the V15 lipsync model and this account's own avatars, each with its SHA-256 and size. Links expire after about 15 minutes; `anva-selfhost fetch` calls this for you. Every listing is recorded. | | POST `/lipsync` | Enterprise | `WAV, FLAC or OGG body, or raw 16-bit mono PCM with ?sample_rate=; ?preset=hybrid or lowlat` | Mouth curves (24 ARKit channels at 30 fps) for one audio clip (Enterprise plan). | | WS `/lipsync/stream` | Enterprise | `WebSocket upgrade; ?sample_rate=16000 or 24000, &preset=hybrid or lowlat` | Stream PCM in and mouth curves out over a WebSocket (Enterprise plan). (`101`) | | POST `/speech` | Enterprise | `{text, voice_id, speed?: 0.7-1.2, preset?: hybrid or lowlat, format?: wav or pcm}` | Text to Anva TTS audio (24 kHz) plus its mouth curves (24 ARKit channels at 30 fps) on the same clock (Enterprise plan). | | WS `/speech/stream` | Enterprise | `WebSocket upgrade; ?voice_id=&speed=&preset= defaults; then {type: speak, id, text, voice_id?, speed?, preset?}, {type: cancel, id}, {type: close}` | Speak lines over a WebSocket: curves, then the audio they describe, as they are generated (Enterprise plan). (`101`) | Message, say, interrupt, and action calls need a connected visitor; before the visitor’s embed connects, or while it reconnects, they return `409 session_not_connected`. `GET /avatars` lists the avatars this key may use, each with `id`, `name`, `kind` (`builtin`, `official` or `custom`), `ready` and `icon_url`: an absolute portrait URL that needs no credentials. For official and custom avatars it is signed and stops working at `icon_expires_at`, at least an hour after the listing; list again for fresh URLs. A preset object is `{id, instance_id, name, avatar_id, system_prompt, voice_id, language_code, allowed_hosts, disabled, active, barge_in, proactive_questions, greeting_enabled, greeting_text, created_at, updated_at}`; `created_at` and `updated_at` are Unix seconds. It also carries `visual_character_id`, a deprecated alias of `avatar_id` that requests still accept. A preset change applies to sessions created afterwards. `PATCH /sessions/{id}` answers `{session_id, updated: ["system_prompt"], applies}`, where `applies` is `session_start` or `next_reply`. `GET /voices` also returns `stale` (true when the catalogue is a cached copy because the last refresh failed) and `synced_at` (when it was last refreshed). A custom voice object is `{id, name, source, state, created_at, updated_at, description?, voice_id?, error_code?, rights_confirmed_at?}`; `voice_id` is present once `state` is `ready`. ### One call per endpoint A copy-pasteable `curl` for every REST endpoint (swap in your key and ids): Sessions ``` # create — embed tier (from a preset) curl https://anva.ai/api/v2/sessions -H "Authorization: Bearer anva_key_..." \ -H "Content-Type: application/json" -d '{"preset_id":"abc123","service_mode":"anva_standard"}' # create — advanced tier (avatar + inline persona) curl https://anva.ai/api/v2/sessions -H "Authorization: Bearer anva_key_..." \ -H "Content-Type: application/json" \ -d '{"avatar_id":"av_abc","service_mode":"anva_standard","system_prompt":"Be helpful.","voice_id":"elevenlabs:oPU3sXizBk7l2JHYrFra","language_code":"en"}' # inspect (status, state, transcript, actions) curl https://anva.ai/api/v2/sessions/apisess-... -H "Authorization: Bearer anva_key_..." # inject a user message curl https://anva.ai/api/v2/sessions/apisess-.../messages -H "Authorization: Bearer anva_key_..." \ -H "Content-Type: application/json" -d '{"text":"Hello!"}' # speak a host line in the session voice (byo_llm) curl https://anva.ai/api/v2/sessions/apisess-.../say -H "Authorization: Bearer anva_key_..." \ -H "Content-Type: application/json" -d '{"text":"Welcome to lesson three.","say_id":"lesson-3"}' # stop the avatar mid-sentence curl -X POST https://anva.ai/api/v2/sessions/apisess-.../interrupt -H "Authorization: Bearer anva_key_..." # trigger an avatar action (names come from GET /sessions/{id} or session.info) curl https://anva.ai/api/v2/sessions/apisess-.../actions -H "Authorization: Bearer anva_key_..." \ -H "Content-Type: application/json" -d '{"name":"wave"}' # end the session curl -X DELETE https://anva.ai/api/v2/sessions/apisess-... -H "Authorization: Bearer anva_key_..." ``` Presets ``` # list every preset in this key's instance curl https://anva.ai/api/v2/presets -H "Authorization: Bearer anva_key_..." # create (name + avatar_id required) curl https://anva.ai/api/v2/presets -H "Authorization: Bearer anva_key_..." \ -H "Content-Type: application/json" \ -d '{"name":"Support agent","avatar_id":"av_abc","voice_id":"elevenlabs:oPU3sXizBk7l2JHYrFra","language_code":"en"}' # fetch one curl https://anva.ai/api/v2/presets/abc123 -H "Authorization: Bearer anva_key_..." # partial update (only the fields you send change) — e.g. allow-list domains curl -X PATCH https://anva.ai/api/v2/presets/abc123 -H "Authorization: Bearer anva_key_..." \ -H "Content-Type: application/json" -d '{"allowed_hosts":["example.com","*.example.com"]}' # delete curl -X DELETE https://anva.ai/api/v2/presets/abc123 -H "Authorization: Bearer anva_key_..." ``` Instances, languages & voices ``` # list your instances (usage buckets) curl https://anva.ai/api/v2/instances -H "Authorization: Bearer anva_key_..." # create / fetch / rename / delete an instance curl https://anva.ai/api/v2/instances -H "Authorization: Bearer anva_key_..." \ -H "Content-Type: application/json" -d '{"name":"Staging"}' curl https://anva.ai/api/v2/instances/inst_... -H "Authorization: Bearer anva_key_..." curl -X PATCH https://anva.ai/api/v2/instances/inst_... -H "Authorization: Bearer anva_key_..." \ -H "Content-Type: application/json" -d '{"name":"Production"}' curl -X DELETE https://anva.ai/api/v2/instances/inst_... -H "Authorization: Bearer anva_key_..." # enumerate valid language_code and voice_id values (don't hardcode them) curl https://anva.ai/api/v2/languages -H "Authorization: Bearer anva_key_..." curl https://anva.ai/api/v2/voices -H "Authorization: Bearer anva_key_..." ``` ## Errors & Limits Every error is `{"error": {"code", "message"}}`. Requests are rate-limited per key — 120 per minute by default, unless the key record specifies a different limit; every response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`, and a `429` includes `Retry-After`. This table and the limits below are generated from the server's error catalogue. Example ``` 402 Payment Required { "error": { "code": "plan_required", "message": "your plan does not include API access; choose Developer or above" } } ``` | HTTP | Code | Meaning | |---|---|---| | 400 | `bad_request` | The request body or parameters are invalid; the message names the problem (including unknown JSON fields). | | 400 | `bad_service_mode` | service_mode / llm_mode / speech_input / performance_options are unknown or conflict with each other. | | 400 | `bad_webhook_url` | webhook_url must be an absolute http(s) URL. | | 400 | `command_failed` | A session command was rejected (bad payload, unsupported for this service mode, or the core refused it). Also the WebSocket error-frame code. | | 400 | `create_failed` | The preset or instance could not be created; the message explains why. | | 400 | `delete_failed` | The instance could not be deleted (unknown id or the default instance). | | 400 | `idempotency_key_required` | An Idempotency-Key header is required for this request. | | 400 | `invalid_access` | The caller may not access this creation. | | 400 | `invalid_audio` | The audio could not be read: send WAV, FLAC or OGG (at most 48 kHz and 2 channels), or 16-bit little-endian mono PCM with sample_rate. | | 400 | `invalid_request` | The creation or voice request is malformed; the message names the problem. | | 400 | `invalid_upload` | The uploaded file could not be decoded as a supported image. | | 400 | `invalid_voice` | voice_id is not a voice this account can use: pick one from GET /voices (the catalogue and your own custom voices) or pass elevenlabs:. | | 400 | `livekit_requires_avatar_only` | A LiveKit room takes the agent's own speech; the session must use service_mode avatar_only. | | 400 | `speed_unsupported` | A speaking rate was sent to a session on the Anva Realtime voice (service_mode anva_standard, or performance_mode standard), which has no speaking-rate control: speech_speed on POST /sessions, or a per-line speed on say / say.delta. Use anva_light, or drop the speed. Also a WebSocket error-frame code. | | 400 | `unknown_action` | The named action is not available for this avatar; GET the session for the list. | | 400 | `update_failed` | The preset or instance could not be updated; the message explains why. | | 401 | `invalid_api_key` | The bearer API key is unknown or has been revoked. | | 401 | `missing_api_key` | No API key was supplied (Authorization: Bearer, X-API-Key, or ?api_key= on WebSocket upgrades). | | 402 | `account_inactive` | The subscription is inactive; choose a plan before starting sessions. | | 402 | `concurrency_limit` | The account is already running its plan's maximum number of concurrent sessions. Retryable. | | 402 | `credits_exhausted` | No balance remains; add a top-up or wait for the monthly renewal. | | 402 | `insufficient_tokens` | The account does not hold enough tokens for the quoted cost: an avatar creation, the audio in a Lipsync clip (a stream: one second of audio), or the most audio a Speech line's text may become. | | 402 | `plan_required` | The account's plan does not include this API feature (API access, external LLM mode, webhooks, or the events WebSocket). | | 402 | `standby_limit` | The account already holds as many prepared (standby=1) sessions as it may, or has prepared as many as it may in the last hour; activate or end one, or start the call without standby=1. The embed posts it to the page as plan.limit. Retryable. | | 403 | `avatar_not_allowed` | The account may not use this avatar_id (not owned, not granted, or advanced sessions are disabled). | | 403 | `creation_not_enabled` | Avatar creation is not enabled for this account's plan. | | 403 | `enterprise_required` | The Lipsync API and the Speech API are available on the Enterprise plan. | | 403 | `not_admitted` | The session was refused by the admission check for a non-billing reason. | | 403 | `preset_disabled` | The preset exists but is disabled. | | 403 | `self_host_not_enabled` | Self-hosting is available on the Enterprise plan once Anva enables it for the account. | | 403 | `voice_creation_not_enabled` | Custom voices require an active Enterprise plan. | | 404 | `avatar_not_found` | The placement service does not know this avatar id. | | 404 | `instance_not_found` | No instance with that id exists for this account. | | 404 | `not_found` | No avatar creation or custom voice with that id exists for this account. | | 404 | `preset_not_found` | No preset with that id exists in this instance (or it was removed after the grant was minted). | | 404 | `session_not_found` | No session with that id is known to this account and instance. | | 409 | `already_published` | The creation has already been published and cannot be changed. | | 409 | `creation_busy` | Another operation on this creation or voice is in progress; retry shortly. Retryable. | | 409 | `creation_in_progress` | The account already has the maximum number of creations in flight. Retryable. | | 409 | `idempotency_conflict` | The Idempotency-Key was already used with a different request body. | | 409 | `instance_not_empty` | The instance still has presets or API keys; move or delete them first. | | 409 | `plan_changed` | The account's plan changed after the grant was minted; create a new session. | | 409 | `quote_changed` | The creation price changed since it was quoted; request a new quote. | | 409 | `session_not_connected` | The viewer has not connected yet, or is reconnecting: commands need a watching viewer. Open embed_url first; on the events WebSocket, wait for session.live. Retryable. | | 409 | `session_standby` | The session is prepared (standby=1 on the embed URL) and not yet activated: it takes no messages, lines or presentation start until POST /sessions/{id}/activate, the activate command, or the embed's activate message. Retryable. | | 409 | `voice_limit` | The account holds the most custom voices it may; delete one to make another. | | 410 | `session_ended` | The session has ended; GET /sessions/{id} reports ended_at and end_reason for 7 days. | | 410 | `session_expired` | The session grant expired before any client connected; create a new session. | | 410 | `unsupported_avatar` | The avatar has no current model bundle and cannot be rendered on this platform version. | | 413 | `audio_too_long` | The clip is longer than 600 seconds, the upload is larger than 64 MB, or a stream passed its audio limit. | | 413 | `image_size` | The uploaded image exceeds the size limit. | | 413 | `sample_size` | A recording exceeds voice_sample_bytes, or all of them exceed voice_clone_body_bytes. | | 413 | `text_too_long` | The text is longer than 2,000 characters, or would become more than 180 seconds of audio. | | 422 | `image_required` | A source image is required. | | 422 | `rights_required` | The caller must confirm they hold the rights to the source image, or that the recorded speaker consents to a voice clone. | | 422 | `samples_required` | A voice clone needs 1 to voice_samples recordings (MP3, WAV, M4A, AAC, OGG, Opus, FLAC or WebM) in files fields. | | 422 | `voice_rejected` | The provider could not make a voice from this description, preview or these recordings; the message says what to change. | | 422 | `voice_unavailable` | The voice provider refused this voice; choose another from GET /voices. | | 429 | `lipsync_concurrency_limit` | The account is already running as many Lipsync jobs as it may at once (clips and streams together); retry when one finishes. Retryable. | | 429 | `rate_limited` | The per-key request limit was exceeded; see Retry-After and the X-RateLimit-* headers. Retryable. | | 429 | `speech_concurrency_limit` | The account already has as many Speech requests and streams open as it may; retry when one finishes. Retryable. | | 500 | `store_error` | A storage read failed; details are logged server-side. Retryable. | | 501 | `livekit_unavailable` | This deployment cannot join LiveKit rooms. | | 502 | `interrupt_failed` | The interrupt could not be delivered to the conversation core. Retryable. | | 502 | `lipsync_failed` | The lipsync worker failed or could not be reached; retry shortly. Retryable. | | 502 | `livekit_join_failed` | The avatar could not join the LiveKit room, or its session could not start on the node; the message says which. Create the session again. Retryable. | | 502 | `send_failed` | The message could not be delivered to the conversation core. Retryable. | | 502 | `speech_failed` | Speech synthesis failed or the speech worker could not be reached; retry shortly. Retryable. | | 503 | `api_disabled` | The public API is not enabled on this deployment (no database configured). | | 503 | `avatar_warming` | The avatar is being loaded onto a session node; retry after retry_after seconds (eta_seconds estimates readiness). Retryable. | | 503 | `billing_unavailable` | Billing state could not be read; retry shortly. Retryable. | | 503 | `catalog_unavailable` | The avatar catalogue could not be read; retry shortly. Retryable. | | 503 | `creation_unavailable` | Avatar creation is not available on this deployment right now. Retryable. | | 503 | `grant_store_unavailable` | The session grant could not be persisted; retry shortly. Retryable. | | 503 | `image_checks_unavailable` | Image safety checks are temporarily unavailable; retry shortly. Retryable. | | 503 | `instances_unavailable` | Instances require a database and none is configured. | | 503 | `lipsync_busy` | Every lipsync worker on the serving node is busy; retry after Retry-After seconds. Retryable. | | 503 | `lipsync_unavailable` | The Lipsync API is not available on this deployment. | | 503 | `mode_unavailable` | The requested service_mode is not supported by the configured conversation core. | | 503 | `no_capacity` | No session node has room for a new session right now; retry after retry_after seconds. Retryable. | | 503 | `refund_pending` | A refund for this creation is still being processed. Retryable. | | 503 | `self_host_unavailable` | Self-hosting downloads are temporarily unavailable; retry shortly. Retryable. | | 503 | `speech_busy` | Every speech worker on the serving node is busy; retry after Retry-After seconds. Retryable. | | 503 | `speech_unavailable` | The Speech API is not available on this deployment. | | 503 | `voice_capacity` | Voice creation is at capacity for now; retry later. Retryable. | | 503 | `voice_creation_unavailable` | Voice creation is temporarily unavailable; retry shortly (a new Idempotency-Key for a request that failed). Retryable. | ### Error events A live session reports what goes wrong during the call as an `error` event `{code, message, say_id?}` on the events WebSocket, the embed page and the `error` webhook. `command_failed`, `session_not_connected`, `session_standby` and `speed_unsupported` (above) answer your own socket commands; these come from the conversation itself. `say_id` names the host line when the error is about one. New codes may be added; treat an unknown one as a failure of the current turn. | Code | Meaning | What to do | |---|---|---| | `conversation_failed` | A reply or host line could not be finished (the voice or the reply text stalled or failed); a host line then ends `failed`. | Send the line again, or let the visitor retry. | | `external_reply_limit` | A reply or host line passed 12,000 characters or 64 pieces; it stops there. | Send shorter lines. | | `audio_buffer_overflow` | The visitor’s playback could not keep up and the line was dropped; a host line ends `failed`. | Send the line again. | | `unsupported_input` | A command this service mode does not take reached the conversation (for example the embed’s `context.update` in an `avatar_only` session). Most such commands are refused earlier: `say` outside `byo_llm` is `command_failed` (REST `400`). | Check the session’s `commands`. | | `say_id_reused` | A `say_id` whose line you had already finished; nothing plays. | Use a new `say_id` for every line. | | `say_queue_full` | A `queue: true` line found 8 lines already waiting; it never plays. | Wait for a `line.ended`, then send it with a new `say_id`. | | `core_disconnected` | The inference connection was lost; the session ends. | Create a new session. | | `core_renderer_unavailable` | The avatar’s renderer could not start for this call. | Create a new session. | | `interrupt_failed` | The visitor’s stop could not reach the conversation; the embed shows an error. | Create a new session. | | `agent_consumer_slow` | `elevenagents_max`: the avatar could not keep up with the agent’s audio; the conversation stops. | Create a new session. | | `agent_audio_failed` | `elevenagents_max`: the agent’s audio could not be animated; the conversation stops. | Create a new session. | ### Why a session ended A session’s `end_reason` on `GET /sessions/{id}`, and `reason` on the socket’s `session.ended`, the `session.ended` webhook and the embed’s `session.ended` message: | Reason | The session ended because | |---|---| | `ended_by_host` | Your server called `DELETE /sessions/{id}`. | | `client_disconnect` | Your page sent the embed’s `disconnect` command. (Closing the page ends as `viewer_left`, after the reconnect window.) | | `viewer_left` | The visitor’s connection dropped and did not come back within about 30 seconds. | | `connection_ended` | The visitor’s connection ended without another reason being recorded, for example when the renderer failed at the start. | | `max_duration` | It reached `max_duration_seconds`. | | `idle` | 10 minutes passed with no input. | | `standby_expired` | A prepared session ([`standby=1`](https://anva.ai/docs#standby)) was not activated within 120 seconds; it was never billed. | | `credits_exhausted` | The token balance ran out. | | `billing_unavailable` | Billing could not be read or written, so metering stopped. | | `core_disconnected` | The inference connection was lost. The embed page gets `error` and then `session.failed`, not `session.ended`. | | `livekit_join_failed` | The avatar could not join the [LiveKit room](https://anva.ai/docs#integrations) at creation; nothing was billed. | | `expired` | The grant’s 15-minute connect window closed before any visitor arrived (`status: "expired"`); the events socket’s `session.ended` carries it too. | | `session_closed` | None of the above applies. | New reasons may be added; treat one you do not know like `session_closed`. ### Limits | Limit | Value | |---|---| | Request body | 256 KB (uploads excepted, below) | | `system_prompt` | 32 KB | | `metadata` | 4 KB, 20 keys, key length 40 | | WebSocket command payload | 48 KB (frame 64 KB) | | Requests per key | 120 / minute | | Grant connect window | 15 minutes | | `max_duration_seconds` | 60 to 7200 (default 7200) | | Finished session readable for | 7 days | | Prepared (`standby=1`) sessions started per account | 30 per rolling hour | | Webhook attempts | 8 (backoff 1s, 2s, 4s, 8s, 16s, 32s, 64s) | | Host line (`say`) | 12,000 characters | | Lipsync clip | 600 seconds, 64 MB, at most 48,000 Hz and 2 channels | | Lipsync stream frame | 192 KB | | Lipsync jobs per account | 8 at once, clips and streams (idle stream closed after 60 s) | | Speech line (`POST /speech`, `speak`) | 2,000 characters, 180 seconds of audio | | Speech requests and streams per account | 4 at once (idle stream closed after 60 s) | | Avatar portrait (`POST /avatar-creations`) | 10 MB (multipart body 11 MB) | | Voice clone recordings (`POST /voices`) | 10 files, 10 MB each (50 MB in all) | ## Common Questions | Symptom | Cause & fix | |---|---| | My Production URL returns `403` ("This assistant can't be shown here") | The domain loading the iframe isn't allow-listed. Add it to the preset's `allowed_hosts` (dashboard edit page or `PATCH /presets/{id}`). Testing on `localhost`? Use the [Test URL](https://anva.ai/docs#urls) instead — the Production URL is host-locked by design, and so are per-session `embed_url`s made from a preset with allowed domains. | | My Production URL says "This assistant isn't ready yet" | The Production URL stays inactive until you allow-list at least one domain. Add one and it activates immediately. | | Which URL do I use for local development? | The [Test URL](https://anva.ai/docs#urls) — it works on `localhost`, `127.0.0.1`, and any domain. It's rate-limited, so switch to the Production URL before you ship. | | My avatar is taking time to connect | Keep the loading state until ready. Follow the player’s status and surface its retry option on failure. See [Startup & readiness](https://anva.ai/docs#warming); availability and startup time depend on capacity and the model cache. | | Every request returns `402 plan_required` | The key's account plan is too low. An eligible subscription is required; the events WebSocket, webhooks, and `service_mode: "byo_llm"` need Developer+. See [Plans & Pricing](https://anva.ai/docs#plans). | | `402 credits_exhausted` when creating a session | The usage balance is exhausted. Token plans need a top-up or the next monthly grant; existing legacy plans keep their original overage terms. | | `409 session_not_connected` on messages / say / interrupt / actions | The visitor hasn’t connected, or is reconnecting. Open `embed_url` in the visitor’s browser; on the events socket, send commands after `session.live`. | | `400 bad_request` on session create | You sent both `preset_id` and `avatar_id`, or neither. Send exactly one. The same code covers a field the API does not know (the message names it) and other invalid values. | | Can I put my API key in the browser? | No. Keep it server-side: mint the session on your backend and hand only the `embed_url` (or use a stable [Test / Production URL](https://anva.ai/docs#urls)) to the client. | | Every call returns `503 api_disabled` or `503 instances_unavailable` | The API (or the instances feature) needs a database on this deployment. This is a server-config issue, not your request. | ## Security - API keys are account-scoped, stored server-side only as irreversible hashes (never in plaintext), revocable in the dashboard, and rate-limited per key. - Session grant tokens are single-session, expire in 15 minutes if unused, and die with the session. Embedding one in a page exposes only that one session. - Webhook payloads are HMAC-signed with your secret; verify `X-Anva-Signature-V2` over `X-Anva-Timestamp` and the raw body, and reject timestamps older than five minutes, before trusting them. - A signed `icon_url` from `GET /avatars` opens one portrait until `icon_expires_at`. - Cross-account access to sessions, presets and instances is rejected with `404`.