@ancsp/widget-sdk 1.0.0
Add ANCSP chat to your site
One public widget id, one script tag, and a same-origin relay for session and message calls. Identity comes from your backend as a signed assertion — never from the browser.
1. Embed
Publish a profile in Widget Studio, then use its public id. The SDK mounts in a shadow root and fetches the published config with an ETag.
<script
src="https://assets.example.com/widget.js"
data-ancsp-widget="wgt_yourprofile"
data-api-base="/api/gw"
async
></script>Or mount it from a bundler:
import { mountWidget } from '@ancsp/widget-sdk'
const widget = mountWidget({
publicId: 'wgt_yourprofile',
apiBaseUrl: '/api/gw',
configBaseUrl: 'https://api.ancsp.example.com',
hubUrl: 'wss://api.ancsp.example.com/hubs/chat',
preChat: true,
})
await widget.ready
widget.on('message', (message) => console.log(message.senderType, message.content))
const { assertion } = await fetch('/api/widget-identity').then((r) => r.json())
await widget.identify(assertion)2. Same-origin relay
The public config allows any origin. /conversations and the guest message route do not, so your server forwards them. Forward Authorization, Content-Type and If-None-Match; forward ETag back; never forward cookies; cap the upstream path.
app.all('/api/gw/*', express.raw({ type: '*/*' }), async (req, res) => {
const path = req.originalUrl.slice('/api/gw/'.length)
if (!path.startsWith('api/v1/') || path.includes('..')) return res.sendStatus(404)
const upstream = await fetch(`${GATEWAY}/${path}`, {
method: req.method,
headers: {
accept: 'application/json',
'content-type': req.headers['content-type'] ?? 'application/json',
...(req.headers.authorization ? { authorization: req.headers.authorization } : {}),
...(req.headers['if-none-match'] ? { 'if-none-match': req.headers['if-none-match'] } : {}),
},
body: ['GET', 'HEAD'].includes(req.method) ? undefined : req.body,
redirect: 'manual',
signal: AbortSignal.timeout(20_000),
})
res.status(upstream.status)
for (const h of ['content-type', 'etag', 'cache-control', 'retry-after']) {
const v = upstream.headers.get(h)
if (v) res.setHeader(h, v)
}
if ([204, 304].includes(upstream.status)) return res.end()
res.send(Buffer.from(await upstream.arrayBuffer()))
}).NET and PHP relay examples are in the repository guide (`docs/DEVELOPERS.md`); they follow the same rules.
3. Signed identity
Your backend exchanges a tenant service key for a short-lived, single-use assertion, then the page calls identify. The service key never reaches the browser.
Node
app.get('/api/widget-identity', async (req, res) => {
const user = req.session.user // your own auth
const response = await fetch(
`${process.env.ANCSP_IDENTITY_URL}/api/v1/auth/sdk/widget-assertion`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': process.env.ANCSP_SERVICE_KEY,
},
body: JSON.stringify({
publicId: process.env.ANCSP_WIDGET_PUBLIC_ID,
externalId: user.id,
email: user.email,
name: user.name,
}),
},
)
const body = await response.json()
if (!body.meta?.success) return res.status(502).json({ error: 'assertion_failed' })
res.json({ assertion: body.data.assertion })
}).NET
app.MapGet("/api/widget-identity", async (HttpContext ctx, IHttpClientFactory factory) =>
{
var user = ctx.User;
using var client = factory.CreateClient("ancsp-identity");
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/auth/sdk/widget-assertion")
{
Content = JsonContent.Create(new
{
publicId = configuration["Ancsp:WidgetPublicId"],
externalId = user.FindFirstValue(ClaimTypes.NameIdentifier),
email = user.FindFirstValue(ClaimTypes.Email),
}),
};
request.Headers.Add("x-api-key", configuration["Ancsp:ServiceKey"]);
using var response = await client.SendAsync(request);
var body = await response.Content.ReadFromJsonAsync<AssertionEnvelope>();
return body?.Meta?.Success == true
? Results.Ok(new { assertion = body.Data!.Assertion })
: Results.Problem("assertion_failed", statusCode: 502);
});PHP
<?php
session_start();
$ch = curl_init(getenv('ANCSP_IDENTITY_URL') . '/api/v1/auth/sdk/widget-assertion');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode([
'publicId' => getenv('ANCSP_WIDGET_PUBLIC_ID'),
'externalId' => (string) $_SESSION['user_id'],
'email' => $_SESSION['user_email'] ?? null,
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'content-type: application/json',
'x-api-key: ' . getenv('ANCSP_SERVICE_KEY'),
],
]);
$body = json_decode(curl_exec($ch), true);
curl_close($ch);
header('content-type: application/json');
echo json_encode(['assertion' => $body['data']['assertion'] ?? null]);Backend-pending capabilities
These calls reject with WidgetBackendPendingError and the exact dependency. They never return invented data:
availability()andcreateTicket()— D08-7requestEmailVerification()/confirmEmailVerification()— D08-4
Pre-chat is local (topic + first message) and offline mode is host-declared: messages queue in a persisted outbox until you call setOffline(false).
CSP and accessibility
No eval, no inline event handlers. Allow the widget origin in script-src, the gateway and hub in connect-src, and pass a nonce as styleNonce for the two style elements. The panel is a labelled dialog: Escape closes, Tab is trapped, focus returns to the launcher, and the message log is a polite live region.