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.
https://anva.ai/api/v2Authorization: Bearer <api key>application/json{"error":{"code","message"}}API keys look like anva_key_… and are created in the dashboard under API Keys (Developer plan or above). The X-API-Key: <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
curl https://anva.ai/api/v2/presets \
-H "Authorization: Bearer anva_key_..."const res = await fetch("https://anva.ai/api/v2/presets", {
headers: { "Authorization": "Bearer anva_key_..." }
});
const { presets } = await res.json();import requests
r = requests.get(
"https://anva.ai/api/v2/presets",
headers={"Authorization": "Bearer anva_key_..."},
)
presets = r.json()["presets"]2 Create a session
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"}'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_atimport 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
id="anva-avatar"
title="Anva avatar"
src="...embed_url..."
allow="microphone; autoplay">
</iframe>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 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.
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 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 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:
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);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 pageimport (
"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 pageWhat 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.
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. 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). 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.
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_lightkeeps working, at the same behaviour and price (60 tokens/minute). It is now displayed as Anva Realtime Lite.- Sessions created with no
service_modenow use Anva Realtime (anva_standard) at 70 tokens/minute, where they usedanva_lightat 60. - Send
service_mode: "anva_light"to keep the previous behaviour and price. - The voice performance modes are now
fast(Lite),standard(Realtime) andexpressive.
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.
// 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.
// 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.
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.
// 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 or Pipecat (including Daily transports), give it an Anva face without changing its STT, LLM or TTS. Both packages run an Avatar Only 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).
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.
{
"avatar_id": "av_...",
"service_mode": "avatar_only",
"livekit": { "url": "wss://your-project.livekit.cloud", "token": "<avatar participant JWT>" }
}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 OutputImageRawFrames and its voice as TTSAudioRawFrames, 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.
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 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 and Anva for 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, 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.
# 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, the voice catalogue’s filters, prepared sessions (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).
# 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.
# 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. 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).
curl -X POST https://anva.ai/api/v2/lipsync \
-H "Authorization: Bearer anva_key_..." \
-H "Content-Type: audio/wav" --data-binary @line.wav{
"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):
jawOpen, mouthClose, mouthFunnel, mouthPucker, mouthLeft, mouthRight,
mouthSmileLeft, mouthSmileRight, mouthFrownLeft, mouthFrownRight,
mouthDimpleLeft, mouthDimpleRight, mouthStretchLeft, mouthStretchRight,
mouthRollLower, mouthRollUpper, mouthShrugLower, mouthShrugUpper,
mouthPressLeft, mouthPressRight, mouthLowerDownLeft, mouthLowerDownRight,
mouthUpperUpLeft, mouthUpperUpRightA 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_limitwithRetry-After. Ask us if you need more. - When the serving node is at capacity the API answers
503 lipsync_busywithRetry-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 withcredits_exhaustedand 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 shows the same model driving an Anva avatar from your microphone.
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, 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): thefetch,check,listandrendercommands, anAvatarStreamclass 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:
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.mp4fetch 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
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 chunkThe 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.
curl https://anva.ai/api/v2/self-host/artifacts \
-H "Authorization: Bearer $ANVA_API_KEY"{
"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), 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).
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"}'{
"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_idmust be a catalogue voice or one of your account’s own custom voices, asGET /voiceslists them; any other id (another account’s custom voice, or a provider library id outside the catalogue) answers400 invalid_voice. A voice the provider refuses answers422 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_limitwithRetry-After. A busy serving node answers503 speech_busywithRetry-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 withcredits_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. 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:<voice_id>; 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 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:
works on localhost + any domainonly 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, 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.)
2 Develop on localhost with the Test URL
Paste the Test URL into an iframe on your local page — it just works:
<!-- open at http://localhost:3000 -->
<iframe
src="https://anva.ai/embed/?session_url_token=<TEST_TOKEN>"
allow="microphone; autoplay"
style="width:100%;height:700px;border:0">
</iframe>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_urls you create from this preset are locked to the same 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"]}'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"] })
});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:
<iframe
src="https://anva.ai/embed/?session_url_token=<PROD_TOKEN>"
allow="microphone; autoplay"
style="width:100%;height:700px;border:0">
</iframe>Loaded from an allow-listed domain it runs at full capacity. Loaded from anywhere else
it returns 403 — see Common Questions 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 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.
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 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 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 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 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.
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}:
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 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. |
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. |
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); 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. 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.
<iframe src="EMBED_URL&ui=none&feather=24" allow="microphone; autoplay"
style="border:0;background:transparent"></iframe>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":
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 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) 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). |
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). |
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). |
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 (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.
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 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.
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). 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:
{"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"}}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), 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). |
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 | 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). |
presentation.state / presentation.action | object | Presenter context (POST /sessions/{id}/context) accepted or refused / the avatar moved between its items (see Presenter context). |
presentation.cue | object | A registered visual starts, ends or is cancelled with its words (Cues). |
session.activated | {session_id} | A prepared (standby=1) 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. |
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:
{"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. 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.
{
"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). |
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). |
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.
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:
← 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 sentturn.done, you receivecontrol.interruptand{"type": "turn.cancel", "payload": {"turn_id": "turn-3", "reason": "cancelled"}}: stop generating for thatturn_id; its late deltas are ignored. A reply you had already finished is not called off. When the visitor said something, a newturn.requestwith a newturn_idfollows. 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
errorevent with codeconversation_failedand 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_actiononPOST /sessions/{id}/actionsand acommand_failederror 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.
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:
{"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.deltas 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_ids 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):
{
"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):
# 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_..."# 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_..."# 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.
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:<voice_id>. |
| 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) 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 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 instead — the Production URL is host-locked by design, and so are per-session embed_urls 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 — 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; 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. |
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) 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-V2overX-Anva-Timestampand the raw body, and reject timestamps older than five minutes, before trusting them. - A signed
icon_urlfromGET /avatarsopens one portrait untilicon_expires_at. - Cross-account access to sessions, presets and instances is rejected with
404.