> ## Documentation Index
> Fetch the complete documentation index at: https://docs.voiceaiwrapper.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Events

> Every widget.*, voice.*, and chat.* event, with payloads and ordering guarantees

All events are standard DOM `CustomEvent`s 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`.

```js theme={null}
widget.addEventListener('voice.ended', (e) => {
  console.log('Call lasted', e.detail.durationMs, 'ms');
});
```

<Note>
  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.
</Note>

## `widget.*` - lifecycle / shared

| 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](/documentation/widget/headless-mode/consent). |
| `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).                                                                                |

<Tip>
  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.
</Tip>

<Note>
  **Chat sessions are ephemeral:** there is no history persistence across page loads, by design.
</Note>

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