Skip to content

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.

examples/html/public/index.html
<!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.

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:

examples/html/token.ts
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.

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:

examples/html/public/shop.html
<!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>
Terminal window
cp examples/html/.env.example examples/html/.env # then fill in the keys
pnpm --filter @nexafon/example-html start

Add 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.