Skip to main content
All events are standard DOM CustomEvents dispatched on the <voiceai-widget> element (or on the init() container). Subscribe with addEventListener - it works in vanilla JS, React (via a ref), Vue, Svelte, Webflow custom code, anything. The payload is in event.detail.
widget.addEventListener('voice.ended', (e) => {
  console.log('Call lasted', e.detail.durationMs, 'ms');
});
Every voice.* / chat.* payload (and widget.ready) includes provider: 'vapi' | 'retell' | 'elevenlabs'. It is a debug aid only - keys and semantics are identical regardless of its value. Never branch on it.

widget.* - lifecycle / shared

EventPayload (event.detail)Fires when
widget.ready{ provider, modes: ('voice'|'chat')[] }Config fetched and parsed; all methods fully callable. modes says which surfaces this widget supports.
widget.consent-required{ pendingAction: 'voice' | 'chat' }Consent is enabled on the widget, the visitor hasn’t consented yet, and a hidden widget can’t show its built-in popup. Show your own consent UI, then call acceptConsent(). See Consent in Headless Mode.
widget.error{ message: string }Config fetch failed, or a method was called for a mode the widget doesn’t support.

voice.* - calls

EventPayloadFires when
voice.connecting{ provider }Start accepted (mic permission OK), provider connection beginning.
voice.started{ provider }The call is live - audio flows both ways.
voice.ended{ provider, durationMs: number }Guaranteed terminal event - fires exactly once per voice.connecting, for any ending: user hangup, agent hangup, error, even a failed connect (then durationMs: 0). durationMs is measured by the widget’s own clock from voice.started.
voice.mic-permission-denied{ provider }Browser microphone permission was rejected. Checked before any provider connection - fires instead of voice.connecting (no voice.ended follows). Identical behavior on all providers.
voice.error{ provider, message: string, raw?: unknown }A call/SDK failure. message is always a human-readable string. raw is the untouched provider error - useful for debugging, but explicitly not part of the stable contract (its shape varies by provider and may change).

chat.* - messaging

EventPayloadFires when
chat.session-started{ provider, sessionId?: string }A chat session was created (auto-created by the first sendChatMessage()).
chat.message{ provider, role: 'user'|'assistant', content: string }A message entered the conversation. Exactly one message per event. Fires for the visitor’s own messages too (role: 'user'), so the whole conversation can be rendered from this single stream.
chat.agent-typing-started{ provider }The agent began composing a reply - show your typing indicator.
chat.agent-typing-stopped{ provider }Always balanced with -started, and always fires before the assistant’s chat.message and before chat.session-ended - typing dots can never get stuck.
chat.session-ended{ provider, reason: 'user'|'inactivity'|'error'|'hidden-tab', sessionId? }The session ended. The next sendChatMessage() starts a fresh session. See reasons below.
chat.error{ provider, message: string, raw?: unknown }A send/session failure. Informational - does not replace chat.session-ended.

chat.session-ended reasons

ReasonMeaning
userThe visitor (or your code via endChatSession()) ended it.
inactivityNo activity for the widget’s configured timeout (1–60 minutes, default 3).
hidden-tabThe visitor switched tabs or minimized the browser; sessions auto-end to avoid zombie sessions. Identical on all three providers.
errorThe session died unexpectedly (network, provider).
When a session ends with reason: 'inactivity', show something like “Session expired - send a message to start a new one.” The next sendChatMessage() starts a fresh session automatically.
Chat sessions are ephemeral: there is no history persistence across page loads, by design.

Guarantees & event ordering

These guarantees are enforced by an internal sequence guard - they do not depend on provider SDK behavior.

Voice state machine (per call attempt)

idle ──startCall()──▶ [mic check]
   ├─ denied  → voice.mic-permission-denied            → idle   (no ended)
   └─ granted → voice.connecting
                  ├─ success → voice.started → … → voice.ended { durationMs }
                  └─ failure → voice.error → voice.ended { durationMs: 0 }
  • voice.ended fires exactly once per voice.connecting - never zero times, never twice.
  • Duplicate or out-of-order provider SDK events are dropped.
  • voice.error is informational and never replaces the terminal event.

Chat turn sequence

[chat.session-started, if new] → chat.message{user} → chat.agent-typing-started
 → chat.agent-typing-stopped → ( chat.message{assistant} | chat.error )
  • The typing pair is always balanced; -stopped is guaranteed before chat.session-ended.
  • chat.session-ended fires exactly once per chat.session-started.
  • For streaming providers, assistant text streams internally, but the public stream still emits one chat.message per completed reply - identical to non-streaming providers.

Cross-provider conformance

The same integration code produces the same event names, payload keys, and ordering whether the campaign runs on Vapi, Retell, or ElevenLabs. Provider quirks - different SDK event names, batched replies, echoed user messages, missing typing signals, varying error shapes - are all normalized away before events reach your page.