Northwind Supply developers

@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() and createTicket() — D08-7
  • requestEmailVerification() / 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.