# Plain HTML

> Add the inbox, softphone and call buttons to any HTML page with one script tag, including multi-page sites with pop-out calls.

Source: https://docs.nexafone.so/v1/web/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

```html title="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.

## 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](/v1/server/user-tokens/) is:

```ts title="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](/v1/server/) instead: one call in Node, PHP, Python or Go.

## 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:

```html title="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>
```

## Run the example

```sh
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.
