Skip to main content

Requirements & gotchas

Never open your page via file://. The page must be served over https:// (or http://localhost for development). Browsers don’t persist microphone permission on file:// pages, causing repeated permission prompts and calls that die with no audio. This is the single most common integration problem.

Microphone permission

Mic access is checked via getUserMedia before any provider connection, on all providers, so you always get a consistent voice.mic-permission-denied event. The probe stream is released immediately.
When permission is denied, guide visitors to the lock icon in the browser’s address bar to re-allow the microphone.

Audio output

hide-launcher hides UI only - audio is unaffected. The provider SDKs attach their audio elements to document.body, so hiding the widget never mutes the call.

Browser support

Anything with Custom Elements and WebRTC - all evergreen browsers.
Safari: microphone permission prompts may be per-session unless the visitor sets “Allow” for the site.

Single-page apps

The element works inside React, Vue, and Next.js layouts. Keep in mind:
  • Removing the element from the DOM destroys the instance and ends active sessions. For client-side routed apps, mount it once at the layout level if conversations must survive navigation.
  • Page navigation or refresh ends calls and chat sessions. Chat cleanup uses keepalive requests so server logs close properly.

Error handling is your job

In Headless Mode there is no widget UI to show errors. Handle at minimum:
  • voice.mic-permission-denied
  • voice.error
  • chat.error
  • widget.error

The widget ID is public

It’s visible in any embedding page’s source by design; the config endpoint is public. No secrets are exposed - provider private keys never reach the browser, and conversation tokens are minted per-conversation by the backend.

Visible widget + API together

The JS API also works without hide-launcher - methods and events function alongside the normal launcher. Headless Mode is just the “renders nothing” variant.

Per-embed, not per-campaign

hide-launcher is an attribute on the embed snippet. The same widget can be embedded normally on page A and headless on page B.

FAQ

Headless Mode is available on our Pro tiers only. You can upgrade your plan from the dashboard.
No. One script tag plus one custom element. The API is plain DOM methods and events.
Yes - grab the element (ref or querySelector), call methods, add event listeners. No framework bindings needed.
All of them (Vapi, Retell, ElevenLabs) with an identical API. You don’t need to know or care which provider the campaign uses.
Yes - the voice and chat surfaces are independent.
Yes - hide-launcher is per-embed. Embed the same widget normally on one page and headless on another.
It’s buffered and fires automatically once the widget is ready. Chat messages are queued in order the same way.
Listen to chat.agent-typing-started / chat.agent-typing-stopped. The pair is always balanced, so your typing indicator can never get stuck.
Either the inactivity timeout (configurable per widget, default 3 minutes) or a tab switch - check the reason field on chat.session-ended.
No - it’s a public identifier, like a publishable key.
No - audio is independent of UI. The call sounds exactly the same with or without hide-launcher.

Still stuck?

Contact support and include which event (or missing event) you’re seeing - the voice.* / chat.* event stream usually pinpoints the issue quickly.