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.
| Event | Payload (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
| Event | Payload | Fires 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
| Event | Payload | Fires 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
| Reason | Meaning |
|---|
user | The visitor (or your code via endChatSession()) ended it. |
inactivity | No activity for the widget’s configured timeout (1–60 minutes, default 3). |
hidden-tab | The visitor switched tabs or minimized the browser; sessions auto-end to avoid zombie sessions. Identical on all three providers. |
error | The 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.
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.