Skip to content

Partner user tokens and the full app

Partner staff sign in to Nexafon only through your platform. Your server vouches for them with a partner user token, and your page shows Nexafon with <nexafon-app>: the whole dashboard, with Nexafon’s side menu, for all of their stores.

A JWT (HS256) signed with a partner secret key (psk_…) that has the partner.stores:manage scope, of the same mode as your publishable partner key:

Claim Required
sub yes Your id for the user
name yes Their display name
jti, exp yes A new id per token; at most 10 minutes ahead. Each token works once
email no Their email
locale no en, so or ar
stores yes 1 to 50 of your stores: { externalRef, role, teams? }

role is one of owner, admin, supervisor, agent or viewer. teams, when present, are the store’s team ids the user is in; leaving it out keeps their teams as they are, and an empty list takes them out of every team.

partner.ts
// For the staff member your session has signed in: one token covers every store your records say they work in, each
// with its own role. Never take the user, stores or roles from the request.
export function partnerToken(user: {
id: string
name: string
email: string
stores: { externalRef: string; role: string }[]
}) {
return createPartnerUserToken({
userId: user.id,
name: user.name,
email: user.email,
locale: 'so',
stores: user.stores,
})
}

On each sign-in Nexafon creates or updates the user’s membership of every listed store (role, name, teams) and re-enables it if it was disabled. Each membership takes a seat in its store; a store without a free seat answers no_free_seat naming the store, unless its owner turned on adding seats automatically (see billing). Users exist per partner: they have no password and cannot sign in to Nexafon directly.

<script src="https://app.nexafone.so/embed/v1/nexafon.js"></script>
<script>
Nexafon.init({
publishableKey: 'ppk_live_…',
getUserToken: async () => {
const r = await fetch('/nexafon/partner-token', { method: 'POST' })
if (!r.ok) throw new Error(`The token endpoint answered ${r.status}`)
return r.text()
},
})
</script>
<nexafon-app style="height: 100vh"></nexafon-app>

A partner session exchanges only on your domains (client_not_allowed elsewhere). One session covers all the user’s stores, and calls from any of them ring the user.

The examples/html partner page:

examples/html/public/partner.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tocktify · Stores</title>
<style>
body { margin: 0; font: 15px/1.5 system-ui, sans-serif; }
header { padding: 10px 16px; border-bottom: 1px solid #e5e7eb; }
nexafon-app { height: calc(100vh - 46px); }
</style>
</head>
<body>
<header><strong>Tocktify</strong> · Seller centre</header>
<!-- A partner runs all of Nexafon inside its own platform: one session covers every store of the user. -->
<script src="%NEXAFON_EMBED_CDN_URL%/v1/nexafon.js"></script>
<script>Nexafon.init({ publishableKey: '%NEXAFON_PARTNER_PUBLISHABLE_KEY%', getUserToken: () => fetch('/nexafon/partner-token', { method: 'POST' }).then((r) => { if (!r.ok) throw new Error(`The token endpoint answered ${r.status}`); return r.text() }) })</script>
<nexafon-app></nexafon-app>
</body>
</html>

and the React version from examples/partner:

examples/partner/src/App.tsx
import { NexafonApp, NexafonProvider } from '@nexafon/react'
import { useEffect, useState } from 'react'
import type { ReceivedEvent } from '../server.ts'
const getUserToken = async () => {
const r = await fetch('/api/nexafon-token', { method: 'POST' })
if (!r.ok) throw new Error(`The token endpoint answered ${r.status}`)
return r.text()
}
// One partner session covers every store of the user; the embed's own switcher moves between them.
export function App() {
return (
<NexafonProvider
publishableKey={import.meta.env.VITE_NEXAFON_PUBLISHABLE_KEY}
getUserToken={getUserToken}
>
<NexafonApp style={{ height: '100%' }} />
</NexafonProvider>
)
}
export function WebhookList() {
const [events, setEvents] = useState<ReceivedEvent[]>([])
useEffect(() => {
const load = () =>
fetch('/api/webhook-events')
.then((r) => r.json() as Promise<ReceivedEvent[]>)
.then(setEvents, () => undefined)
load()
const timer = setInterval(load, 2000)
return () => clearInterval(timer)
}, [])
return events.map((e) => (
<li key={e.id} data-type={e.type} data-store={e.store}>
{e.type} {e.store}
</li>
))
}

Both examples have no sign-in of their own, so for local runs only their token endpoints sign for one demo user (EXAMPLE_DEMO_USER_* in each example’s .env) and answer 401 without it. Your server signs only for the user your own session has signed in, and your platform decides which stores and roles that user has; never take them from the request.

<nexafon-app> shows every section the user’s role allows in the current store: inbox, calls, team calls and meetings, team, routing and Shop, templates, recordings, reports, connecting WhatsApp, billing, products, orders and payments. An agent sees the agent menu only. Nexafon’s own sign-in and sign-out never appear. Smaller pieces still work alongside it, such as a <nexafon-call-button phone="…" store="shop-1"> on your order page.