Returning.AIDocs
v1

Guides / Custom Widget SDK

.md

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.

Your frontend

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.

browser-to-your-server.http
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.

your-server-to-returning.http
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.

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.

Node.js + Express setup

Install the exact backend dependency:

bash
npm install express@5.1.0

Use Node.js 24.16.0 or a later Node.js 24 release. Save the complete route below as app.mjs. It exports createApp and registers GET /api/widget-token; it does not open a listening socket.

Create your server entry point and install your existing host session middleware before the route. The middleware must read the server-side session cookie and set the request user in this shape:

javascript
{
  identifiers: {
    "data-configured-key": "VALUE_FROM_TRUSTED_SESSION"
  }
}

Pass that middleware to createApp({ hostSessionMiddleware }). If it sets request.user, the default user adapter will use it. Your server entry point must open the socket after creating the app:

javascript
import { createServer } from "node:http";
import { createApp } from "./app.mjs";

const app = createApp({ hostSessionMiddleware });
createServer(app).listen(Number(process.env.PORT || 3000), "127.0.0.1");

The browser must call the route as same-origin GET /api/widget-token with no identity in the query string, body or headers. The route returns only the flattened embed token and optional expiry.

Set these server environment variables:

bash
RAI_ACCESS_ID=server-value
RAI_ACCESS_KEY=server-value
RAI_USER_IDENTIFIER_KEYS=data-configured-key
# Optional. Defaults to the production token endpoint.
RAI_TOKEN_ENDPOINT=https://api-v2.returning.ai/v2/api/widget-access-keys/token
# Optional milliseconds. Defaults to 10000 and is bounded.
RAI_TOKEN_TIMEOUT_MS=10000

Keep the frontend and this route on one origin. Do not add CORS or accept a browser-supplied identifier as a shortcut.

Complete token route

app.mjs
import express from "express";

export const DEFAULT_TOKEN_ENDPOINT =
  "https://api-v2.returning.ai/v2/api/widget-access-keys/token";

function noStore(response) {
  response.set("Cache-Control", "no-store, max-age=0");
  response.set("Pragma", "no-cache");
}

function jsonError(response, status, error) {
  noStore(response);
  return response.status(status).json({ error });
}

function readList(value) {
  return String(value ?? "")
    .split(",")
    .map((item) => item.trim())
    .filter(Boolean);
}

function readTimeout(value) {
  const timeoutMs = Number(value ?? 10_000);
  return Number.isFinite(timeoutMs) && timeoutMs > 0 && timeoutMs <= 30_000
    ? Math.floor(timeoutMs)
    : Number.NaN;
}

export function readConfig(env = process.env) {
  return {
    accessId: String(env.RAI_ACCESS_ID ?? "").trim(),
    accessKey: String(env.RAI_ACCESS_KEY ?? "").trim(),
    userIdentifierKeys: readList(env.RAI_USER_IDENTIFIER_KEYS),
    tokenEndpoint: String(
      env.RAI_TOKEN_ENDPOINT || DEFAULT_TOKEN_ENDPOINT,
    ).trim(),
    timeoutMs: readTimeout(env.RAI_TOKEN_TIMEOUT_MS),
  };
}

function getConfigurationProblems(config) {
  const problems = [];
  if (!config.accessId) problems.push("RAI_ACCESS_ID");
  if (!config.accessKey) problems.push("RAI_ACCESS_KEY");
  if (config.userIdentifierKeys.length === 0) {
    problems.push("RAI_USER_IDENTIFIER_KEYS");
  }
  if (
    config.userIdentifierKeys.some(
      (key) => !key.startsWith("data-") || key.length <= 5 || /\s/.test(key),
    )
  ) {
    problems.push("RAI_USER_IDENTIFIER_KEYS");
  }
  if (!Number.isFinite(config.timeoutMs)) problems.push("RAI_TOKEN_TIMEOUT_MS");
  return problems;
}

function isCrossSiteRequest(request) {
  if (String(request.get("sec-fetch-site") || "").toLowerCase() === "cross-site") {
    return true;
  }

  const origin = request.get("origin");
  if (!origin) return false;

  let originUrl;
  try {
    originUrl = new URL(origin);
  } catch {
    return true;
  }

  const requestProtocol = request.protocol === "https" ? "https:" : "http:";
  const requestHost = String(request.get("host") || "").toLowerCase();
  return (
    originUrl.protocol !== requestProtocol ||
    originUrl.host.toLowerCase() !== requestHost
  );
}

/**
 * Host-session adapter boundary.
 *
 * Install the host application's existing session/auth middleware before this
 * route. It must set request.user from the server-side session. The browser
 * may send a session cookie, but it must not choose an identity in a query,
 * body, or browser-controlled header. Replace this fallback with the host's
 * trusted lookup when integrating the example.
 */
function getAuthenticatedUser(request) {
  return request.user ?? null;
}

function selectConfiguredIdentifiers(user, keys) {
  const source = user?.identifiers;
  if (!source || typeof source !== "object" || Array.isArray(source)) {
    throw new Error("Authenticated host user has no identifier map");
  }

  const identifiers = {};
  for (const key of keys) {
    const value = source[key];
    if (
      typeof value !== "string" &&
      !(typeof value === "number" && Number.isFinite(value))
    ) {
      throw new Error(`Authenticated host user is missing ${key}`);
    }
    const normalized = String(value).trim();
    if (!normalized) throw new Error(`Authenticated host user is missing ${key}`);
    identifiers[key] = normalized;
  }
  return identifiers;
}

async function requestEmbedToken(config, userIdentifiers, fetchImpl) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), config.timeoutMs);

  try {
    const response = await fetchImpl(config.tokenEndpoint, {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        accessId: config.accessId,
        accessKey: config.accessKey,
        userIdentifiers,
      }),
      signal: controller.signal,
      cache: "no-store",
    });

    if (!response?.ok) {
      throw new Error("Returning.AI token endpoint returned a non-success status");
    }

    let payload;
    try {
      payload = await response.json();
    } catch {
      throw new Error("Returning.AI token response was not valid JSON");
    }

    const data =
      payload && typeof payload === "object" && !Array.isArray(payload)
        ? payload.data
        : null;
    if (!data || typeof data !== "object" || Array.isArray(data)) {
      throw new Error("Returning.AI token response had malformed data");
    }

    const embedToken = data.embedToken;
    if (typeof embedToken !== "string" || !embedToken.trim()) {
      throw new Error("Returning.AI token response did not include data.embedToken");
    }

    if (
      data.expiresIn !== undefined &&
      !(
        typeof data.expiresIn === "number" &&
        Number.isFinite(data.expiresIn) &&
        data.expiresIn > 0
      )
    ) {
      throw new Error("Returning.AI token response had invalid data.expiresIn");
    }

    return data.expiresIn === undefined
      ? { embedToken }
      : { embedToken, expiresIn: data.expiresIn };
  } finally {
    clearTimeout(timeout);
  }
}

/**
 * Create the host application route.
 *
 * `hostSessionMiddleware` is the existing host session/auth setup. The
 * default is intentionally unauthenticated so an unwired example cannot mint
 * a token. The route itself remains independent of the frontend framework.
 */
export function createApp({
  env = process.env,
  fetchImpl = globalThis.fetch,
  hostSessionMiddleware,
  getUser = getAuthenticatedUser,
} = {}) {
  const config = readConfig(env);
  const app = express();
  app.disable("x-powered-by");
  app.use(hostSessionMiddleware ?? ((_request, _response, next) => next()));

  app.all("/api/widget-token", async (request, response) => {
    if (request.method !== "GET") {
      return jsonError(response, 405, "Method not allowed");
    }
    if (isCrossSiteRequest(request)) {
      return jsonError(response, 403, "Cross-site token requests are not allowed");
    }

    if (getConfigurationProblems(config).length > 0) {
      return jsonError(response, 503, "The token route is not configured yet");
    }

    let user;
    try {
      user = await getUser(request);
    } catch {
      return jsonError(response, 500, "Authentication setup failed");
    }
    if (!user) return jsonError(response, 401, "Sign-in is required");

    let userIdentifiers;
    try {
      userIdentifiers = selectConfiguredIdentifiers(
        user,
        config.userIdentifierKeys,
      );
    } catch {
      return jsonError(
        response,
        500,
        "The authenticated user is missing a configured identifier",
      );
    }

    try {
      const token = await requestEmbedToken(
        config,
        userIdentifiers,
        fetchImpl,
      );
      noStore(response);
      return response.status(200).json(token);
    } catch {
      return jsonError(response, 502, "Unable to mint a widget token");
    }
  });

  app.use((_error, _request, response, _next) => {
    return jsonError(response, 500, "Request failed");
  });

  return app;
}

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.

terminal
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.js

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.

optional-public-config.http
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.

app.js
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.

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.

  1. Fetch the first token from the protected host endpoint, then connect the widget.
  2. Refresh before expiry or on the SDK's rai-session-expired event. Share concurrent requests and stop after bounded failures.
  3. Wire your login system's logout/account-change event to window.returningAiHostSessionChanged(++generation), where generation starts at zero in your host code. Call it after the trusted host session changes. Alternatively, unmount the component on logout and pass a new sessionGeneration prop/input on account change. The local marker is never sent as a signing identity.
  4. 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.

widget-lifecycle.js
// 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 load

Optional 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 starter

React + 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 starter

Next.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 starter

Vue + 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 starter

Angular + 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 starter

The 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.