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.
The token
Section titled “The token”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.
// 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.
The page
Section titled “The page”<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:
<!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:
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.
What the user sees
Section titled “What the user sees”<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.