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
livereturns409on resolve. - A page served over HTTPS — the browser will not grant microphone access on plain HTTP (§2.1).
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.
<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.
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.
| Attribute | Required | Purpose |
|---|---|---|
src | Yes | The bundle URL — https://widget.aiandhumn.com/aiw-voice-widget.js. |
data-widget-token | Yes | Your public widget token. The backend resolves tenant + agent from this — no tenant/assistant IDs needed. |
data-backend-url | Optional | Override the API origin (e.g. point a staging page at the dev API). Defaults to the production API. |
data-visitor-token | Optional | A signed HS256 JWT to pre-fill the visitor's name/email/phone. See §5. |
defer | Recommended | Bootstraps after first paint. Manual orb clicks still work even before bootstrap finishes. |
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:
[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.
# 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.
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:
- The iframe element must carry
allow="microphone":parent.html<iframe src="/widget-host.html" allow="microphone"></iframe>
- The parent page's
Permissions-Policymust permit microphone for the iframe's origin. Same origin →microphone=(self)is enough. Different origin →microphone=(self "https://your-iframe-origin.com").
<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.
| Origin | Result 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.net | ❌ 403 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
Originheader (401 ORIGIN_REQUIRED). Add anOriginheader 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.
| Method | Gated? | Purpose |
|---|---|---|
open(anchor?) | gesture | Open 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) | no | Set the visitor-identity JWT for pre-fill. Pure setter — no side effects until the next call (§5.4). |
onNavigate(handler) | no | Register your SPA router so guided navigation uses it. Pass null to clear (§3.5). |
navigate(path, sectionSelector?) | no | Programmatic 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.
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:
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:
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() 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 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.
// 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
| Type | Parameter | Fires when… |
|---|---|---|
urlMatches | pattern (wildcard, e.g. */pricing*, case-insensitive) | The current URL matches the pattern. |
dwellSeconds | gte (number) | Seconds spent on the page (while the tab is visible) ≥ gte. |
scrollDepth | gte (0–1) | Max scroll depth reached this page-view ≥ gte (e.g. 0.6 = 60%). |
exitIntent | — | The pointer moves toward the top viewport edge (exit gesture). |
idleSeconds | gte (number) | Seconds since last activity (scroll/mouse/key/touch) ≥ gte. The idle clock only starts after the first activity. |
repeatVisitor | visitCountGte? (number, optional) | Visitor has been here before (or total visits ≥ visitCountGte). Tracked cross-session per widget token. |
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 forNdays 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:
AIW_WIDGET_SECRET=<your 64-char hex 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.
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):
| Claim | Required | Type | Notes |
|---|---|---|---|
sub | Yes | string | Your internal user identifier. |
exp | Yes | seconds | Expiry. Recommend now + 300. |
iat | — | seconds | Issued-at. Future-dated tokens are rejected. |
email | — | string | Pre-fills the email pill. |
phone | — | string | Pre-fills the phone pill. |
name | — | string | Personalisation / call analytics. |
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):
<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):
const token = await fetch('/api/visitor-token').then((r) => r.text()); window.AIWWidget.identify(token);
5.5 Verify it works
- Visit a page with the widget as a logged-in user; open DevTools → Network.
- Start a call; find the
POST /api/sessionsrequest. The body should carry avisitor_tokenfield; the response should carry avisitorClaimsobject with your email/phone/name. - 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.
7 Guided navigation (co-browse)
7.1 What it does Intermediate / Advanced
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.
7.2 SPA router hook (recommended for SPAs)
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):
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.
7.3 Nav map & the same-origin constraint
- 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.urlis 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.
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
| Protection | Handled by | What you do |
|---|---|---|
| Origin / domain enforcement | Backend | Register the correct domain in the wizard (§2.4). |
| Resolve→session HMAC challenge | Widget + backend (automatic) | Nothing — minted and sent for you. |
| Engagement gate (bot defense) | Widget + backend (automatic) | Nothing. |
User-gesture gate on open() | Widget | Call open() synchronously from a real gesture (§3.2). |
| Visitor-identity JWT signing | You | Keep the secret server-side; mint short-lived HS256 tokens (§5.2). |
| Microphone permission | Your server headers + visitor browser | Serve 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.
| Endpoint | Limit (per IP) |
|---|---|
POST /api/widget/resolve | 60 / minute |
POST /api/sessions | 30 / hour |
POST /api/sessions/:id/captures | 60 / 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 line | Cause | Fix |
|---|---|---|
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 dismissed | Visitor 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 application | Zoom/OBS holds the mic. | Visitor closes the other app, retries. |
AIWWidget.open() rejected — must be called synchronously from a real user gesture | open() 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)
| Status | error code | Cause & fix |
|---|---|---|
403 | ORIGIN_NOT_ALLOWED | Page origin doesn't share a registrable domain with the widget. Update the domain in the dashboard, or make a separate widget (§2.4). |
401 | ORIGIN_REQUIRED | No 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. |
409 | n/a | Widget isn't live yet. Complete the wizard's "Go live" step. |
429 | n/a | Per-IP rate limit (§8.2). Usually transient; persistent = scripted abuse. |
9.3 Visitor-identity errors
| Symptom | Cause & fix |
|---|---|
INVALID_VISITOR_TOKEN | Signature mismatch — backend AIW_WIDGET_SECRET ≠ dashboard secret, or you rotated the secret. Re-copy it. |
TOKEN_EXPIRED | exp in the past — clock skew, over-cached token, or a tab open >5 min. Mint on demand. |
visitorClaims: null but no error | The feature toggle is OFF for this widget. Enable it (§5.2). |
| Pill doesn't pre-fill with a valid token | Agent question didn't match the contact classifier, the pill was already dismissed this call, or claim names are wrong (use email/phone/name). |
api.aiandhumn.com/* request and email contact@asqvox.com. Together those two views cover every known failure mode.