Plain HTML
No build step: one script tag, one Nexafon.init call and the <nexafon-*> elements. The complete example is
examples/html in the Nexafon repository; it runs with Playwright in Chromium, Firefox and WebKit in CI.
1. The page
Section titled “1. The page”<!doctype html><html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Routa · Orders</title> <style> body { margin: 0; font: 15px/1.5 system-ui, sans-serif; background: #f6f7f9; color: #111827; } header { display: flex; gap: 12px; align-items: center; padding: 12px 20px; background: #fff; border-bottom: 1px solid #e5e7eb; } main { display: grid; gap: 16px; padding: 20px; grid-template-columns: minmax(0, 1fr) 340px; } nexafon-inbox { height: 640px; border: 1px solid #e5e7eb; border-radius: 12px; overflow: hidden; } #events { font: 12px/1.6 ui-monospace, monospace; white-space: pre-wrap; } @media (max-width: 800px) { main { grid-template-columns: 1fr; } } </style> </head> <body> <header> <strong>Routa</strong><span>Orders</span> <nexafon-call-button id="call-customer" phone="+252615550142"></nexafon-call-button> </header> <main> <script src="%NEXAFON_EMBED_CDN_URL%/v1/nexafon.js"></script> <script>Nexafon.init({ publishableKey: '%NEXAFON_PUBLISHABLE_KEY%', getUserToken: () => fetch('/nexafon/user-token', { method: 'POST' }).then((r) => { if (!r.ok) throw new Error(`The token endpoint answered ${r.status}`); return r.text() }), appearance: { primaryColor: '#16A34A', radius: 10 } })</script> <nexafon-inbox></nexafon-inbox> <aside> <nexafon-softphone></nexafon-softphone> <h2>Events</h2> <div id="events" aria-live="polite"></div> </aside> </main> <script> const log = document.getElementById('events') for (const name of ['ready', 'session.ready', 'session.error', 'call.ringing', 'call.answered', 'call.ended', 'message.received']) { Nexafon.on(name, (detail) => { log.textContent += `${name} ${JSON.stringify(detail)}\n` }) } window.nexafonEvents = log </script> </body></html>The server fills in %NEXAFON_EMBED_CDN_URL% and %NEXAFON_PUBLISHABLE_KEY%; in your page write the script URL
(https://app.nexafone.so/embed/v1/nexafon.js) and your publishable key in directly.
2. The token endpoint
Section titled “2. The token endpoint”The page’s getUserToken posts to your server, which signs a token for the signed-in user. The example signs it
without dependencies, which shows what a user token is:
import { createHmac, randomUUID } from 'node:crypto'
// A user token is a JWT (HS256) the site's server signs with its Nexafon secret key: the key string is the HMAC key.// It lives at most 10 minutes and is used once, so every embed load asks for a new one.export interface UserClaims { sub: string name?: string email?: string email_verified?: boolean role?: string locale?: string stores?: { externalRef: string; role: string }[]}
const LIFETIME_S = 5 * 60
const b64 = (v: string | Buffer) => Buffer.from(v).toString('base64url')
export function signUserToken(secretKey: string, claims: UserClaims, now = Date.now()): string { const iat = Math.floor(now / 1000) const header = b64(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) const payload = b64(JSON.stringify({ ...claims, iat, exp: iat + LIFETIME_S, jti: randomUUID() })) const signature = createHmac('sha256', secretKey).update(`${header}.${payload}`).digest() return `${header}.${payload}.${b64(signature)}`}
// "store-1:owner,store-2:agent" → the partner token's stores.export function parseStores(raw: string): { externalRef: string; role: string }[] { return raw .split(',') .map((pair) => pair.trim()) .filter(Boolean) .map((pair) => { const [externalRef = '', role = 'agent'] = pair.split(':') return { externalRef, role } })}In your own server use a server helper instead: one call in Node, PHP, Python or Go.
Multi-page sites
Section titled “Multi-page sites”examples/html/public/shop.html and shop-orders.html start Nexafon with popOutCalls: true. A call started on
one page moves to a small pop-out window and keeps going while the user navigates to the other page:
<!doctype html><html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Routa · Shop</title> </head> <body> <nav><a href="/shop-orders.html">Orders</a></nav> <h1>Shop</h1> <!-- A multi-page site: calls run in a pop-out window, so leaving this page does not end them. --> <script src="%NEXAFON_EMBED_CDN_URL%/v1/nexafon.js"></script> <script>Nexafon.init({ publishableKey: '%NEXAFON_PUBLISHABLE_KEY%', getUserToken: () => fetch('/nexafon/user-token', { method: 'POST' }).then((r) => { if (!r.ok) throw new Error(`The token endpoint answered ${r.status}`); return r.text() }), popOutCalls: true })</script> <nexafon-call-button phone="+252615550142"></nexafon-call-button> <nexafon-softphone></nexafon-softphone> </body></html>Run the example
Section titled “Run the example”cp examples/html/.env.example examples/html/.env # then fill in the keyspnpm --filter @nexafon/example-html startAdd http://localhost:5199 to the workspace’s embed domains and to the publishable key’s allowed origins first. For
this demo only, set EXAMPLE_DEMO_USER_EMAIL to a member’s email: the example has no sign-in, so its token endpoint
signs for that one demo user and answers 401 without it. Your server signs only for the user your own session has
signed in, answers 403 to signed-in users who are not your staff (a token can make its user a member of your
workspace, so never sign for customers who signed up), and sends email_verified: true only for an address your site
has confirmed.