Returning.AIDocs
v1

Guides / Custom Widget SDK

.md

Authentication

The recommended way to authenticate widgets. Your backend exchanges access credentials for a short-lived JWT, which is injected into the widget tag as an HTML attribute. User identity is signed into that token request server-side, so no browser-visible user identifiers are needed on the widget tag.

From your login to a widget token

Server authentication

The broker login decides the user. The browser never supplies an identity that the server trusts without checking it.

  1. Ask your backend for a widget session

    Portal browser

    The customer is already signed in to your portal. Call your own protected token endpoint with that existing session.

  2. Read the trusted session and map it to the same identifier used during Returning.AI enrollment. Do not copy a customer ID or referral parameter from the request into a signed token.

  3. Exchange the Access ID and Access Key

    Your backend

    Send accessId, accessKey, and userIdentifiers to the Returning.AI token endpoint. The server credentials stay here.

  4. Return the short-lived embed token

    Your backend to browser

    Read data.embedToken and data.expiresIn. The browser sets embed-token on the custom element; long-lived pages request a fresh token before expiry.

A signed token is still browser-visible

The token authenticates the widget. Do not put secrets in its user identifiers or log it. Removing the widget on logout clears the browser view; it does not prove an already issued token was revoked.

What ends access?

The current service code treats these as separate checks. Confirm the supplied environment's behavior with your Returning.AI security contact before security approval.

  • New issuance. Deactivating an Access Key blocks new embed tokens from that key.
  • Embed-token validation. The validator checks that the key is still active, including when validating the identity proof for an established refresh session. Initial authentication checks the embed token's expiry; the established refresh path also has its own live refresh-token checks.
  • Downstream widget sessions. Widget access and refresh credentials have a separate lifecycle. Key deactivation does not enumerate and delete all existing sessions. The embed token's 15-minute expiry is not a maximum remaining-access window for those credentials.
  • Logout and account changes. The logout endpoint clears the supplied session credentials; removing a widget only clears the local view. Do not treat local cleanup, account removal or stopping your token route as proof that every downstream API now rejects an existing session.

Before approval, record separate validation, refresh, logout and downstream-access results after the agreed key or account change in the handover decision gates. Do not run revocation tests against real customers to fill this sheet.

Community API keys are a different credential family. Their rotation guide explains replacement-before-revocation; it does not establish SDK session termination.

When to use

  • Logged-in portals and trader dashboards where you know the user server-side.
  • Any integration where server-verified identity is important.
  • Custom widget bundles rendered directly inside the portal page.

Step 1 - Get access credentials

In the Returning.AI admin panel, go to Community Settings > Integrations > SDK Access and generate an accessId and accessKey pair. The accessKey is shown once only, and revocation takes effect immediately for new token issuance. Store both values in your server's environment variables - they must never be exposed to the client.

Step 2 - Create a backend endpoint

Your server calls the Access Key API with your accessId and accessKey, plus a userIdentifiers object containing the fields your widget expects. The response returns a short-lived embed token at data.embedToken. Your browser only receives that token.

These host implementations use Express, PHP sessions and Django. Connect the trusted-session boundary to your own login and use your configured identifier keys. They serve GET /api/widget-token on the same origin as your page, returning { embedToken, expiresIn? } with Cache-Control: no-store; the upstream Returning.AI response stays wrapped in data. A browser request never chooses the identity being signed.

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;
}

Response shape

A successful call returns the JWT under data.embedToken and the lifetime in seconds under data.expiresIn.

javascript
// POST https://api-v2.returning.ai/v2/api/widget-access-keys/token
// Request body: { accessId, accessKey, userIdentifiers }
// Response body (200 OK):

{
  "data": {
    "embedToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "expiresIn": 900
  }
}

// Notes:
// - embedToken: short-lived JWT, pass to the widget as the embed-token attribute
// - expiresIn: token lifetime in seconds (typically 900 = 15 minutes)
// - For long-lived views, mint a fresh token before expiry and update the
//   embed-token attribute on the widget element. The SDK watches this
//   attribute (added in 1.4.2); use the current release 1.8.11. Call reload()
//   only as a fallback for older builds.

Step 3 - Inject the token into the page

Your server renders the widget tag with the embed-token attribute set to the token from Step 2. The SDK sends that token to Returning.AI for validation, then continues startup using the signed identity claims from the token mint request.

Step 4 - Add the widget

Add the widget tag with embed-token and bundle-url attributes. The bundle-url must match the configured experience selected by widget-id.

html
<rai-custom-widget
  widget-id="YOUR_WIDGET_ID"
  bundle-url="https://prod-widgets.returning.ai/custom-widget/bundle/milestones/widget.js"
  embed-token="TOKEN_FROM_YOUR_SERVER"
  height="auto"
></rai-custom-widget>

Use the supplied widget ID and bundle URL

Do not guess the bundle from the screen name. Different widget IDs can share one bundle, and dedicated deployments may use a client-specific host.

How the widget reaches the page

Browser SDK and bundle delivery

All custom experiences use the same element. The supplied ID and URL select the configured widget and its code.

  1. Register the custom element

    Portal browser

    Load @returningai/widget-sdk once. It registers rai-custom-widget. In a server-rendered framework, load the browser SDK on the client.

  2. Validate the widget session

    SDK and Returning.AI

    The custom element reads widget-id, bundle-url, and embed-token. The SDK validates the signed token for widget authentication.

  3. The supplied widget.js contains the experience. Different widget IDs can use the same bundle. Use the full URL provided for your environment.

  4. The widget mounts into your page and reads its configured user state. A newly accepted backend event may still be processing.

Mobile apps and WebViews

The Widget SDK is browser-based Web Component code. Do not add the SDK directly to a native iOS, Android, or Flutter screen. For mobile apps, host the widget on a normal web page, then load that page inside a WebView.

If your portal already has a page where the widget works in a browser, use that same page as the WebView URL. Keep the access key and token minting on your backend. The Flutter app should only open the page that receives the short-lived embed-token from your server.

returning_widget_page.dart
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

class ReturningWidgetPage extends StatefulWidget {
  const ReturningWidgetPage({super.key});

  @override
  State<ReturningWidgetPage> createState() => _ReturningWidgetPageState();
}

class _ReturningWidgetPageState extends State<ReturningWidgetPage> {
  late final WebViewController controller;

  @override
  void initState() {
    super.initState();

    controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..loadRequest(
        Uri.parse('https://portal.example.com/loyalty/widget'),
      );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: WebViewWidget(controller: controller),
      ),
    );
  }
}

User identifier keys

The browser should only receive embed-token. The user identifier keys themselves are sent server-side in the userIdentifiers object when you mint the token:

Key in userIdentifiersTypical sourceWhen to use it
data-customer-idBroker CRM / platform customer IDPreferred for most production integrations. Stable, broker-owned, and avoids exposing email.
data-user-idYour internal user IDGood fallback if that field already exists in your platform and matches the ID used during registration.
data-emailUser emailUse only when email is already your canonical identifier or you do not have a better stable ID.
data-user-objectidReturning.AI object IDOnly use if you already persist the Returning.AI-side user object ID and explicitly want to key off it.

Which identifiers are required depends on your community's widget configuration. Check your admin panel, or ask your Returning.AI contact, for the exact required key before rolling out. Missing a required identifier in the server-side userIdentifiers object causes the widget to fail on auth with a rai-error event.

Token lifecycle

Access Key tokens are short-lived JWTs that expire after 15 minutes. The SDK does not call your backend to mint a fresh embed-token automatically. For long-lived views, your app must fetch a new embed token, then update the embed-token attribute. The SDK watches this attribute (added in 1.4.2); use the current release 1.8.11. Call reload() only as an older-build fallback.

ScenarioStrategy
Server-rendered pagesToken generated per page load. 15 minutes is plenty for most page sessions.
SPAsRe-fetch the token from your backend on route navigation. Each view gets a fresh token.
Long-lived sessions (>15 min on one view)Listen for the rai-session-expired event, re-fetch a token from your backend, update the embed-token attribute. The SDK watches this attribute (added in 1.4.2); use the current release 1.8.11. Call window.ReturningAIWidget.reload() only as an older-build fallback.

Handling token expiry in long-lived sessions

If a user stays on the same page for more than 15 minutes, the token will expire. Use this pattern to recover automatically:

token-refresh.js
const widget = document.querySelector('rai-custom-widget')

async function refreshWidgetToken() {
  const response = await fetch('/api/widget-token')
  if (!response.ok) throw new Error('Unable to refresh widget token')

  const { embedToken } = await response.json()
  widget.setAttribute('embed-token', embedToken)
}

widget.addEventListener('rai-session-expired', refreshWidgetToken)