Skip to main content

The identity relay

Appilot serves identified users only. Your application already knows who its users are, so the identity comes from you: your backend exchanges your widget key and widget secret for a short-lived Appilot token that names one of your users, and hands that token to the widget. Your users never see an Appilot account and never sign in twice.

The exchange is one HTTP call. There is a Node package that makes it a three-line handler, and there is nothing framework-specific about the call itself, so a Django, Rails, Laravel or Go backend implements it directly.

caution

The widget secret mints identities. It lives in your backend's environment and must never reach a browser, a build artifact served to the browser, or a repository. The public widget key alone cannot mint a token.

The contract​

POST https://api.appilot.space/widget/token
HeaderRequiredValue
X-Widget-KeyYesYour publishable key, wk_live_... or wk_test_....
X-Widget-SecretYesThe server secret paired with that key, wsk_secret_....
Content-TypeYesapplication/json

Both headers are required. The key selects the credential; the secret proves you hold it. A request with the key alone is refused, which is the point of the split.

Body:

{
"externalId": "acme:4711",
"email": "jane@example.com",
"displayName": "Jane Ruiz"
}

At least one of externalId and email is required. displayName is optional and only affects what the panel shows. Prefer a stable, namespaced externalId over an email: Appilot federates on it, so it should survive the user changing their address.

Response:

{ "token": "eyJhbGciOiJIUzI1NiIs...", "expiresIn": 3600 }

The token identifies one user for one hour. Mint it per user, per session, and mint a new one rather than storing it anywhere shared. The widget re-relays through your endpoint at about 80% of the token's life, so a long-lived tab stays authenticated as long as your own session does.

This call is server to server and carries no browser Origin, so the key's allowed-domain list is not applied to it. The secret is what authenticates it.

Errors​

StatusCodeWhat happened
401WIDGET_KEY_INVALID_FORMATX-Widget-Key is not a widget key.
401WIDGET_KEY_INVALIDNo such key.
403WIDGET_KEY_DEACTIVATEDThe key was revoked in the Backoffice.
401WIDGET_SECRET_MISSINGNo X-Widget-Secret header.
401WIDGET_SECRET_INVALIDThe secret does not match the key.
403WIDGET_SECRET_NOT_PROVISIONEDThe key has no secret yet. Regenerate it in the Backoffice.
403WIDGET_TEST_KEY_IN_PRODA wk_test_ key whose allowed domains are not all loopback addresses. See Develop on localhost.
400Neither externalId nor email was supplied.
429WIDGET_RATE_LIMITEDToo many calls this minute for this key.

Node: the package​

appilot-server is that call, with the failure modes handled and the secret kept out of every response. Node 18 or later, no dependencies.

npm install appilot-server
// app/api/widget/token/route.ts
import { createWidgetTokenHandler } from 'appilot-server';

export const POST = createWidgetTokenHandler({
apiUrl: 'https://api.appilot.space',
widgetKey: process.env.APPILOT_WIDGET_KEY!,
widgetSecret: process.env.APPILOT_WIDGET_SECRET!,
resolveUser: async request => {
const session = await getSession(request); // your session, your rules
if (!session) return null; // answers 401 IDENTITY_REQUIRED
return { externalId: `acme:${session.userId}`, displayName: session.name };
},
});

The handler follows the fetch standard, so it drops into Next.js, Remix, Hono, Cloudflare Workers, Deno and Bun. Express, Fastify and bare node:http use createNodeWidgetTokenHandler from appilot-server/node, kept in its own entry point so a Workers build never pulls node:http into the bundle.

resolveUser is the security boundary, and the reason to prefer the package over a hand-rolled fetch: the caller never says who they are, your session does.

Its failures answer JSON { error: { code, message } } with a stable code (IDENTITY_REQUIRED, ORIGIN_FORBIDDEN, METHOD_NOT_ALLOWED, WIDGET_RELAY_NOT_CONFIGURED, WIDGET_TOKEN_EXCHANGE_FAILED) and never forward the upstream's own prose. When you call exchangeWidgetToken yourself, the AppilotRelayError it throws carries reason, upstreamStatus and upstreamCode, so your logs name which of the codes above actually happened.

Python​

Flask, with the session your app already has:

import os, requests
from flask import Blueprint, jsonify, session

appilot = Blueprint("appilot", __name__)

@appilot.post("/api/widget/token")
def widget_token():
user = session.get("user")
if not user:
return jsonify({"error": {"code": "IDENTITY_REQUIRED"}}), 401

response = requests.post(
f"{os.environ['APPILOT_API_URL']}/widget/token",
headers={
"X-Widget-Key": os.environ["APPILOT_WIDGET_KEY"],
"X-Widget-Secret": os.environ["APPILOT_WIDGET_SECRET"],
"Content-Type": "application/json",
},
json={
"externalId": f"acme:{user['id']}",
"displayName": user.get("name"),
},
timeout=10,
)
if response.status_code != 200:
return jsonify({"error": {"code": "WIDGET_TOKEN_EXCHANGE_FAILED"}}), 502

return jsonify(response.json())

Django is the same body inside a view: read the user from request.user, refuse with 401 when it is anonymous, and return the upstream JSON unchanged.

Ruby​

Rails, in a controller the session already protects:

class Appilot::TokensController < ApplicationController
def create
return render(json: { error: { code: "IDENTITY_REQUIRED" } }, status: :unauthorized) unless current_user

response = Faraday.post("#{ENV.fetch('APPILOT_API_URL')}/widget/token") do |req|
req.headers["X-Widget-Key"] = ENV.fetch("APPILOT_WIDGET_KEY")
req.headers["X-Widget-Secret"] = ENV.fetch("APPILOT_WIDGET_SECRET")
req.headers["Content-Type"] = "application/json"
req.body = { externalId: "acme:#{current_user.id}", displayName: current_user.name }.to_json
req.options.timeout = 10
end

return render(json: { error: { code: "WIDGET_TOKEN_EXCHANGE_FAILED" } }, status: :bad_gateway) unless response.success?

render json: response.body
end
end

Three rules whatever the language​

  1. Derive the identity from your session. Never from the request body, a query parameter or a header the browser controls. A relay that trusts the body lets any signed-in user become any other.
  2. Return the upstream JSON unchanged. { token, expiresIn } is what the widget expects. Adding fields is harmless; renaming them is not.
  3. Answer 401 when there is no session. The widget then shows its signed-out state instead of chatting as nobody, and it retries once your user signs in.

Point the widget at it​

import { bootAppilotWidget } from 'appilot';

bootAppilotWidget({
widgetScriptUrl: 'https://cdn.appilot.space/widget/v1/appilot.esm.js',
tokenEndpoint: '/api/widget/token',
getBearer: () => getMyAppSessionToken(), // only if your session is a bearer
});

getBearer exists for apps whose session is not a cookie. When your relay is protected by a cookie on the same origin, leave it out.

If your app authenticates after the page has loaded, boot without a token and call window.Appilot.setUserToken(token) when you have one.

Check it from the outside​

verify_integration loads the real page and reports what is true: whether the instance is reachable, whether the page's domain resolves to a tenant, whether the widget is referenced and boots, whether the relay answers correctly for an unauthenticated caller, and whether Appilot logged console errors.

Verify the integration at https://app.example.com, the relay is at /api/widget/token

It is the check to run after a deploy, because it catches what no static review can see: a stale bundle, a rotated key, a relay that answers 200 with an empty body, or a domain resolving to the wrong tenant. See the MCP reference.

By hand, an unauthenticated call to your own relay must answer 401:

curl -i -X POST https://app.example.com/api/widget/token

If it answers 200 with a token, your relay is minting identities for anonymous callers, and anyone can read the conversations of whatever user it names.