Custom Widget SDK 1.8.11
Render your first widget
Keep your existing website and backend. Your server authenticates the user; the browser loads the configured widget.
This choice changes the frontend instructions, not your backend language. Prefer a complete project? Choose a starter download.
Collect the integration values
Use the values supplied by Returning.AI for your target environment. The widget ID is a string: copy it unchanged, with its matching bundle URL. Your client-specific handover may include these values; you do not need a tailored demo to follow this guide.
- Returning.AI supplies
- Widget ID, bundle URL, server access credentials, configured identity keys and any environment overrides.
- Your team supplies
- The exact portal origin and a trusted signed-in user. Use a registered user whose identity matches the configured Returning.AI mapping.
If registration is not connected yet, enroll and verify a user first. Enrollment and token request property names can differ; their configured mapping and values must agree.
Confirm the allowed origin
Ask the authorized Returning.AI administrator or your setup contact to allow your exact browser origin, such as https://portal.example.com. Scheme, hostname and port must match. Do not assume your developer account can change these settings.
This path needs a trusted backend
Access Key authentication keeps the secret key on your server. If you have no backend, ask Returning.AI whether your supplied setup permits legacy attribute authentication; that option exposes identity in the browser and is not an equivalent security model.
Connect your server authentication
There are two requests. First, the browser calls your own protected endpoint using its existing login session. This example uses the same origin for the page and backend; a separate-origin API needs its own session and cross-origin setup.
GET /api/widget-token
Accept: application/json
Cookie: <your existing authenticated session>
200 OK
Cache-Control: no-store
{
"embedToken": "SHORT_LIVED_TOKEN",
"expiresIn": 900
}/api/widget-token is a route in your application, not a Returning.AI endpoint. It accepts no customer identity from the browser. Return embedToken and, when the upstream response supplies a valid value, expiresIn.
Your backend resolves the signed-in user, then makes this second request. Replace the example identifier key with your configured key; its value comes from the trusted session.
POST https://api-v2.returning.ai/v2/api/widget-access-keys/token
Content-Type: application/json
{
"accessId": "SERVER_ACCESS_ID",
"accessKey": "SERVER_ACCESS_KEY",
"userIdentifiers": {
"data-customer-id": "VALUE_FROM_TRUSTED_HOST_SESSION"
}
}
// Returning.AI wraps the result:
// { "data": { "embedToken": "...", "expiresIn": 900 } }Read data.embedToken and data.expiresIn from Returning.AI, then return the flattened response above. Use the supplied endpoint for dedicated environments. Node is not required as your production backend.
The access key stays on your server
Never put the access key in HTML, browser JavaScript, a mobile app or a shared URL. Keep token responses non-cacheable, and do not log tokens or raw upstream responses.
Example server implementations
Choose independently of your frontend. These implementations use Express, PHP sessions and Django respectively; connect their trusted-session boundary to your existing login. The complete starters below include their own reference backend.
The sample host returns 401 for missing authentication, 403 for a prohibited origin, 503 for missing configuration, 500 for invalid mapped identity, 502 for upstream failure and 405 for the wrong method. These are the reference host's responses, not Returning.AI API status guarantees. Authentication details.
Load the SDK once
Save the pinned SDK in your site's public files. The supplied HTML loads it once from this path before the widget module; do not add a second SDK script. No Node or frontend build tool is required for this plain HTML path.
mkdir -p vendor/sdk
curl --fail https://unpkg.com/@returningai/widget-sdk@1.8.11/dist/rai-widget.iife.js \
--output vendor/sdk/rai-widget.iife.jsClient-rendered React. Use JSX types that match your React version. The source below is tested with React 19.2.3 / Vite 6.3.5. Install the SDK in your existing frontend project. Your framework's build tools may need Node even when your production backend uses another language.
npm install @returningai/widget-sdk@1.8.11Next.js App Router. Register the browser SDK after mount. The source below is tested with Next.js 16.3.4 / React 19.2.3. Install the SDK in your existing frontend project. Your framework's build tools may need Node even when your production backend uses another language.
npm install @returningai/widget-sdk@1.8.11Vue with Vite. Mark rai-* tags as custom elements in the compiler. The source below is tested with Vue 3.5.21 / Vite 6.3.5. Install the SDK in your existing frontend project. Your framework's build tools may need Node even when your production backend uses another language.
npm install @returningai/widget-sdk@1.8.11Browser-rendered standalone Angular. Angular SSR is outside this example. The source below is tested with Angular 22.1.5 / TypeScript 6.0.2. Install the SDK in your existing frontend project. Your framework's build tools may need Node even when your production backend uses another language.
npm install @returningai/widget-sdk@1.8.11import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag === "rai-custom-widget",
},
},
}),
],
build: {
outDir: "dist",
emptyOutDir: true,
},
});Connect the frontend widget
Use the selected binding with your supplied public widget configuration and protected token endpoint. Replace placeholder values; keep the token request tied to the current host session. The examples include the initial connection, not just an empty widget tag.
The included HTML, React, Vue and Angular entry files read this public JSON from your own host. Serve it at /api/config, or pass the same object directly to the framework component's config prop/input. This is a chosen host route, not a Returning.AI API. PHP and Django token examples do not create it for you.
GET /api/config
200 OK
Content-Type: application/json
{
"widgetId": "YOUR_WIDGET_ID",
"bundleUrl": "https://your-cdn.example/widget.js"
}Replace both values with your handover settings. Only add apiUrl, v2ApiUrl or domainKey if supplied for your environment. Never add credentials or a signing identity. Next.js's page example below passes this object directly.
For HTML without this route, set window.returningAiWidgetConfig = { widgetId, bundleUrl } in a script before app.js. Keep the IDs and containers in the supplied markup. Frontend source paths below assume the existing site's public root; adjust asset URLs if hosting under a subpath.
import { createWidgetLifecycle } from "./shared/widget-lifecycle.js";
const STORAGE_PREFIX = "returning-ai-html-node-starter";
const widgetSlot = document.querySelector("#widget-slot");
const statusElement = document.querySelector("#app-status");
const errorPanel = document.querySelector("#error-panel");
const errorMessage = document.querySelector("#error-message");
const retryButton = document.querySelector("#retry-button");
const logoutButton = document.querySelector("#logout-button");
let publicConfig;
let publicConfigPromise;
let lifecycle;
let stopped = false;
let sessionGeneration = 0;
let bootstrapGeneration = 0;
let bootstrapInFlight;
function setState(state) {
document.body.dataset.appState = state;
}
function setStatus(message) {
statusElement.textContent = message;
}
function showError(message) {
if (stopped) return;
setState("error");
setStatus("The widget is not available.");
errorMessage.textContent = message;
errorPanel.hidden = false;
}
function hideError() {
errorPanel.hidden = true;
}
function clearStarterStorage() {
try {
for (const key of Object.keys(localStorage)) {
if (key.startsWith(`${STORAGE_PREFIX}-`)) localStorage.removeItem(key);
}
} catch {
// Storage can be disabled by the browser. Local widget cleanup still runs.
}
}
function isPublicConfig(value) {
return Boolean(
value &&
typeof value === "object" &&
typeof value.widgetId === "string" &&
value.widgetId &&
typeof value.bundleUrl === "string" &&
value.bundleUrl,
);
}
function getInlinePublicConfig() {
const inline = window.returningAiWidgetConfig;
if (inline === undefined) return null;
if (!isPublicConfig(inline)) {
throw new Error("window.returningAiWidgetConfig needs widgetId and bundleUrl");
}
return inline;
}
async function loadPublicConfig() {
const response = await fetch("/api/config", {
credentials: "same-origin",
cache: "no-store",
headers: { Accept: "application/json" },
});
const config = await response.json().catch(() => null);
if (!response.ok || !isPublicConfig(config)) {
throw new Error("Set the widget ID and bundle URL in the server environment first");
}
return config;
}
async function resolvePublicConfig() {
const inline = getInlinePublicConfig();
if (inline) return inline;
publicConfigPromise ??= loadPublicConfig().catch((error) => {
publicConfigPromise = undefined;
throw error;
});
return publicConfigPromise;
}
async function waitForSdk() {
if (customElements.get("rai-custom-widget")) return;
await Promise.race([
customElements.whenDefined("rai-custom-widget"),
new Promise((_, reject) => {
setTimeout(() => reject(new Error("The Returning.AI SDK did not load")), 5000);
}),
]);
}
function createWidget() {
const element = document.createElement("rai-custom-widget");
element.setAttribute("widget-id", publicConfig.widgetId);
element.setAttribute("bundle-url", publicConfig.bundleUrl);
element.setAttribute("height", "auto");
element.setAttribute("eager", "");
element.setAttribute("max-retries", "1");
element.setAttribute("storage-prefix", STORAGE_PREFIX);
if (publicConfig.apiUrl) element.setAttribute("api-url", publicConfig.apiUrl);
if (publicConfig.v2ApiUrl) element.setAttribute("v2-api-url", publicConfig.v2ApiUrl);
if (publicConfig.domainKey) element.setAttribute("domain-key", publicConfig.domainKey);
return element;
}
function makeLifecycle() {
return createWidgetLifecycle({
endpoint: "/api/widget-token",
createWidget,
widgetSlot,
onState: (state) => {
if (stopped) return;
setState(state);
if (state === "loading") setStatus("Loading secure session...");
if (state === "refreshing") setStatus("Refreshing secure session...");
if (state === "authenticating") setStatus("Authenticating widget...");
},
onError: showError,
onAuthenticated: () => {
if (stopped) return;
setState("authenticating");
setStatus("Authenticated. Loading experience...");
},
onMounted: () => {
if (stopped) return;
setState("ready");
setStatus("Experience ready.");
hideError();
logoutButton.hidden = false;
},
onToken: () => {
if (!stopped) hideError();
},
});
}
function isCurrentBootstrap(generation) {
return !stopped && bootstrapGeneration === generation;
}
async function startSession(nextGeneration = sessionGeneration) {
if (stopped) return;
const generation = bootstrapGeneration;
if (bootstrapInFlight?.generation === generation) {
return bootstrapInFlight.promise;
}
const promise = (async () => {
const resolvedConfig = publicConfig ?? (await resolvePublicConfig());
if (!isCurrentBootstrap(generation)) return;
publicConfig ??= resolvedConfig;
await waitForSdk();
if (!isCurrentBootstrap(generation)) return;
lifecycle ??= makeLifecycle();
await lifecycle.start({ sessionGeneration: nextGeneration });
})();
bootstrapInFlight = { generation, promise };
promise.then(
() => {
if (bootstrapInFlight?.promise === promise) bootstrapInFlight = undefined;
},
() => {
if (bootstrapInFlight?.promise === promise) bootstrapInFlight = undefined;
},
);
return promise;
}
function retry() {
void startSession().catch((error) => showError(error instanceof Error ? error.message : "Widget startup failed"));
}
function logout() {
if (stopped) return;
stopped = true;
bootstrapGeneration += 1;
bootstrapInFlight = undefined;
logoutButton.disabled = true;
// dispose() removes the element, aborts token work and clears timers before
// it makes the SDK logout request. logoutPublic is best effort and unawaited.
lifecycle?.dispose();
clearStarterStorage();
logoutButton.hidden = true;
hideError();
setState("signed-out");
setStatus("Signed out. Reload this page to start again.");
}
// The host owns authentication. On host logout/account change it should call
// this boundary with a new local generation. The marker is never sent upstream.
window.returningAiHostSessionChanged = (nextGeneration) => {
if (stopped) return;
sessionGeneration = nextGeneration;
bootstrapGeneration += 1;
void startSession(nextGeneration).catch((error) => showError(error instanceof Error ? error.message : "Widget startup failed"));
};
retryButton.addEventListener("click", retry);
logoutButton.addEventListener("click", logout);
window.addEventListener("pagehide", () => {
bootstrapGeneration += 1;
bootstrapInFlight = undefined;
lifecycle?.dispose({ logout: false });
clearStarterStorage();
});
void startSession().catch((error) => showError(error instanceof Error ? error.message : "Widget startup failed"));Required support files (4)
The shared helpers are required by the binding above. The entry files show initial mounting; merge that wiring into your existing app instead of overwriting its page. Save source at the shown paths, or update imports to match your layout. These are individual frontend files, not a starter ZIP, and contain no backend. Styles are optional if your app supplies its own; remove their import or link if omitted.
import { useEffect, useRef, useState } from "react";
import { createWidgetLifecycle } from "./shared/widget-lifecycle.js";
const STORAGE_PREFIX = "returning-ai-react-node-starter";
function clearStarterStorage() {
try {
for (const key of Object.keys(localStorage)) {
if (key.startsWith(`${STORAGE_PREFIX}-`)) localStorage.removeItem(key);
}
} catch {
// Storage can be disabled. The local widget is still removed.
}
}
function WidgetExperience({ config, sessionGeneration = 0 }) {
const widgetSlotRef = useRef(null);
const retryRef = useRef(() => undefined);
const disconnectRef = useRef(() => undefined);
const [status, setStatus] = useState("Loading secure session...");
const [error, setError] = useState(null);
const [ready, setReady] = useState(false);
const [signedOut, setSignedOut] = useState(false);
useEffect(() => {
let active = true;
let stopped = false;
const createWidget = () => {
const element = document.createElement("rai-custom-widget");
element.setAttribute("widget-id", config.widgetId);
element.setAttribute("bundle-url", config.bundleUrl);
element.setAttribute("height", "auto");
element.setAttribute("eager", "");
element.setAttribute("max-retries", "1");
element.setAttribute("storage-prefix", STORAGE_PREFIX);
if (config.apiUrl) element.setAttribute("api-url", config.apiUrl);
if (config.v2ApiUrl) element.setAttribute("v2-api-url", config.v2ApiUrl);
if (config.domainKey) element.setAttribute("domain-key", config.domainKey);
return element;
};
const lifecycle = createWidgetLifecycle({
endpoint: "/api/widget-token",
createWidget,
widgetSlot: widgetSlotRef.current,
onState: (state) => {
if (!active || stopped) return;
document.body.dataset.appState = state;
if (state === "loading") setStatus("Loading secure session...");
if (state === "refreshing") setStatus("Refreshing secure session...");
if (state === "authenticating") setStatus("Authenticating widget...");
},
onError: (message) => {
if (!active || stopped) return;
setReady(false);
setError(message);
setStatus("The widget is not available.");
},
onAuthenticated: () => {
if (!active || stopped) return;
setStatus("Authenticated. Loading experience...");
},
onMounted: () => {
if (!active || stopped) return;
setReady(true);
setError(null);
setStatus("Experience ready.");
},
onToken: () => {
if (active && !stopped) setError(null);
},
});
retryRef.current = () => {
if (stopped) return;
setError(null);
void lifecycle.start({ sessionGeneration }).catch((reason) => {
if (active) setError(reason instanceof Error ? reason.message : "Widget startup failed");
});
};
disconnectRef.current = () => {
if (!active || stopped) return;
stopped = true;
setReady(false);
// Local cleanup happens before the best-effort, unawaited SDK logout.
lifecycle.dispose();
clearStarterStorage();
setError(null);
setSignedOut(true);
setStatus("Signed out. Reload this page to start again.");
};
setSignedOut(false);
void lifecycle.start({ sessionGeneration }).catch((reason) => {
if (active) {
setError(reason instanceof Error ? reason.message : "Widget startup failed");
setStatus("The widget is not available.");
}
});
return () => {
active = false;
stopped = true;
retryRef.current = () => undefined;
disconnectRef.current = () => undefined;
lifecycle.dispose();
clearStarterStorage();
};
}, [config, sessionGeneration]);
return (
<section className="card" aria-labelledby="widget-heading">
<div className="card-heading">
<div>
<p className="eyebrow">SDK 1.8.11</p>
<h2 id="widget-heading">Your experience</h2>
</div>
{!signedOut && (
<button className="button secondary" type="button" onClick={() => disconnectRef.current()}>
Disconnect widget
</button>
)}
</div>
<p className="status" role="status" aria-live="polite">{status}</p>
{error && (
<div className="error-panel" role="alert">
<p>{error}</p>
<button className="button" type="button" onClick={() => retryRef.current()}>Try again</button>
</div>
)}
<div ref={widgetSlotRef} className="widget-slot" aria-live="polite" />
{ready && <p className="ready-note">The SDK mounted the supplied bundle.</p>}
</section>
);
}
export default function App() {
const [config, setConfig] = useState(null);
const [error, setError] = useState(null);
const [sessionGeneration, setSessionGeneration] = useState(0);
useEffect(() => {
window.returningAiHostSessionChanged = (nextGeneration) => {
setSessionGeneration(nextGeneration);
};
return () => {
delete window.returningAiHostSessionChanged;
};
}, []);
useEffect(() => {
fetch("/api/config", { credentials: "same-origin", cache: "no-store" })
.then(async (response) => {
const payload = await response.json().catch(() => null);
if (!response.ok || !payload?.widgetId || !payload?.bundleUrl) {
throw new Error("Set the widget ID and bundle URL in the server environment first");
}
return payload;
})
.then(setConfig)
.catch((reason) => setError(reason instanceof Error ? reason.message : "Configuration failed"));
}, []);
return (
<main className="page-shell">
<header className="intro">
<p className="eyebrow">Returning.AI starter</p>
<h1>Authenticated custom widget</h1>
<p>The server mints the short-lived embed token. The browser receives only that token and public widget configuration.</p>
</header>
{error && <div className="error-panel" role="alert"><p>{error}</p></div>}
{config && <WidgetExperience config={config} sessionGeneration={sessionGeneration} />}
<p className="security-note">
The disconnect button clears only the widget session. The host application owns user logout and token revocation.
</p>
</main>
);
}
export { WidgetExperience };Required support files (6)
The shared helpers are required by the binding above. The entry files show initial mounting; merge that wiring into your existing app instead of overwriting its page. Save source at the shown paths, or update imports to match your layout. These are individual frontend files, not a starter ZIP, and contain no backend. Styles are optional if your app supplies its own; remove their import or link if omitted.
"use client";
import { useEffect, useRef, useState } from "react";
import type { PublicWidgetConfig } from "@/lib/types";
import { createWidgetLifecycle } from "@/lib/shared/widget-lifecycle.js";
const STORAGE_PREFIX = "returning-ai-nextjs-starter";
type WidgetElement = HTMLElement & {
logoutPublic?: () => Promise<void>;
};
function clearStarterStorage(): void {
try {
for (const key of Object.keys(window.localStorage)) {
if (key.startsWith(`${STORAGE_PREFIX}-`)) {
window.localStorage.removeItem(key);
}
}
} catch {
// Storage can be disabled by the browser. Local widget cleanup still runs.
}
}
function setOptionalAttribute(
element: HTMLElement,
name: string,
value: string | undefined,
): void {
if (value) element.setAttribute(name, value);
}
export function WidgetStarter({
config,
sessionGeneration = 0,
}: {
config: PublicWidgetConfig;
/** Host-owned local marker. Never sent to Returning.AI or used as identity. */
sessionGeneration?: string | number;
}) {
const widgetSlotRef = useRef<HTMLDivElement>(null);
const retryRef = useRef<() => void>(() => undefined);
const logoutRef = useRef<() => void>(() => undefined);
const [status, setStatus] = useState("Loading secure session...");
const [error, setError] = useState<string | null>(null);
const [ready, setReady] = useState(false);
const [signedOut, setSignedOut] = useState(false);
const [hostSessionGeneration, setHostSessionGeneration] = useState(sessionGeneration);
useEffect(() => {
setHostSessionGeneration(sessionGeneration);
}, [sessionGeneration]);
useEffect(() => {
const hostWindow = window as Window & {
returningAiHostSessionChanged?: (generation: string | number) => void;
};
hostWindow.returningAiHostSessionChanged = (generation) => {
setHostSessionGeneration(generation);
};
return () => {
delete hostWindow.returningAiHostSessionChanged;
};
}, []);
useEffect(() => {
let active = true;
let stopped = false;
let lifecycle: ReturnType<typeof createWidgetLifecycle>;
const setAppState = (value: string) => {
document.body.dataset.appState = value;
};
const showError = (message: string) => {
if (!active || stopped) return;
setAppState("error");
setReady(false);
setError(message);
setStatus("The widget is not available.");
};
const waitForSdk = async () => {
// Browser-only registration. This import stays inside the client effect so
// Next server rendering never evaluates customElements or window.
await import("@returningai/widget-sdk");
if (customElements.get("rai-custom-widget")) return;
await Promise.race([
customElements.whenDefined("rai-custom-widget"),
new Promise<never>((_, reject) => {
window.setTimeout(
() => reject(new Error("The Returning.AI SDK did not load")),
5000,
);
}),
]);
};
const createWidget = (): WidgetElement => {
const element = document.createElement("rai-custom-widget") as WidgetElement;
element.setAttribute("widget-id", config.widgetId);
element.setAttribute("bundle-url", config.bundleUrl);
element.setAttribute("height", "auto");
element.setAttribute("eager", "");
element.setAttribute("max-retries", "1");
element.setAttribute("storage-prefix", STORAGE_PREFIX);
setOptionalAttribute(element, "api-url", config.apiUrl);
setOptionalAttribute(element, "v2-api-url", config.v2ApiUrl);
setOptionalAttribute(element, "domain-key", config.domainKey);
return element;
};
lifecycle = createWidgetLifecycle({
endpoint: "/api/widget-token",
createWidget,
widgetSlot: widgetSlotRef.current,
onState: (state) => {
if (!active || stopped) return;
setAppState(state);
if (state === "loading") setStatus("Loading secure session...");
if (state === "refreshing") setStatus("Refreshing secure session...");
if (state === "authenticating") setStatus("Authenticating widget...");
},
onError: showError,
onAuthenticated: () => {
if (!active || stopped) return;
setAppState("authenticating");
setStatus("Authenticated. Loading experience...");
},
onMounted: () => {
if (!active || stopped) return;
setAppState("ready");
setReady(true);
setError(null);
setStatus("Experience ready.");
},
onToken: () => {
if (active && !stopped) setError(null);
},
});
const startSession = async () => {
if (!config.widgetId || !config.bundleUrl) {
throw new Error("Set the widget ID and bundle URL in the server environment first");
}
await waitForSdk();
if (!active || stopped) return;
await lifecycle.start({ sessionGeneration: hostSessionGeneration });
};
retryRef.current = () => {
if (stopped) return;
setError(null);
void lifecycle.start({ sessionGeneration: hostSessionGeneration }).catch((reason: unknown) => {
showError(reason instanceof Error ? reason.message : "Widget startup failed");
});
};
logoutRef.current = () => {
if (!active || stopped) return;
stopped = true;
setReady(false);
// dispose() performs local invalidation/removal first. SDK logout is
// best-effort and deliberately not awaited, so a hanging request cannot
// keep the previous account's widget visible.
lifecycle.dispose();
clearStarterStorage();
setError(null);
setSignedOut(true);
setAppState("signed-out");
setStatus("Signed out. Reload this page to start again.");
};
setSignedOut(false);
void startSession().catch((reason: unknown) => {
showError(reason instanceof Error ? reason.message : "Widget startup failed");
});
const handlePageHide = () => {
lifecycle.dispose({ logout: false });
clearStarterStorage();
};
window.addEventListener("pagehide", handlePageHide);
return () => {
active = false;
window.removeEventListener("pagehide", handlePageHide);
stopped = true;
retryRef.current = () => undefined;
logoutRef.current = () => undefined;
lifecycle.dispose();
clearStarterStorage();
};
}, [
config.apiUrl,
config.bundleUrl,
config.domainKey,
config.v2ApiUrl,
config.widgetId,
sessionGeneration,
hostSessionGeneration,
]);
return (
<main className="page-shell">
<header className="intro">
<p className="eyebrow">Returning.AI starter</p>
<h1>Authenticated custom widget</h1>
<p>
The server mints the short-lived embed token. The browser receives only
that token and the supplied widget configuration.
</p>
</header>
<section className="card" aria-labelledby="widget-heading">
<div className="card-heading">
<div>
<p className="eyebrow">SDK 1.8.11</p>
<h2 id="widget-heading">Your experience</h2>
</div>
{!signedOut && (
<button className="button secondary" type="button" onClick={() => logoutRef.current()}>
Disconnect widget
</button>
)}
</div>
<p className="status" role="status" aria-live="polite">
{status}
</p>
{error && (
<div className="error-panel" role="alert">
<p>{error}</p>
<button className="button" type="button" onClick={() => retryRef.current()}>
Try again
</button>
</div>
)}
<div ref={widgetSlotRef} className="widget-slot" aria-live="polite" />
{ready && <p className="ready-note">The SDK mounted the supplied bundle.</p>}
</section>
<p className="security-note">
This disconnect button clears only the widget session. The host application
still owns user logout and any token revocation. Replace the server
host-auth adapter before using this starter with a real session.
</p>
</main>
);
}Required support files (6)
The shared helpers are required by the binding above. The entry files show initial mounting; merge that wiring into your existing app instead of overwriting its page. Save source at the shown paths, or update imports to match your layout. These are individual frontend files, not a starter ZIP, and contain no backend. Styles are optional if your app supplies its own; remove their import or link if omitted.
This page passes public settings directly, so no Node configuration helper or /api/config route is needed. Keep your existing App Router layout, TypeScript setup and @/* alias pointing to your project root.
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { createWidgetLifecycle } from "../shared/widget-lifecycle.js";
const props = defineProps({
config: { type: Object, required: true },
sessionGeneration: { type: [String, Number], default: 0 },
});
const STORAGE_PREFIX = "returning-ai-vue-node-starter";
const widgetSlot = ref(null);
const status = ref("Loading secure session...");
const error = ref(null);
const ready = ref(false);
const signedOut = ref(false);
let lifecycle;
function clearStarterStorage() {
try {
for (const key of Object.keys(localStorage)) {
if (key.startsWith(`${STORAGE_PREFIX}-`)) localStorage.removeItem(key);
}
} catch {
// Storage can be disabled. The local widget is still removed.
}
}
function createWidget() {
const element = document.createElement("rai-custom-widget");
element.setAttribute("widget-id", props.config.widgetId);
element.setAttribute("bundle-url", props.config.bundleUrl);
element.setAttribute("height", "auto");
element.setAttribute("eager", "");
element.setAttribute("max-retries", "1");
element.setAttribute("storage-prefix", STORAGE_PREFIX);
if (props.config.apiUrl) element.setAttribute("api-url", props.config.apiUrl);
if (props.config.v2ApiUrl) element.setAttribute("v2-api-url", props.config.v2ApiUrl);
if (props.config.domainKey) element.setAttribute("domain-key", props.config.domainKey);
return element;
}
function start() {
if (signedOut.value || !lifecycle) return Promise.resolve();
return lifecycle.start({ sessionGeneration: props.sessionGeneration });
}
onMounted(() => {
lifecycle = createWidgetLifecycle({
endpoint: "/api/widget-token",
createWidget,
widgetSlot: widgetSlot.value,
onState: (value) => {
if (value === "loading") status.value = "Loading secure session...";
if (value === "refreshing") status.value = "Refreshing secure session...";
if (value === "authenticating") status.value = "Authenticating widget...";
},
onError: (message) => {
if (signedOut.value) return;
ready.value = false;
error.value = message;
status.value = "The widget is not available.";
},
onAuthenticated: () => {
status.value = "Authenticated. Loading experience...";
},
onMounted: () => {
ready.value = true;
error.value = null;
status.value = "Experience ready.";
},
onToken: () => {
error.value = null;
},
});
void start().catch((reason) => {
if (!signedOut.value) {
error.value = reason instanceof Error ? reason.message : "Widget startup failed";
}
});
});
watch(() => props.sessionGeneration, () => {
if (!lifecycle || signedOut.value) return;
void start().catch((reason) => {
if (!signedOut.value) {
error.value = reason instanceof Error ? reason.message : "Widget startup failed";
}
});
});
onBeforeUnmount(() => {
lifecycle?.dispose();
clearStarterStorage();
});
function retry() {
if (signedOut.value) return;
error.value = null;
void start().catch((reason) => {
if (!signedOut.value) {
error.value = reason instanceof Error ? reason.message : "Widget startup failed";
}
});
}
function disconnect() {
signedOut.value = true;
ready.value = false;
error.value = null;
// Local cleanup happens before the best-effort, unawaited SDK logout.
lifecycle?.dispose();
clearStarterStorage();
status.value = "Signed out. Reload this page to start again.";
}
</script>
<template>
<section class="card" aria-labelledby="widget-heading">
<div class="card-heading">
<div><p class="eyebrow">SDK 1.8.11</p><h2 id="widget-heading">Your experience</h2></div>
<button v-if="!signedOut" class="button secondary" type="button" @click="disconnect">Disconnect widget</button>
</div>
<p class="status" role="status" aria-live="polite">{{ status }}</p>
<div v-if="error" class="error-panel" role="alert">
<p>{{ error }}</p>
<button class="button" type="button" @click="retry">Try again</button>
</div>
<div ref="widgetSlot" class="widget-slot" aria-live="polite"></div>
<p v-if="ready" class="ready-note">The SDK mounted the supplied bundle.</p>
</section>
</template>Required support files (6)
The shared helpers are required by the binding above. The entry files show initial mounting; merge that wiring into your existing app instead of overwriting its page. Save source at the shown paths, or update imports to match your layout. These are individual frontend files, not a starter ZIP, and contain no backend. Styles are optional if your app supplies its own; remove their import or link if omitted.
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
ElementRef,
Input,
OnChanges,
OnDestroy,
SimpleChanges,
ViewChild,
} from "@angular/core";
import { createWidgetLifecycle } from "../shared/widget-lifecycle.js";
export type PublicWidgetConfig = {
widgetId: string;
bundleUrl: string;
apiUrl?: string;
v2ApiUrl?: string;
domainKey?: string;
};
type WidgetElement = HTMLElement & {
logoutPublic?: () => Promise<void>;
};
@Component({
selector: "rai-widget-experience",
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="card" aria-labelledby="widget-heading">
<div class="card-heading">
<div><p class="eyebrow">SDK 1.8.11</p><h2 id="widget-heading">Your experience</h2></div>
@if (!signedOut) {
<button class="button secondary" type="button" (click)="disconnect()">Disconnect widget</button>
}
</div>
<p class="status" role="status" aria-live="polite">{{ status }}</p>
@if (error) {
<div class="error-panel" role="alert">
<p>{{ error }}</p>
<button class="button" type="button" (click)="retry()">Try again</button>
</div>
}
<div #widgetSlot class="widget-slot" aria-live="polite"></div>
@if (ready) {
<p class="ready-note">The SDK mounted the supplied bundle.</p>
}
</section>
`,
styles: [],
imports: [],
})
export class WidgetExperienceComponent
implements AfterViewInit, OnChanges, OnDestroy
{
@Input({ required: true }) config!: PublicWidgetConfig;
@Input() sessionGeneration: string | number = 0;
@ViewChild("widgetSlot", { static: true }) widgetSlot!: ElementRef<HTMLDivElement>;
status = "Loading secure session...";
error: string | null = null;
ready = false;
signedOut = false;
private lifecycle?: ReturnType<typeof createWidgetLifecycle>;
constructor(private readonly changeDetector: ChangeDetectorRef) {}
ngAfterViewInit(): void {
this.lifecycle = createWidgetLifecycle({
endpoint: "/api/widget-token",
createWidget: () => this.createWidget(),
widgetSlot: this.widgetSlot.nativeElement,
onState: (state) => {
if (state === "loading") this.status = "Loading secure session...";
if (state === "refreshing") this.status = "Refreshing secure session...";
if (state === "authenticating") this.status = "Authenticating widget...";
this.changeDetector.markForCheck();
},
onError: (message) => {
this.ready = false;
this.error = message;
this.status = "The widget is not available.";
this.changeDetector.markForCheck();
},
onAuthenticated: () => {
this.status = "Authenticated. Loading experience...";
this.changeDetector.markForCheck();
},
onMounted: () => {
this.ready = true;
this.error = null;
this.status = "Experience ready.";
this.changeDetector.markForCheck();
},
onToken: () => {
this.error = null;
this.changeDetector.markForCheck();
},
});
void this.start().catch((reason: unknown) => {
this.error = reason instanceof Error ? reason.message : "Widget startup failed";
this.changeDetector.markForCheck();
});
}
ngOnChanges(changes: SimpleChanges): void {
if (changes["sessionGeneration"] && !changes["sessionGeneration"].firstChange) {
void this.start().catch((reason: unknown) => {
this.error = reason instanceof Error ? reason.message : "Widget startup failed";
this.changeDetector.markForCheck();
});
}
}
ngOnDestroy(): void {
this.lifecycle?.dispose();
this.clearStarterStorage();
}
retry(): void {
this.error = null;
void this.start().catch((reason: unknown) => {
this.error = reason instanceof Error ? reason.message : "Widget startup failed";
this.changeDetector.markForCheck();
});
}
disconnect(): void {
this.signedOut = true;
this.ready = false;
// Local cleanup happens before the best-effort, unawaited SDK logout.
this.lifecycle?.dispose();
this.clearStarterStorage();
this.status = "Signed out. Reload this page to start again.";
this.changeDetector.markForCheck();
}
private start(): Promise<void> {
if (this.signedOut || !this.lifecycle) return Promise.resolve();
return this.lifecycle.start({ sessionGeneration: this.sessionGeneration });
}
private createWidget(): WidgetElement {
const element = document.createElement("rai-custom-widget") as WidgetElement;
element.setAttribute("widget-id", this.config.widgetId);
element.setAttribute("bundle-url", this.config.bundleUrl);
element.setAttribute("height", "auto");
element.setAttribute("eager", "");
element.setAttribute("max-retries", "1");
element.setAttribute("storage-prefix", "returning-ai-angular-node-starter");
if (this.config.apiUrl) element.setAttribute("api-url", this.config.apiUrl);
if (this.config.v2ApiUrl) element.setAttribute("v2-api-url", this.config.v2ApiUrl);
if (this.config.domainKey) element.setAttribute("domain-key", this.config.domainKey);
return element;
}
private clearStarterStorage(): void {
try {
for (const key of Object.keys(window.localStorage)) {
if (key.startsWith("returning-ai-angular-node-starter-")) {
window.localStorage.removeItem(key);
}
}
} catch {
// Storage can be disabled. The local widget is still removed.
}
}
}Required support files (8)
The shared helpers are required by the binding above. The entry files show initial mounting; merge that wiring into your existing app instead of overwriting its page. Save source at the shown paths, or update imports to match your layout. These are individual frontend files, not a starter ZIP, and contain no backend. Styles are optional if your app supplies its own; remove their import or link if omitted.
Check the first result
A successful token response is only the first checkpoint. The SDK must register, authenticate and mount the supplied widget. Confirm the visible experience belongs to the intended test user; a downloaded project's mocked tests do not prove your environment is configured.
Handle refresh and host-session changes
The embed token normally expires after 15 minutes; use the supplied expiresIn value when present. Keep the example's lifecycle handling when moving it into your application. Updating the token on the existing element preserves the widget; minting a new token does not prove its underlying data refreshed.
- Fetch the first token from the protected host endpoint, then connect the widget.
- Refresh before expiry or on the SDK's
rai-session-expiredevent. Share concurrent requests and stop after bounded failures. - Wire your login system's logout/account-change event to
window.returningAiHostSessionChanged(++generation), wheregenerationstarts at zero in your host code. Call it after the trusted host session changes. Alternatively, unmount the component on logout and pass a newsessionGenerationprop/input on account change. The local marker is never sent as a signing identity. - Remove the old widget and cancel timers and requests immediately. A stalled SDK logout must not keep the old view open.
Read the shared lifecycle implementation
This is the same helper provided in every frontend's support files. It imports token-client.js from the same directory. The highlighted lines update the existing widget token and handle the SDK expiry event.
// Shared starter runtime. Materialized into each standalone archive.
import { createTokenClient } from "./token-client.js";
const DEFAULT_TOKEN_LIFETIME_SECONDS = 900;
function isFinitePositiveNumber(value) {
return typeof value === "number" && Number.isFinite(value) && value > 0;
}
function setTokenAttribute(widget, token) {
if (!widget || typeof widget.setAttribute !== "function") return;
widget.setAttribute("embed-token", token);
}
/**
* Coordinates one mounted custom element with one same-origin token session.
*
* `sessionGeneration` is owned by the host application. It is only a local
* stale-result marker. It is never sent to the token route and never replaces
* the host's trusted session lookup.
*/
export function createWidgetLifecycle({
endpoint = "/api/widget-token",
createWidget,
widgetSlot,
fetchImpl,
sleepImpl,
retryDelayMs,
onState = () => {},
onError = () => {},
onAuthenticated = () => {},
onMounted = () => {},
onToken = () => {},
} = {}) {
if (typeof createWidget !== "function") {
throw new Error("createWidget is required");
}
let disposed = true;
let hostSessionGeneration;
let lifecycleGeneration = 0;
let widget = null;
let startInFlight = null;
let refreshInFlight = null;
let requestTimer;
let recoveryAttempts = 0;
let terminalFailure = false;
let requestController = null;
let tokenClient = null;
let widgetCleanup = () => {};
function current(generation) {
return !disposed && lifecycleGeneration === generation;
}
function clearTimer() {
if (requestTimer !== undefined && typeof window !== "undefined") {
window.clearTimeout(requestTimer);
}
requestTimer = undefined;
}
function scheduleRefresh(expiresIn, generation) {
clearTimer();
if (!current(generation) || terminalFailure || typeof window === "undefined") return;
const lifetimeMs = (isFinitePositiveNumber(expiresIn)
? expiresIn
: DEFAULT_TOKEN_LIFETIME_SECONDS) * 1000;
const refreshAheadMs = Math.min(60_000, Math.max(250, Math.floor(lifetimeMs / 2)));
const delayMs = Math.max(0, lifetimeMs - refreshAheadMs);
requestTimer = window.setTimeout(() => {
requestTimer = undefined;
const task = refresh();
if (task) void task.catch(() => {});
}, delayMs);
}
function showError(error) {
const message = error instanceof Error ? error.message : "Widget startup failed";
onState("error");
onError(message);
}
function attachWidgetEvents(element, generation) {
const listeners = [
["rai-authenticated", () => {
if (!current(generation)) return;
onAuthenticated();
}],
["rai-mounted", () => {
if (!current(generation)) return;
recoveryAttempts = 0;
onMounted();
}],
["rai-session-expired", () => {
if (!current(generation)) return;
void recover(generation).catch(() => {});
}],
["rai-error", () => {
if (!current(generation)) return;
void recover(generation).catch(() => {});
}],
];
for (const [eventName, handler] of listeners) {
element.addEventListener?.(eventName, handler);
}
widgetCleanup = () => {
for (const [eventName, handler] of listeners) {
element.removeEventListener?.(eventName, handler);
}
widgetCleanup = () => {};
};
}
function removeWidget() {
const previousWidget = widget;
widget = null;
widgetCleanup();
previousWidget?.remove?.();
return previousWidget;
}
function bestEffortLogout(previousWidget) {
const logoutPublic = previousWidget?.logoutPublic;
if (typeof logoutPublic !== "function") return;
// Deliberately do not await this. Local cleanup must finish even if the SDK
// request never resolves or the upstream is offline.
Promise.resolve()
.then(() => logoutPublic.call(previousWidget))
.catch(() => {});
}
function dispose({ logout = true } = {}) {
if (disposed && !widget && !startInFlight && !refreshInFlight) return;
disposed = true;
lifecycleGeneration += 1;
clearTimer();
requestController?.abort();
requestController = null;
tokenClient?.dispose();
tokenClient = null;
startInFlight = null;
refreshInFlight = null;
recoveryAttempts = 0;
terminalFailure = false;
const previousWidget = removeWidget();
if (logout) bestEffortLogout(previousWidget);
}
async function getToken(generation) {
const client = tokenClient;
if (!current(generation) || !client) {
throw new Error("The widget session is no longer active");
}
try {
const result = await client.refreshWithRetry();
if (!current(generation)) return null;
return result;
} catch (error) {
// A disposed/account-switched session is complete, not a user-visible
// authentication failure. Never let its stale result reach the widget.
if (!current(generation)) return null;
throw error;
}
}
function refresh(generation = lifecycleGeneration) {
if (!current(generation) || terminalFailure || !widget || !tokenClient) return;
if (refreshInFlight) return refreshInFlight;
clearTimer();
onState("refreshing");
const task = (async () => {
const result = await getToken(generation);
if (!result || !current(generation) || !widget) return;
setTokenAttribute(widget, result.embedToken);
onToken(result);
scheduleRefresh(result.expiresIn, generation);
onState("ready");
})();
refreshInFlight = task;
task.then(
() => {
if (refreshInFlight === task) refreshInFlight = null;
},
(error) => {
if (refreshInFlight === task) refreshInFlight = null;
if (current(generation)) showError(error);
},
);
return task;
}
async function recover(generation) {
if (!current(generation) || terminalFailure || !widget) return;
if (refreshInFlight) return refreshInFlight;
if (recoveryAttempts >= 1) {
terminalFailure = true;
clearTimer();
const error = new Error(
"Authentication failed again. Check the server session and try again.",
);
showError(error);
return;
}
recoveryAttempts += 1;
return refresh(generation);
}
async function start({ sessionGeneration = 0 } = {}) {
if (!disposed && hostSessionGeneration !== sessionGeneration) {
dispose();
}
if (disposed) {
disposed = false;
hostSessionGeneration = sessionGeneration;
lifecycleGeneration += 1;
tokenClient = createTokenClient({
endpoint,
fetchImpl,
sleepImpl,
retryDelayMs,
});
}
if (startInFlight) return startInFlight;
const generation = lifecycleGeneration;
// A retry after a mounted widget has failed must refresh that same element.
// Creating a second element would overwrite the cleanup handle and leave
// the old listeners and DOM node behind.
if (widget) {
recoveryAttempts = 0;
terminalFailure = false;
clearTimer();
return refresh(generation);
}
onState("loading");
const task = (async () => {
const result = await getToken(generation);
if (!result || !current(generation)) return;
widget = createWidget();
attachWidgetEvents(widget, generation);
setTokenAttribute(widget, result.embedToken);
onToken(result);
if (!widget.isConnected) widgetSlot?.append?.(widget);
scheduleRefresh(result.expiresIn, generation);
onState("authenticating");
})();
startInFlight = task;
task.then(
() => {
if (startInFlight === task) startInFlight = null;
},
(error) => {
if (startInFlight === task) startInFlight = null;
if (current(generation)) showError(error);
},
);
return task;
}
return {
start,
refresh,
dispose,
getWidget: () => widget,
};
}A reference project's disconnect control only clears its widget. Your application owns the actual user logout; disconnecting a widget does not revoke an already issued token.
Troubleshoot a widget that will not loadOptional complete starters
Five projects, one for each frontend above. Every download includes its frontend, a reference backend, configuration, tests and run instructions. Node is included in these reference projects; keep your existing backend by implementing the token contract above.
HTML + Node
Node.js reference server
Node >=20.20.2 is required to run this reference project.
Express 5.1.0 reference server; browser SDK is served from the pinned package.
Download HTML + Node starterReact + Node
Node.js reference server
Node >=20.20.2 is required to run this reference project.
React 19 client-rendered binding with Vite 6.3.5; Express 5.1.0 reference server.
Download React + Node starterNext.js
Next.js server route
Node >=20.20.2 is required to run this reference project.
Next.js 16.3.4 App Router with React 19.2.3; server rendering and client hydration are verified.
Download Next.js starterVue + Node
Node.js reference server
Node >=20.20.2 is required to run this reference project.
Vue 3.5.21 client-rendered binding with Vite 6.3.5; Express 5.1.0 reference server.
Download Vue + Node starterAngular + Node
Node.js reference server
Node >=24.16.0 is required to run this reference project.
Angular 22.1.5 standalone browser binding with Angular CLI/build-angular 22.1.5 and TypeScript 6.0.2; Angular SSR is outside this starter.
Download Angular + Node starterThe local test-user option still needs an enrolled test user and your supplied configuration. It is blocked in production. Connect the documented adapter to your own login before deploying; no shared live sandbox or client credentials are included.
Continue building
To connect the activity behind your widget, choose your data path, then follow trading volume and rewards or onboarding milestones.
See the integration handbook. Use the widget catalog for bundle choices, or configuration for attributes and events.