Asqvox · Voice Widget

Widget developer documentation

Everything a developer needs to install, configure, secure, and extend the embeddable Asqvox voice widget on a customer site — from the one-line install to the JavaScript API, behavioural triggers, visitor identity, and guided navigation.

1 Getting started

1.1 What you'll need

Before you embed the widget, make sure you have:

  • A public widget token — found in your dashboard under Widgets → your widget → Embed snippet. It looks like wt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx. This token is public by design (it ships in your page source); it's protected by origin enforcement (§2.4), not by secrecy.
  • A live widget — complete the 7-step onboarding wizard and hit Go live. A widget that isn't live returns 409 on resolve.
  • A page served over HTTPS — the browser will not grant microphone access on plain HTTP (§2.1).
Where things are configured Most features are toggled in the dashboard (triggers, visitor identity, guided navigation, plan). Only a handful require code on your page: the install snippet, optional CTA wiring, the visitor-identity JWT, and the optional SPA router hook. Each section below marks which side the work lives on.

1.2 Quick install

Paste this once, anywhere in your page's <body> (typically just before </body>). That's the entire installation — a floating orb appears bottom-right; clicking it opens the conversation.

index.html
<script
  src="https://widget.aiandhumn.com/aiw-voice-widget.js"
  data-widget-token="YOUR_WIDGET_TOKEN"
  defer
></script>

The bundle injects a shadow-DOM UI, so it neither inherits your site's CSS nor leaks its own. defer is recommended so the widget bootstraps after first paint and never blocks above-the-fold content.

Same bundle, both environments The widget is environment-agnostic — the backend it talks to comes from data-backend-url (or the built-in default), not from a separate build. Dev sites use https://widget.aiworkfllow.com/aiw-voice-widget.js + https://api.aiworkfllow.com; production uses the aiandhumn.com hosts.

1.3 Script tag attributes

The widget auto-initialises from data-* attributes on its own <script> tag. The token is the only required one.

AttributeRequiredPurpose
srcYesThe bundle URL — https://widget.aiandhumn.com/aiw-voice-widget.js.
data-widget-tokenYesYour public widget token. The backend resolves tenant + agent from this — no tenant/assistant IDs needed.
data-backend-urlOptionalOverride the API origin (e.g. point a staging page at the dev API). Defaults to the production API.
data-visitor-tokenOptionalA signed HS256 JWT to pre-fill the visitor's name/email/phone. See §5.
deferRecommendedBootstraps after first paint. Manual orb clicks still work even before bootstrap finishes.
Legacy attributes removed data-tenant-id and data-assistant-id are no longer used. If you have an old snippet carrying them, they're harmless but redundant — the token resolves everything.

2 Core requirements

2.1 HTTPS / secure context

navigator.mediaDevices.getUserMedia() is exposed only on secure contexts. Every modern browser refuses the microphone API on plain HTTP — the only exceptions are localhost and 127.0.0.1 during development.

On an HTTP page the widget detects this and logs to the console:

DevTools console
[aiw:mic] Microphone API is not available on this page. Most common
cause: the page is served over plain HTTP. …

Fix: serve the embedding page (ideally the whole site) over HTTPS.

2.2 Permissions-Policy (microphone) — the most common gotcha

If your server sends a Permissions-Policy response header that denies microphone, the browser refuses getUserMedia() before any prompt appears. The visitor sees the widget fall back to text with "Mic access is blocked. You can still ask questions by typing below." — they're never asked for permission. This is a server-header issue the visitor cannot fix.

Symptoms: clicking the orb shows the text UI immediately; no mic prompt ever appears; identical across browsers and incognito; console shows [aiw:mic] Microphone blocked by this page's Permissions-Policy header.

Fix: include microphone=(self) in the header for the embedding page.

Header examples
# No existing policy — add anywhere convenient:
Permissions-Policy: microphone=(self)

# Existing strict policy (e.g. Next.js deny-all) — add (self) to the mic entry:
Permissions-Policy: camera=(), microphone=(self), geolocation=()

# nginx — inside the server/location block serving the page:
add_header Permissions-Policy "microphone=(self)" always;

After deploying, hard-reload (Ctrl/⌘+Shift+R — browsers cache headers aggressively) and verify with curl -I https://your-site.com/page | grep -i permissions-policy.

We cannot fix this from our side No JavaScript API in any browser lets an embedded script override its host page's Permissions-Policy. Mic permission is controlled by your server headers and your visitor's browser settings. Every voice-enabled third-party widget has the same constraint.

2.3 Embedding inside an <iframe>

If the snippet lives inside an iframe (CMS custom-HTML blocks, sandboxing), you need two things:

  1. The iframe element must carry allow="microphone":
    parent.html
    <iframe src="/widget-host.html" allow="microphone"></iframe>
  2. The parent page's Permissions-Policy must permit microphone for the iframe's origin. Same origin → microphone=(self) is enough. Different origin → microphone=(self "https://your-iframe-origin.com").
Recommended Prefer the plain <script> install (§1.2) — it uses shadow DOM for isolation and avoids the iframe permission model entirely.

2.4 Domain registration & origin enforcement

When you create a widget you set a domain (wizard Step 1) and a widget page URL (Step 2). On every POST /api/widget/resolve and POST /api/sessions, the server compares the browser's Origin header's registrable domain against your configured domain using the public suffix list.

OriginResult for a widget registered to acme.com
acme.com, www.acme.com, app.acme.com, shop.acme.com/checkout✅ Allowed — any subdomain of the same registrable base.
acme.io, acme.co.uk, impostor.com, acme.com.evil.net403 ORIGIN_NOT_ALLOWED.
localhost, 127.0.0.1 (any port)✅ Allowed — for local development.

Why it matters: your widget token is visible in page source. Origin enforcement is what stops a malicious site from copy-pasting your snippet onto their domain and burning your voice minutes.

  • Changed your domain? Edit Domain (Step 1) + Widget page URL (Step 2) in the dashboard and republish.
  • Multiple registrable domains (e.g. acme.com + acme.io)? Each needs its own widget — create a second one and use its snippet on the second site.
  • Calling the API from curl/Postman? Production rejects requests with no Origin header (401 ORIGIN_REQUIRED). Add an Origin header matching your registered domain.

3 JavaScript API

3.1 window.AIWWidget overview

Once the bundle has loaded, it exposes a small global. All methods are safe to call no-op-style; the object exists only after the script runs, so guard with if (window.AIWWidget) if your code can run before the (deferred) bundle loads.

MethodGated?Purpose
open(anchor?)gestureOpen the widget / start a call. Must be called from a real user gesture (§3.2). Optional anchor element positions the panel near it.
identify(token)noSet the visitor-identity JWT for pre-fill. Pure setter — no side effects until the next call (§5.4).
onNavigate(handler)noRegister your SPA router so guided navigation uses it. Pass null to clear (§3.5).
navigate(path, sectionSelector?)noProgrammatic same-origin navigation for first-party CTAs; routes through the same same-origin guard as agent-driven nav.

3.2 open() & the user-gesture gate

This is the most common integration question, so it's worth being precise: there is no security token you pass to open(). The widget mints and carries all the real security material internally — the HMAC challenge is minted at /api/widget/resolve and attached automatically to /api/sessions; your page never touches it.

The only host-side condition is that open() runs synchronously inside a genuine user gesture. Internally it checks navigator.userActivation.isActive, which is true only while a real (trusted) click/tap is being processed, and false for console calls, setTimeout, async callbacks, and synthetic .click() / dispatchEvent.

The one trap Transient activation is consumed by await. If you await anything (a fetch, a promise) before calling open(), the gesture is gone and the call is silently rejected with [aiw] AIWWidget.open() rejected — must be called synchronously from a real user gesture in the console. Call open() first, then do async work.

3.3 Opening from a custom button or a form submit

From a button click — the simplest case; the click is the gesture:

cta-button.js
document.getElementById('talk-to-us').addEventListener('click', (e) => {
  // Pass the clicked element so the panel anchors near it (optional).
  window.AIWWidget?.open(e.currentTarget);
});

From a form submit — a submit is also a gesture, but the native submit (or your async POST) will consume the activation. Call preventDefault(), open synchronously, then submit:

contact-form.js
document.getElementById('contact-form').addEventListener('submit', (e) => {
  e.preventDefault();

  // ✅ Call open() FIRST, synchronously — activation is still live here.
  window.AIWWidget?.open(e.submitter || e.target);

  // Now run the async submission — order matters.
  fetch('/your-endpoint', { method: 'POST', body: new FormData(e.target) });
});
"Open after the form succeeds" won't work Calling open() in a .then() after the POST resolves runs outside the gesture and is rejected — no token re-enables it. Either open before the POST (above), or show a "Chat with us" button on your success screen and call open() from that button's click handler.

3.4 identify(token)

Sets the visitor-identity JWT from client-side JS — the SPA-friendly alternative to the data-visitor-token attribute. It's not gesture-gated (it's a pure setter; the token is only validated server-side when a call later starts). Full walkthrough in §5.4.

after-auth.js
// After your auth flow finishes, fetch a freshly-minted JWT from YOUR backend:
const token = await fetch('/api/visitor-token').then((r) => r.text());
window.AIWWidget.identify(token);

3.5 onNavigate() & navigate()

These power guided navigation (§7) — letting the agent take the visitor to a page mid-conversation without breaking the live call. onNavigate registers your SPA's router so route changes use it instead of a synthetic popstate (required for routers that don't react to it, e.g. Next.js app-router). navigate lets your own first-party CTAs trigger the same same-origin navigation.

spa-router-hook.js
// React Router
window.AIWWidget.onNavigate((path, sectionSelector) => {
  navigate(path);                 // your router's push
  // scrolling to sectionSelector is handled by the widget after the route renders
});

// Next.js app-router
window.AIWWidget.onNavigate((path) => router.push(path));

// Clear the hook
window.AIWWidget.onNavigate(null);

4 Behavioural triggers

4.1 Overview dashboard-configured

Triggers auto-open the widget based on visitor behaviour — "open after 30s on the pricing page", "open on exit intent". You define them in the dashboard on the widget detail page; the widget evaluates them client-side every tick against live signals. No host code is needed — triggers ride on the same bundle.

Each rule has a name, a priority, an array of conditions (all must be satisfied), and an optional cooldown.

4.2 Condition types

TypeParameterFires when…
urlMatchespattern (wildcard, e.g. */pricing*, case-insensitive)The current URL matches the pattern.
dwellSecondsgte (number)Seconds spent on the page (while the tab is visible) ≥ gte.
scrollDepthgte (0–1)Max scroll depth reached this page-view ≥ gte (e.g. 0.6 = 60%).
exitIntentThe pointer moves toward the top viewport edge (exit gesture).
idleSecondsgte (number)Seconds since last activity (scroll/mouse/key/touch) ≥ gte. The idle clock only starts after the first activity.
repeatVisitorvisitCountGte? (number, optional)Visitor has been here before (or total visits ≥ visitCountGte). Tracked cross-session per widget token.
JS API on triggers is stable No new condition types or JS-API methods have been added since M2. The condition shapes above mirror the backend Zod validators exactly.

4.3 Cooldown

An optional cooldown stops a rule from re-firing too often. Both fields are optional and can be combined:

  • perSession: true — fire at most once per browser session.
  • perDays: N — after firing, suppress this rule for N days for this visitor.

4.4 Interaction with anti-abuse

Trigger-driven opens have no click event, so the isTrusted / user-activation gate (§3.2) does not apply to them. Instead they're protected by the engagement gate: the triggering signal itself (a scroll, a mouse move, an exit gesture, activity-then-idle) is the engagement signal. A fully passive, never-interacted bot page won't accumulate engagement and won't get a session — which is the intended outcome. All of this is automatic; there is nothing to wire on your page.

5 Visitor identity (pre-fill)

5.1 What it does opt-in

When enabled and a valid JWT is sent, the widget already knows the visitor's email / phone / name. When the agent asks for one (or the visitor volunteers it), the inline capture pill appears pre-filled — a one-tap confirm instead of spelling out an email over voice. It does not skip the pill, does not authenticate the visitor to your widget, and does not change the agent's behaviour. The agent itself never sees the identity directly; it flows on a separate metadata channel for your records and post-call joins.

5.2 Enable the feature + your widget secret

Turn it on in the dashboard under Widget detail → Visitor identity. On first enable, a 64-char hex widget secret is generated. Reveal it (eye icon), copy it, and store it as a server-side env var:

.env (server only)
AIW_WIDGET_SECRET=<your 64-char hex secret>
Secret hygiene Never commit it; never expose it in a frontend bundle. If leaked, click Regenerate in the dashboard — that immediately invalidates every outstanding token minted with the old secret.

5.3 Mint a JWT after your user authenticates

Mint a fresh HS256 JWT per visitor session (recommended expiry: 5 minutes). HS256 only — RS256/ES256/none are rejected.

mint-token.ts — Node (jsonwebtoken)
import jwt from 'jsonwebtoken';

export function mintVisitorToken(user) {
  return jwt.sign(
    { sub: user.id, email: user.email, name: user.name },
    process.env.AIW_WIDGET_SECRET,
    { algorithm: 'HS256', expiresIn: '5m' },
  );
}

Claim reference (Python/PHP/Ruby snippets live in docs/host-site-identify.md):

ClaimRequiredTypeNotes
subYesstringYour internal user identifier.
expYessecondsExpiry. Recommend now + 300.
iatsecondsIssued-at. Future-dated tokens are rejected.
emailstringPre-fills the email pill.
phonestringPre-fills the phone pill.
namestringPersonalisation / call analytics.
Exact claim names The widget reads email, phone, name only — emailAddress / phoneNumber etc. are ignored.

5.4 Pass the token to the widget

Two transports — use whichever fits. If you use both, the last one wins.

Option A — server-rendered HTML (interpolate into the snippet):

page.html (server template)
<script
  src="https://widget.aiandhumn.com/aiw-voice-widget.js"
  data-widget-token="wt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
  data-visitor-token="<%= visitor_token %>"
></script>

Option B — client-side SPA (fetch then identify):

spa.js
const token = await fetch('/api/visitor-token').then((r) => r.text());
window.AIWWidget.identify(token);
Mint on demand Don't fetch the token until the user is authenticated, and don't cache a long-lived token in browser storage — mint per session so a tab left open >5 min doesn't ship an expired token.

5.5 Verify it works

  1. Visit a page with the widget as a logged-in user; open DevTools → Network.
  2. Start a call; find the POST /api/sessions request. The body should carry a visitor_token field; the response should carry a visitorClaims object with your email/phone/name.
  3. Get the agent to ask for the email — the pill appears pre-filled.

Or skip the call entirely: paste a freshly-minted JWT into the dashboard's Test now input (Visitor identity card) — it decodes the claims or returns a specific error code instantly.

6 Inline lead capture

6.1 How capture works automatic

Lead capture is built in — there's nothing to configure on your page. During a call the widget surfaces an inline pill in two situations:

  • Agent-asked — the agent asks for a name, email, or phone (the question must end in ? and contain the relevant keyword). The pill appears with an input the visitor can type into.
  • User-volunteered — the visitor's own utterance contains an email or phone shape; the pill appears pre-filled for a one-tap confirm. (Names are deliberately never auto-detected from free speech — too ambiguous.)

If visitor identity (§5) is on, the agent-asked pill is pre-filled from the JWT instead of empty.

6.2 Where captures appear

Each submitted value is stored and rolled up by call. You'll see it in two dashboard places:

  • Leads — one row per call with name / email / phone columns and a deep-link to the conversation.
  • Conversations (per widget) — a "Captured contacts" block on the call's detail row.
Voice-only answers If the visitor answers a contact question by voice (never touching the pill), the spoken value stays in the transcript but isn't stored as a structured capture. To reliably capture spoken name/email/phone, configure your Retell agent's post-call extraction — ask your Asqvox contact for the current setup.

7 Guided navigation (co-browse)

Mid-conversation the agent can take the visitor to a relevant page and scroll to the right section — "let me take you to pricing" — with no visitor click and the voice call uninterrupted. It's an entitlement-gated feature: enable it per widget in the dashboard's Guided navigation card. Two tiers run automatically:

  • Tier 0 — same-page scroll. Target is on the current page → smooth scroll. Works everywhere, zero config.
  • Tier 1 — SPA in-place nav. Single-page-app host → client-side route change + scroll. The live call survives because the JS realm is never destroyed.

For Tier 1 the widget falls back to history.pushState + a synthetic popstate, which works for many routers with no host code. Some routers (notably Next.js app-router and any router that tracks its own history index) don't react to synthetic popstate — for those, register your router via onNavigate (§3.5):

register-router.js
window.AIWWidget.onNavigate((path) => router.push(path));

If the widget attempts a Tier-1 nav and can't confirm the new content rendered within ~3s, it logs a console warning telling you to register onNavigate.

  • Nav map (dashboard). You configure which pages/sections the agent may show as a list of { label, url, sectionSelector?, aliases? } entries on the Guided navigation card. url is relative (e.g. /pricing); the agent matches a free-text request against your labels/aliases. The agent can only reach configured targets — never a hallucinated URL.
  • Strict same-origin. Navigation is restricted to the exact same origin (scheme + host + port) as the embedding page. A different domain, subdomain, www-vs-apex, or http-vs-https is a different origin and is not navigable in-call — the agent degrades to spoken directions. Don't add authenticated, destructive, or checkout routes to the nav map; it is the security boundary.
Single canonical origin If you run one widget across both www.acme.com and acme.com, standardise on one canonical origin — guided navigation binds to the exact origin the call started on.

8 Security model

8.1 What's automatic vs what's yours

ProtectionHandled byWhat you do
Origin / domain enforcementBackendRegister the correct domain in the wizard (§2.4).
Resolve→session HMAC challengeWidget + backend (automatic)Nothing — minted and sent for you.
Engagement gate (bot defense)Widget + backend (automatic)Nothing.
User-gesture gate on open()WidgetCall open() synchronously from a real gesture (§3.2).
Visitor-identity JWT signingYouKeep the secret server-side; mint short-lived HS256 tokens (§5.2).
Microphone permissionYour server headers + visitor browserServe HTTPS + allow microphone=(self) (§2.2).

8.2 Rate limits

Per-IP limits protect customer minutes. Legitimate use never hits these; persistent 429s from one IP indicate scripted abuse.

EndpointLimit (per IP)
POST /api/widget/resolve60 / minute
POST /api/sessions30 / hour
POST /api/sessions/:id/captures60 / hour

9 Troubleshooting

Two places to look: the DevTools Console (for [aiw:*] warnings) and the Network tab (for HTTP responses from the API host).

9.1 Mic & permission failures (Console)

Console lineCauseFix
Microphone API is not available on this page.Page is HTTP, not HTTPS.Serve over HTTPS (§2.1).
Microphone blocked by this page's Permissions-Policy header.Permissions-Policy: microphone=().Add microphone=(self) (§2.2).
getUserMedia denied (NotAllowedError) + prompt shown then dismissedVisitor clicked "Block".Visitor un-blocks the site in browser settings. Widget can't override a user denial.
No microphone hardware available…No mic / disabled at OS.Visitor connects a microphone.
Microphone exists but is in use by another applicationZoom/OBS holds the mic.Visitor closes the other app, retries.
AIWWidget.open() rejected — must be called synchronously from a real user gestureopen() ran outside a gesture (after await, in a timer, from console).Call open() first, synchronously, in the gesture handler (§3.3).

9.2 Origin / HTTP errors (Network)

Statuserror codeCause & fix
403ORIGIN_NOT_ALLOWEDPage origin doesn't share a registrable domain with the widget. Update the domain in the dashboard, or make a separate widget (§2.4).
401ORIGIN_REQUIREDNo Origin header — you're calling from a script/curl. Add a matching Origin header.
401(none)Unknown widget token. Re-copy the snippet from the dashboard.
409n/aWidget isn't live yet. Complete the wizard's "Go live" step.
429n/aPer-IP rate limit (§8.2). Usually transient; persistent = scripted abuse.

9.3 Visitor-identity errors

SymptomCause & fix
INVALID_VISITOR_TOKENSignature mismatch — backend AIW_WIDGET_SECRET ≠ dashboard secret, or you rotated the secret. Re-copy it.
TOKEN_EXPIREDexp in the past — clock skew, over-cached token, or a tab open >5 min. Mint on demand.
visitorClaims: null but no errorThe feature toggle is OFF for this widget. Enable it (§5.2).
Pill doesn't pre-fill with a valid tokenAgent question didn't match the contact classifier, the pill was already dismissed this call, or claim names are wrong (use email/phone/name).
Still stuck? Capture a screenshot of both the DevTools Console and the Network tab for any failing api.aiandhumn.com/* request and email contact@asqvox.com. Together those two views cover every known failure mode.