Skip to content

Quickstart: the inbox on your site in 10 minutes

By the end of this page a page on your own site shows the Nexafon inbox, signed in as one of your users, with no Nexafon sign-in screen. You need:

  • a Nexafon workspace whose plan includes SDK embeds, with its WhatsApp number connected (the inbox works without one, it is just empty);
  • Node.js 22.18 or later, which runs TypeScript files directly (the server helpers also exist for PHP, Python and Go);
  • about 10 minutes.

In the dashboard (https://app.nexafone.so), open Developers → API keys and create two keys in test mode:

  1. A publishable key (pk_test_…). Under Allowed websites add the origin your page runs on, here http://localhost:3000. A publishable key is safe in a browser: it only starts sessions, and only from those origins.
  2. A secret key (sk_test_…). Pick the permissions the embedded users may use, for the inbox at least inbox:reply. Copy it now: it is shown once. It stays on your server.

Then open Developers → Embed domains and add the same origin, http://localhost:3000. Nexafon’s frames refuse to load on any other site.

Your server tells Nexafon who is signed in by signing a short-lived user token with the secret key, using the Node helper @nexafon/node. The SDK packages are not on npm yet, so npm install @nexafon/node fails for now; build the helper from a checkout of the Nexafon repo and install the tarball (install from a checkout has the same route for every package). In the repo:

Terminal window
pnpm install
pnpm --filter @nexafon/node build
pnpm --filter @nexafon/node pack --pack-destination /tmp

Then, in a new folder for this quickstart ("type": "module" lets Node run server.ts as a module without a warning):

Terminal window
npm init -y
npm pkg set type=module
npm install /tmp/nexafon-node-*.tgz

Save this server as server.ts:

server.ts
import { readFileSync } from 'node:fs'
import { createServer, type IncomingMessage } from 'node:http'
import { fileURLToPath } from 'node:url'
import { createUserToken, verifyWebhook, WebhookVerificationError } from '@nexafon/node'
interface SignedInUser {
id: string
name: string
email: string
emailVerified: boolean
isStaff: boolean
}
// Your app's own session decides who is signed in, and its own records whether it has confirmed their email.
// This sample has no sign-in: its user is the teammate in NEXAFON_USER_EMAIL, whose address you confirmed by
// inviting it to your workspace.
function signedInUser(_req: IncomingMessage): SignedInUser | null {
const email = process.env.NEXAFON_USER_EMAIL ?? ''
if (email === '') return null
return {
// Nexafon keeps the member it first linked to this id, so it must be the same person's id on every sign-in and
// never another person's. Use your app's user id; the sample has no user records, so the email stands in.
id: email,
name: 'Amina Ali',
email,
emailVerified: true,
isStaff: true,
}
}
// Everyone who gets a token may reach your workspace's inbox and calls: sign only for your staff, never for customers
// who signed up. The sample's user is the teammate you invited.
function mayUseNexafon(user: SignedInUser): boolean {
return user.isStaff
}
async function rawBody(req: IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(Buffer.from(chunk))
return Buffer.concat(chunks)
}
const page = readFileSync(new URL('./quickstart.html', import.meta.url), 'utf8')
.replaceAll('%NEXAFON_EMBED_CDN_URL%', process.env.NEXAFON_EMBED_CDN_URL ?? '')
.replaceAll('%NEXAFON_PUBLISHABLE_KEY%', process.env.NEXAFON_PUBLISHABLE_KEY ?? '')
export const server = createServer(async (req, res) => {
// #region token
// The SDK calls getUserToken whenever it needs a session; answer with a fresh token for the signed-in user.
if (req.method === 'POST' && req.url === '/nexafon-token') {
const user = signedInUser(req)
if (!user) {
res.writeHead(401).end()
return
}
if (!mayUseNexafon(user)) {
res.writeHead(403).end()
return
}
// Signed with NEXAFON_SECRET_KEY (sk_test_… or sk_live_…), which never leaves this server.
const token = createUserToken({
userId: user.id,
name: user.name,
role: 'agent',
email: user.email,
// Nexafon links a first sign-in to the member with this email only when it is verified: never send true for
// an address your app has not confirmed, or whoever sets a member's email on your app acts as that member.
emailVerified: user.emailVerified,
})
res.writeHead(200, { 'content-type': 'text/plain', 'cache-control': 'no-store' }).end(token)
return
}
// #endregion
// #region webhook
if (req.method === 'POST' && req.url === '/hooks/nexafon') {
try {
// The raw body, exactly as received: parsing it first changes the bytes the signature covers.
const event = verifyWebhook(
req.headers,
await rawBody(req),
process.env.NEXAFON_WEBHOOK_SECRET ?? '',
)
// Deliveries can repeat: skip an event.id you have already handled.
console.log(event.type, event.id)
res.writeHead(204).end()
} catch (err) {
if (!(err instanceof WebhookVerificationError)) throw err
res.writeHead(400).end()
}
return
}
// #endregion
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(page)
})
if (process.argv[1] === fileURLToPath(import.meta.url)) {
server.listen(3000, '127.0.0.1', () => console.log('listening on port 3000'))
}

createUserToken signs a JWT that lives 5 minutes and works once. The role caps what the session may do, below the member’s own role and the key’s permissions. User tokens has every claim.

The sample’s user is the teammate you invited, so its address is confirmed. In your app, pass the flag your own user record keeps.

The userId must be stable per person: Nexafon keeps the member it first linked to an id, so send the same id for the same person every time and never one person’s id for another. Use your app’s own user id; the sample has no user records, so it uses the email. To try a second teammate, invite them too and set NEXAFON_USER_EMAIL to their address.

Save the page next to the server as quickstart.html. It loads the SDK, starts it with your publishable key and a function that fetches a fresh token (and throws when the server answers an error, such as 401 when no one is signed in), and shows <nexafon-inbox>:

quickstart.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Support</title>
<style>
nexafon-inbox { display: block; height: 640px; }
</style>
</head>
<body>
<script src="%NEXAFON_EMBED_CDN_URL%/v1/nexafon.js"></script>
<script>
Nexafon.init({
publishableKey: '%NEXAFON_PUBLISHABLE_KEY%',
// A 401 (no one signed in) must not reach the SDK as a token.
getUserToken: async () => {
const r = await fetch('/nexafon-token', { method: 'POST' })
if (!r.ok) throw new Error(`The token endpoint answered ${r.status}`)
return r.text()
},
})
</script>
<nexafon-inbox></nexafon-inbox>
</body>
</html>

The server above fills in %NEXAFON_EMBED_CDN_URL% and %NEXAFON_PUBLISHABLE_KEY%; in your own page write the values in directly.

Terminal window
NEXAFON_SECRET_KEY=sk_test_… \
NEXAFON_PUBLISHABLE_KEY=pk_test_… \
NEXAFON_EMBED_CDN_URL=https://app.nexafone.so/embed \
node server.ts

Open http://localhost:3000. The inbox loads already signed in as the teammate whose email you set, and shows the conversations of their teams.

What you see Why Fix
The frame is blank and the console reports frame-ancestors The page’s origin is not an embed domain Add it under Developers → Embed domains
“This can’t open here” The session exchange failed Open the browser’s network tab: POST /api/v1/sdk/sessions answers with a Problem Details code
client_not_allowed The origin is not on the publishable key Add it to the key’s Allowed websites
sdk_member_not_found No member has that email Invite the user, or check NEXAFON_USER_EMAIL
The inbox is empty The teammate is in no team, or their teams have no conversations Add them to a team under Staff → Members (Edit teams)
sdk_role_not_allowed The email belongs to the workspace owner Sign in as a teammate
user_token_replayed A token was reused Fetch a new token on every getUserToken call

The SDK packages are not published yet: they are not on npm, Packagist or PyPI, and go get finds the Go module only once the Nexafon repository is public and tagged. Until then, install them from a checkout of the repo; below, /path/to/nexafon is that checkout. The guides show the install command to use after release.

npm (@nexafon/js, @nexafon/react, @nexafon/vue, @nexafon/angular, @nexafon/node): build and pack the packages you use, in the repo. The React, Vue and Angular wrappers depend on @nexafon/js, so pack it with them:

Terminal window
pnpm install
pnpm --filter @nexafon/js --filter @nexafon/react build
pnpm --filter @nexafon/js --filter @nexafon/react pack --pack-destination /tmp

Then install the tarballs in your app, all in one command:

Terminal window
npm install /tmp/nexafon-js-*.tgz /tmp/nexafon-react-*.tgz

PHP (nexafon/nexafon-php): add the package folder as a Composer path repository. With symlink off, Composer copies the package, so your app does not depend on where the checkout lives:

Terminal window
composer config repositories.nexafon '{"type": "path", "url": "/path/to/nexafon/packages/server-php", "options": {"symlink": false}}'
composer require nexafon/nexafon-php:@dev

Python (nexafon): install the package folder. Building it from source needs Python 3.9 or later:

Terminal window
pip install /path/to/nexafon/packages/server-python

Go (github.com/Keyd-Solutions/nexafon/packages/server-go): point the module path at the folder with a replace directive. After release, remove the replace and run go get:

Terminal window
go mod edit -require=github.com/Keyd-Solutions/nexafon/packages/[email protected] \
-replace=github.com/Keyd-Solutions/nexafon/packages/server-go=/path/to/nexafon/packages/server-go