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.
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
| Header | Required | Value |
|---|---|---|
X-Widget-Key | Yes | Your publishable key, wk_live_... or wk_test_.... |
X-Widget-Secret | Yes | The server secret paired with that key, wsk_secret_.... |
Content-Type | Yes | application/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
| Status | Code | What happened |
|---|---|---|
| 401 | WIDGET_KEY_INVALID_FORMAT | X-Widget-Key is not a widget key. |
| 401 | WIDGET_KEY_INVALID | No such key. |
| 403 | WIDGET_KEY_DEACTIVATED | The key was revoked in the Backoffice. |
| 401 | WIDGET_SECRET_MISSING | No X-Widget-Secret header. |
| 401 | WIDGET_SECRET_INVALID | The secret does not match the key. |
| 403 | WIDGET_SECRET_NOT_PROVISIONED | The key has no secret yet. Regenerate it in the Backoffice. |
| 403 | WIDGET_TEST_KEY_IN_PROD | A wk_test_ key whose allowed domains are not all loopback addresses. See Develop on localhost. |
| 400 | Neither externalId nor email was supplied. | |
| 429 | WIDGET_RATE_LIMITED | Too 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
- 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.
- Return the upstream JSON unchanged.
{ token, expiresIn }is what the widget expects. Adding fields is harmless; renaming them is not. - 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.
Related
- Identity and sign-in: choosing between federation, the built-in sign-in and SSO.
- Account linkage: letting a user tie their Appilot account to their identity in your app.
- Widget installation