Voice Widget
Embed the Aurion voice widget into your website for real-time AI voice support.
Voice Widget
The Aurion voice widget is an embeddable Preact component that adds AI-powered voice and chat support to any website. It connects to the same AI agent that powers phone and mobile app calls.
Embed the Widget
The widget is bootstrapped by setting window.AurionSettings and then injecting the loader script. The loader is served directly from your Aurion instance at /api/v1/voice-widget/embed/aurion.js. Paste this inline snippet into your page (the admin dashboard generates a copy-paste-ready version pre-filled with your values):
<script>
;(function () {
window.AurionSettings = {
appId: '<your-tenant-id>',
publicKey: 'vwk_xxxxxxxxxxxxxxxxxxxxxxxx',
apiBaseUrl: 'https://apps.aurionai.net',
}
var s = document.createElement('script')
s.src = 'https://apps.aurionai.net/api/v1/voice-widget/embed/aurion.js'
s.async = true
document.head.appendChild(s)
})()
</script>The widget automatically renders a floating button in the bottom-right corner of your page.
There is no separate "widget ID" — bootstrap requires two values:
appId— your tenant UUID.publicKey— the public widget key (formatvwk_…). This is not an API key (itsm_sk_…); it is a public, embeddable identifier paired with an origin allowlist.
Host-Injected End-User Identity
If your host page already knows the signed-in end-user, it can inject that identity before the widget starts a voice session:
window.AurionSettings = {
appId: '<your-tenant-id>',
publicKey: 'vwk_xxxxxxxxxxxxxxxxxxxxxxxx',
apiBaseUrl: 'https://apps.aurionai.net',
userEmail: 'jane@example.com',
userName: 'Jane Example',
}userEmail is trimmed and lowercased by the widget. Blank values are ignored. userName is trimmed and reserved for forward compatibility; the voice session does not trust or send the client-supplied name. Server-side pre-authentication only applies when userEmail matches an active requester and the tenant's voice auth policy allows that shortcut.
When identity becomes available after the loader script has already mounted, update window.AurionSettings and call window.AurionWidget.mount() so the widget re-reads the settings:
window.AurionSettings.userEmail = 'jane@example.com'
window.AurionSettings.userName = 'Jane Example'
window.AurionWidget?.mount?.()On sign-out, delete both identity fields and call mount() again. That resets the widget to anonymous state and prevents the previous user's email from being reused on shared devices.
userEmail is a self-asserted hint, not proof of identity. To give the widget a verified identity, assign getAuthToken: an async function that returns the visitor's end-user access token (issued by the End-User API), or null when nobody is signed in:
window.AurionSettings.getAuthToken = async () => myAuthClient.getEndUserAccessToken() // your sign-in client; null when signed outThe widget calls it whenever it needs a credential — when it opens the chat socket, mints the conversation-history token and requests a voice token — so return a fresh token each time rather than a cached string. This is the only way the shipped widget sends an end-user credential, and only a verified identity lets the chat agent act on the visitor's own tickets (get_my_tickets, create_support_ticket). If the function is absent, returns null or throws, the widget carries on without a verified identity (voice falls back to the self-asserted userEmail, if one is set). The widget reads window.AurionSettings when it mounts, so set getAuthToken before the loader script runs, or call window.AurionWidget.mount() after assigning it — a getter added later is otherwise never called.
Get Your Bootstrap Values
- Log in to the Aurion Admin Dashboard
- Navigate to Configuration > Voice Widget
- Configure appearance, welcome text, and allowed origins
- Copy the generated
embed_snippet(it already contains yourappIdandpublicKey)
Widget Configuration
Configure the widget via the admin dashboard or the API. These endpoints live under /api/v1/voice-widget, which maps to the config API-key scope: GET /config needs config:read and config:write (the route is admin-only), PUT /config needs config:write. A key missing the path scope (config:read on GET, config:write on PUT) receives 403 {"detail": "API key missing required scope: <scope>"}; a GET with config:read but not config:write receives 403 {"detail": "Insufficient permissions: requires config:write"}. The dashboard dialog does not offer config:* scopes — how to get them.
curl "https://apps.aurionai.net/api/v1/voice-widget/config" \
-H "X-API-Key: itsm_sk_live_xxxx"GET /config returns the full VoiceWidgetConfigResponse, including public_widget_key, embed_script_url, and a ready-to-paste embed_snippet.
curl -X PUT "https://apps.aurionai.net/api/v1/voice-widget/config" \
-H "X-API-Key: itsm_sk_live_xxxx" \
-H "Content-Type: application/json" \
-d '{
"enabled": true,
"display_title": "Support",
"launcher_label": "Chat with us",
"welcome_text": "Hi! How can I help you today?",
"launcher_position": "bottom-right",
"accent_theme": "blue",
"primary_color": "#6366F1",
"allowed_origins": ["https://example.com", "https://app.example.com"]
}'enabled (bool), display_title (1–80 chars), and launcher_label (1–40 chars) are required — omitting any of them returns 422. Other fields are optional:
| Field | Type | Notes |
|---|---|---|
allowed_origins | string[] (max 50) | Full normalized origins (scheme://host, no path), e.g. https://example.com |
launcher_position | "bottom-right" | "bottom-left" | Default bottom-right |
accent_theme | "blue" | "emerald" | "amber" | "slate" | Default blue |
primary_color / accent_color | hex string | e.g. #2563eb |
welcome_text | string (max 120) | |
launcher_bubble_message | string (max 80) | |
logo_url | data URL | PNG / JPEG / SVG, base64 encoded |
show_powered_by | bool | |
sso_auto_login | bool | Reserved — currently has no effect on the widget |
theme | "light" | "dark" | "auto" | Default auto |
quick_actions_enabled | bool | Default true |
*_translations | { locale: string } | Per-locale maps for display_title, launcher_label, welcome_text, launcher_bubble_message |
Note allowed_origins are full origins (https://example.com), not bare domains.
Widget Chat API
The public chat surface lives under /api/v1/widget. These endpoints are not authenticated with an API key. Every request authenticates with your widget public key (vwk_…) plus an Origin header that matches your allowed origins; POST /session additionally requires an end-user Bearer token, and POST /messages and the WebSocket optionally accept one (see below). The runtime sends the public key and Origin automatically, and an end-user credential only when the host page supplies one through getAuthToken (see Host-Injected End-User Identity); the examples below show the raw contract.
The optional HMAC-signed email token (returned by /session below) lets a signed-in visitor fetch their own conversation history. It is the output of the session call, not a login credential — and since it grants read access to one person's transcripts, /session is the one endpoint in this group that additionally requires an end-user Bearer token.
Create a Session
Exchange a verified end-user session for an HMAC-signed email_token. Provide your tenant_id and public_key in the body, an allowed Origin, and an end-user Bearer token (see End-User API).
The token is minted for the address the Bearer resolves to. email is optional and is not how the identity is chosen: send it and it must match the verified address, or the request is refused with 403. There is no way to obtain a token for an address you have not authenticated as — the widget public key and Origin are public by construction, so they cannot stand in for proof of ownership.
curl -X POST "https://apps.aurionai.net/api/v1/widget/session" \
-H "Content-Type: application/json" \
-H "Origin: https://example.com" \
-H "Authorization: Bearer <end-user-jwt>" \
-d '{
"tenant_id": "11111111-2222-3333-4444-555555555555",
"public_key": "vwk_xxxxxxxxxxxxxxxxxxxxxxxx"
}'Errors: 401 when no valid end-user session is supplied; 403 when the supplied email names somebody other than the verified account.
Conversation history requires a signed-in visitor. The Bearer is an end-user session token from the End-User API; the Aurion Help Center supplies one for its signed-in visitors. A widget embedded on your own site has no proven identity unless your page supplies such a token through
getAuthToken, so without one/sessionreturns401there and the returning-visitor history panel stays empty. Chat itself is unaffected — sending and receiving messages needs no Bearer, though without one the agent cannot act on the visitor's own tickets (see Send a Message).
Response (200 OK):
{
"email_token": "eyJ0aWQiOiI...base64payload.hexsignature"
}Send a Message
The message body field is body (1–4000 chars), and tenant_id + public_key go in the JSON body. An Authorization: Bearer <end-user-jwt> header is optional (see End-User API).
The Bearer is the only thing that gives the agent a verified identity for the turn. When it resolves to an active end-user session in the widget's tenant, the per-requester ticket tools — get_my_tickets and create_support_ticket — act for that requester. Without it the agent still answers knowledge-base questions, but it cannot list or open tickets for anyone. An invalid, expired, revoked or other-tenant end-user Bearer is not refused for that reason: the request is handled exactly like one sent without a Bearer.
curl -X POST "https://apps.aurionai.net/api/v1/widget/messages" \
-H "Content-Type: application/json" \
-H "Origin: https://example.com" \
-d '{
"tenant_id": "11111111-2222-3333-4444-555555555555",
"public_key": "vwk_xxxxxxxxxxxxxxxxxxxxxxxx",
"body": "I need help resetting my password"
}'The same request for a signed-in visitor adds -H "Authorization: Bearer <end-user-jwt>".
Optional fields: sender_email, source_thread_id, source_message_id, upload_ids (from /uploads), metadata.
sender_email is self-asserted and does not attribute the turn. A message is
filed under a contact only when the request also carries an end-user Bearer that
resolves, and then it is filed under the address that identity owns in the
directory — never under the one in the body. Without a resolving credential the
turn is stored unattributed and contact_id comes back null. An anonymous
caller therefore cannot reach another person's history by naming their address.
Conversation continuity for an unattributed turn is carried by
source_thread_id. On an unattributed turn only a key in the server-minted
widget_<uuid4> form (lowercase) is accepted — omit the field on the first
turn, and the API mints one and returns it as thread_id; send that value back
unchanged on later turns. Do not mint your own: the server checks the form, not
where the value came from, so a key you generate is only as unguessable as your
generator. A value not in that form on an unattributed turn — including any
other random token — is refused with 422 before the turn is processed.
Unattributed conversations sharing a thread id are treated as the same
conversation, so the key is a bearer capability: anyone who holds it can append
to the conversation and have its recent turns replayed as AI context. Keep it
private to the visitor's browser. The shipped widget uses the server's value.
Response (200 OK):
{
"tenant_id": "11111111-2222-3333-4444-555555555555",
"conversation_id": "conv_abc123",
"thread_id": "widget_3f2b8c1e-9d4a-4e7b-8c2f-1a5d6e7f8a9b",
"contact_id": "contact_abc123",
"user_message_id": "msg_user_1",
"ai_message_id": "msg_ai_1",
"ai_response": "I can help you reset your password. Let me look up your account.",
"deduplicated": false
}Get Conversation History
History is fetched with tenant_id and public_key as query params and the email_token from /session in the X-Widget-Email-Token header. The token is never accepted on the URL — a query string lands in access logs, browser history and Referer headers. Optional conversation_limit (1–20, default 5) and message_limit (1–100, default 40) control how much is returned. The Origin header must be allowed.
The token is bound to the end-user session that minted it: once that user signs out, their session expires, or their account is deactivated, /history answers 401 and a new token must be minted with a live bearer.
curl "https://apps.aurionai.net/api/v1/widget/history?tenant_id=11111111-2222-3333-4444-555555555555&public_key=vwk_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Origin: https://example.com" \
-H "X-Widget-Email-Token: eyJ0aWQiOiI..."Response (200 OK):
{
"tenant_id": "11111111-2222-3333-4444-555555555555",
"email": "jane@example.com",
"conversations": [
{
"conversation_id": "conv_abc123",
"thread_id": "widget_3f2b8c1e-9d4a-4e7b-8c2f-1a5d6e7f8a9b",
"subject": "Password reset",
"status": "open",
"updated_at": "2026-06-09T15:00:00Z",
"messages": [
{
"id": "msg_user_1",
"sender_type": "contact",
"body": "I need help resetting my password",
"created_at": "2026-06-09T14:59:00Z"
}
]
}
]
}An invalid or expired email_token returns 401 {"detail": "Invalid or expired email token"}.
Upload an Attachment
Multipart form upload — send tenant_id and public_key as form fields alongside file, with an allowed Origin. Max 20 MB. The declared content type must be one of image/jpeg, image/png, image/heic, application/pdf, application/msword, the three OOXML types (.docx, .xlsx, .pptx), or text/plain. Leading bytes that identify a different known format (for example PDF bytes declared image/png) are refused, and executables are refused whatever type is declared. application/msword must be a legacy binary (OLE2) Word file: RTF, HTML/MHTML or a ZIP-based document declared application/msword is refused. Oversize files return 413; other rejected uploads return 422.
curl -X POST "https://apps.aurionai.net/api/v1/widget/uploads" \
-H "Origin: https://example.com" \
-F "tenant_id=11111111-2222-3333-4444-555555555555" \
-F "public_key=vwk_xxxxxxxxxxxxxxxxxxxxxxxx" \
-F "file=@screenshot.png"Response (201 Created):
{
"upload_id": "upl_abc123",
"file_name": "screenshot.png",
"content_type": "image/png",
"size_bytes": 48213
}Pass the returned upload_id in the upload_ids array on a subsequent /messages (or WebSocket) send to attach it.
WebSocket Connection
For real-time streaming, the widget connects via WebSocket. Auth is by tenant_id + public_key query params (both required) plus an allowed Origin. Optional source_thread_id and sender_email query params are also accepted. sender_email is self-asserted here exactly as on /messages: it confers no identity and does not attribute the turn.
A browser cannot set an Authorization header on a WebSocket, so the optional end-user credential travels in the Sec-WebSocket-Protocol handshake header instead: offer the subprotocol aurion-end-user-token followed by the end-user JWT as the next subprotocol. The server echoes back only aurion-end-user-token, never the token. It has the same effect as the Bearer on /messages: when it resolves, get_my_tickets and create_support_ticket act for that requester while it keeps validating (see re-authorization below); when it does not, the socket opens anonymously rather than being refused. The credential is never read from the URL — a query string lands in access logs, browser history and Referer headers.
// `url` is the wss:// URL shown below. With no token, omit the protocols argument.
const ws = new WebSocket(url, ['aurion-end-user-token', endUserJwt])wss://apps.aurionai.net/api/v1/widget/ws?tenant_id=11111111-2222-3333-4444-555555555555&public_key=vwk_xxxxxxxxxxxxxxxxxxxxxxxxMissing tenant_id or public_key, or a disallowed origin, closes the socket with code 1008.
Send a message as a JSON frame with a body field (max length applies; optional source_thread_id, sender_email, source_message_id, upload_ids, metadata). The /messages thread-key rule applies to source_thread_id here too, whether it comes from the frame or the connect-time query param: on an unattributed turn only a key in the server-minted widget_<uuid4> form is accepted, and a value not in that form is answered with a validation_error frame instead of a reply.
{
"body": "I need help resetting my password"
}The server streams a sequence of JSON frames in response: typing, ack, one or more chunk frames carrying the streamed AI reply, then done. Validation problems and rate limits arrive as error frames.
An open socket is re-authorized periodically. If the tenant is suspended, the widget is disabled, its key is rotated or the origin is removed, the server closes the established socket with code 1008; do not reconnect automatically, because the connection would be refused. If only the visitor's end-user token stops validating, the socket stays open but anonymous, and the server sends {"type": "identity", "status": "anonymous"} once, before the next reply. Clients should ignore frame types they do not recognise.
Concatenate the chunk values verbatim, with no separator of your own — "".join(chunks) reproduces the reply exactly, apart from any leading or trailing whitespace, which the server trims before chunking. Every frame after the first begins with the whitespace that separated it from the previous one, which may be a space, a tab or a newline, so do not trim frames and do not join them with " "; either would corrupt the reply.
{
"type": "chunk",
"index": 0,
"conversation_id": "conv_abc123",
"thread_id": "widget_3f2b8c1e-9d4a-4e7b-8c2f-1a5d6e7f8a9b",
"chunk": "I can help you reset your password."
}{
"type": "error",
"code": "validation_error",
"message": "body is required"
}To keep a signed-in socket authenticated past its token's expiry, present the refreshed end-user JWT on the open socket instead of re-dialing:
{
"type": "reauth",
"token": "<refreshed end-user JWT>",
"id": "r1"
}id is your correlation id: when it is a string of 1–64 characters the server echoes it on the answer, so you can tell which token a reply is about and re-send one whose reply never arrived. The server answers with one reauth frame:
{
"type": "reauth",
"id": "r1",
"status": "accepted"
}accepted— the token is valid for the same end-user session the socket connected with. The socket now uses it; if it had been downgraded to anonymous, the identity is restored and the pendingidentitynotice is not sent; and the periodic re-authorization restarts from the new token.rejected— the token is missing or could not be validated (including a transient fault while validating it), it belongs to a different requester or session, or the socket connected anonymously. Nothing changes. A new sign-in, even by the same person, is a new session: re-dial the socket with the new token rather than sendingreauth.unavailable— the check could not run right now. The socket keeps the token it had; try again shortly.
If the re-authorization finds the widget itself no longer authorized (tenant suspended, widget disabled, key rotated, origin removed), the server closes the socket with code 1008 exactly as the periodic check does. reauth frames count against the same per-socket rate limit as messages; a rate-limited reauth frame — like any frame the socket refuses before evaluating it — is answered with an error frame that carries no id, so treat a correlation id that never gets a reauth answer as unanswered and re-send it.
Voice Token
There are two ways to obtain a LiveKit participant token for a voice session, depending on who is calling.
Authenticated path: POST /voice-widget/token
For an authenticated tenant member (dashboard session or an API key with config:write and calls:write). It takes no request body — identity and room are derived from the authenticated user. Rate-limited to 5/minute. The dashboard dialog does not offer config:* scopes — how to get them.
curl -X POST "https://apps.aurionai.net/api/v1/voice-widget/token" \
-H "X-API-Key: itsm_sk_live_xxxx"Response:
{
"token": "livekit_participant_token",
"livekit_url": "wss://lk-staging.aurionai.net",
"room_name": "room_abc123",
"participant_identity": "user_jane",
"pre_authenticated": true,
"expires_in_seconds": 3600
}The token field is livekit_url (the client WebSocket URL — the literal host is environment-dependent), and expires_in_seconds is 3600, so re-request after 60 minutes.
Public embed path: POST /voice-widget/embed/token
This is the path the embedded widget actually calls. It is public — authenticated by your widget public_key + an allowed Origin, not by an API key. Provide tenant_id and public_key; user_email is an optional, self-asserted hint (it only elevates pre_authenticated when the tenant has fully disabled voice auth and the email matches an existing requester).
curl -X POST "https://apps.aurionai.net/api/v1/voice-widget/embed/token" \
-H "Content-Type: application/json" \
-H "Origin: https://example.com" \
-d '{
"tenant_id": "11111111-2222-3333-4444-555555555555",
"public_key": "vwk_xxxxxxxxxxxxxxxxxxxxxxxx"
}'The response is the same VoiceWidgetTokenResponse shape shown above (token, livekit_url, room_name, participant_identity, pre_authenticated, expires_in_seconds).
Public embed branding: POST /voice-widget/embed/config
Returns the widget's branding/behavior config for rendering. Same public auth model (tenant_id + public_key body, allowed Origin):
curl -X POST "https://apps.aurionai.net/api/v1/voice-widget/embed/config" \
-H "Content-Type: application/json" \
-H "Origin: https://example.com" \
-d '{
"tenant_id": "11111111-2222-3333-4444-555555555555",
"public_key": "vwk_xxxxxxxxxxxxxxxxxxxxxxxx"
}'It returns the public-facing subset of the config (display_title, welcome_text, launcher_label, launcher_position, accent_theme, colors, logo_url, show_powered_by, mode, theme, plus the *_translations maps) — never the public_widget_key itself.
Origin Allowlist
For security, the widget only loads on origins you explicitly allow. Origins are full, normalized scheme://host values (e.g. https://example.com, no path or query), stored in allowed_origins and configured in the admin dashboard or via PUT /config. Requests whose Origin header is not allowed receive a 403 response.
First-party Aurion Help Center origins (under the configured Help Center domain suffix, including tenant subdomains) are auto-allowed and do not need an explicit allowed_origins entry.
Rate Limits
Per-endpoint limits on the widget surface:
| Endpoint | Limit |
|---|---|
POST /widget/session | 30/minute |
POST /widget/messages | 60/minute |
POST /widget/uploads | 30/minute |
POST /voice-widget/token | 5/minute |
API-key requests (e.g. the /voice-widget/config endpoints) are additionally subject to the plan-based limits described in Error Handling → Rate Limiting, which also covers the 429 bodies and headers.
Multimodal Support
The widget supports both voice and chat. The active surface is driven by the mode field on the embed config ("chat", "voice", or "both", default "both"). When both is configured, users can switch between voice and chat during a conversation and the AI agent maintains context across the switch.
Regenerate Widget Key
If your public widget key is compromised, regenerate it. This is a non-GET call under /api/v1/voice-widget, so an API key needs config:write (not offered by the dashboard dialog):
curl -X POST "https://apps.aurionai.net/api/v1/voice-widget/config/regenerate-key" \
-H "X-API-Key: itsm_sk_live_xxxx"Response:
{
"public_widget_key": "vwk_xxxxxxxxxxxxxxxxxxxxxxxx",
"embed_snippet": "<script>(function(){window.AurionSettings={...};...})();</script>"
}The old key stops working immediately — update your embed snippet with the new public_widget_key (or paste the returned embed_snippet).
GA Readiness Check
Check whether the voice widget is ready to embed for your tenant. An API key needs config:read and config:write (the route is admin-only; not offered by the dashboard dialog):
curl "https://apps.aurionai.net/api/v1/voice-widget/ga-readiness" \
-H "X-API-Key: itsm_sk_live_xxxx"Response:
{
"ready": true,
"failed_checks": [],
"checks": {
"allowed_origins_configured": { "passed": true, "reason": null, "details": {} }
}
}ready is false when any check fails; failed_checks lists the failing check keys.
Required Scopes
The /api/v1/voice-widget admin endpoints are gated by the config API-key scope (the path prefix maps to the config resource — there is no voice-widget:* scope). The public /widget/* and /voice-widget/embed/* endpoints use the widget public key + origin allowlist, not API-key scopes. The dashboard's Create API Key dialog does not offer config:* scopes; create such a key with POST /api/v1/api-keys (see API Key Management).
| Scope | Endpoints |
|---|---|
config:read + config:write | GET /voice-widget/config, GET /voice-widget/ga-readiness |
config:write | PUT /voice-widget/config, POST /voice-widget/config/regenerate-key |
config:write + calls:write | POST /voice-widget/token |
| none (public key + Origin) | GET /widget/history, POST /widget/uploads, POST /voice-widget/embed/token, POST /voice-widget/embed/config |
| public key + Origin, optional end-user Bearer | POST /widget/messages, WS /widget/ws (credential in Sec-WebSocket-Protocol) |
| public key + Origin and an end-user Bearer | POST /widget/session |
Related Guides
- Calls & Recordings — Access call data, transcripts, and recordings
- End-User API — End-user authentication for widget sessions
- Conversations — Widget conversations in the helpdesk
- Channels — Channel configuration overview