Connections
Recognize signed-in customers on your website
Add website customer recognition: your server signs a short-lived identity assertion and the Nuteq widget chat knows which signed-in customer is typing.
In Nuteq, website customer recognition lets the widget's text chat know which signed-in customer is typing. Your server signs a short-lived assertion about the customer, the browser forwards it, and Nuteq verifies it before the chat sees any detail. Voice chat never uses it, and guest chat keeps working for everyone.
What does my server need to sign?
Your authenticated server builds a UTF-8 JSON claims object, encodes its exact bytes as unpadded base64url (payload), and computes a lowercase hex HMAC-SHA256 over that encoded payload. The key is the install's signing secret. The browser forwards { payload, userHash } and never sees the secret.
| Claim | Rule |
|---|---|
version | Always 2. |
install_key | The widget's public install key. |
user_id | Your stable account id as a string. Never an email address. At most 256 bytes. |
session_id | The customer's session id in your system. Required, at most 256 bytes. |
jti | A unique id for this assertion. Required, at most 256 bytes. Each assertion can be exchanged once. |
iat, exp | Integer Unix seconds. exp - iat is at most 300 seconds (5 minutes), with 60 seconds of clock skew allowed. |
name | Optional. Leave it out when the customer has no name. An empty value is refused. |
email | Optional, and only used when the owner chose to share it. Leave it out when empty. |
metadata | Optional, but when present it must be a JSON object, never a list. |
Unknown claims, duplicate claims, wrong types, and NUL bytes are rejected. Send only the details your owner chose to share in the recognition setup; Nuteq drops anything else on the server.
What does the server code look like?
The dashboard's developer page generates these snippets with your install's own detail keys. The examples below use plan and renewal_date as sample details and share the email. The secret comes from NUTEQ_WIDGET_IDENTITY_SECRET and the install key from NUTEQ_WIDGET_KEY.
Node.js
import { createHmac, randomUUID } from 'node:crypto'
const sign = (claims) => {
const payload = Buffer.from(JSON.stringify(claims), 'utf8').toString('base64url')
const userHash = createHmac('sha256', process.env.NUTEQ_WIDGET_IDENTITY_SECRET).update(payload, 'utf8').digest('hex')
return { payload, userHash }
}
// After your customer signs in: render this into the page; the chat forwards it. Single-use, expires in 5 minutes.
const now = Math.floor(Date.now() / 1000)
const claims = {
version: 2, install_key: process.env.NUTEQ_WIDGET_KEY,
user_id: String(account.id), // your stable id, never their email
session_id: req.session.id, jti: randomUUID(), iat: now, exp: now + 300,
metadata: { plan: account.plan, renewal_date: account.renewalDate },
}
// Leave out a name or email the customer does not have; an empty one is refused.
if (account.firstName?.trim()) claims.name = account.firstName.trim()
if (account.email) claims.email = account.email
const identity = sign(claims)
// When they sign out, end that session in Nuteq from your server:
const revocation = sign({ type: 'revoke', version: 2, install_key: process.env.NUTEQ_WIDGET_KEY, session_id: req.session.id, jti: randomUUID(), iat: now, exp: now + 300 })
await fetch('https://api.nuteqai.com/v1/public/text/identity/revoke', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ key: process.env.NUTEQ_WIDGET_KEY, revocation: { payload: revocation.payload, user_hash: revocation.userHash } }),
})
Python
import base64, hashlib, hmac, json, os, time, uuid, urllib.request
def sign(claims):
raw = json.dumps(claims, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
payload = base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
user_hash = hmac.new(os.environ["NUTEQ_WIDGET_IDENTITY_SECRET"].encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
return {"payload": payload, "userHash": user_hash}
# After your customer signs in: render this into the page; the chat forwards it. Single-use, expires in 5 minutes.
now = int(time.time())
claims = {
"version": 2, "install_key": os.environ["NUTEQ_WIDGET_KEY"],
"user_id": str(account.id), # your stable id, never their email
"session_id": session.id, "jti": str(uuid.uuid4()), "iat": now, "exp": now + 300,
"metadata": {"plan": account.plan, "renewal_date": account.renewal_date},
}
# Leave out a name or email the customer does not have; an empty one is refused.
if (account.first_name or "").strip():
claims["name"] = account.first_name.strip()
if account.email:
claims["email"] = account.email
identity = sign(claims)
# When they sign out, end that session in Nuteq from your server:
revocation = sign({"type": "revoke", "version": 2, "install_key": os.environ["NUTEQ_WIDGET_KEY"], "session_id": session.id, "jti": str(uuid.uuid4()), "iat": now, "exp": now + 300})
body = json.dumps({"key": os.environ["NUTEQ_WIDGET_KEY"], "revocation": {"payload": revocation["payload"], "user_hash": revocation["userHash"]}}).encode("utf-8")
urllib.request.urlopen(urllib.request.Request("https://api.nuteqai.com/v1/public/text/identity/revoke", data=body, headers={"content-type": "application/json"}))
PHP
function nuteq_sign(array $claims): array {
$raw = json_encode($claims, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$payload = rtrim(strtr(base64_encode($raw), "+/", "-_"), "=");
return ["payload" => $payload, "userHash" => hash_hmac("sha256", $payload, getenv("NUTEQ_WIDGET_IDENTITY_SECRET"))];
}
// After your customer signs in: render this into the page; the chat forwards it. Single-use, expires in 5 minutes.
$now = time();
$claims = [
"version" => 2, "install_key" => getenv("NUTEQ_WIDGET_KEY"),
"user_id" => (string)$account->id, // your stable id, never their email
"session_id" => session_id(), "jti" => bin2hex(random_bytes(16)), "iat" => $now, "exp" => $now + 300,
"metadata" => (object) ["plan" => $account->plan, "renewal_date" => $account->renewal_date], // (object) keeps an empty list a JSON object
];
// Leave out a name or email the customer does not have; an empty one is refused.
if (trim($account->first_name ?? "") !== "") $claims["name"] = trim($account->first_name);
if (($account->email ?? "") !== "") $claims["email"] = $account->email;
$identity = nuteq_sign($claims);
// When they sign out, end that session in Nuteq from your server:
$revocation = nuteq_sign(["type" => "revoke", "version" => 2, "install_key" => getenv("NUTEQ_WIDGET_KEY"), "session_id" => session_id(), "jti" => bin2hex(random_bytes(16)), "iat" => $now, "exp" => $now + 300]);
$context = stream_context_create(["http" => ["method" => "POST", "header" => "content-type: application/json",
"content" => json_encode(["key" => getenv("NUTEQ_WIDGET_KEY"), "revocation" => ["payload" => $revocation["payload"], "user_hash" => $revocation["userHash"]]])]]);
file_get_contents("https://api.nuteqai.com/v1/public/text/identity/revoke", false, $context);
Any other language follows the same three steps: build the claims, base64url-encode them without padding, and HMAC-SHA256 the encoded payload with your secret as UTF-8 key bytes.
How does the browser pass the identity to the widget?
Render the signed { payload, userHash } into the signed-in page, then call identify. The dispatcher below queues calls made before the widget script loads.
function nuteq(command, value) {
if (window.NuteqWidget?.[command]) return window.NuteqWidget[command](value);
window.NuteqWidget = window.NuteqWidget || { q: [] };
window.NuteqWidget.q.push([command, value]);
}
nuteq('identify', identity);
// When the signed-in chat ends, return a fresh identity from your server, or null.
nuteq('onIdentityExpired', async () => {
const res = await fetch('/nuteq/identity', { credentials: 'same-origin' });
return res.ok ? res.json() : null;
});
// On sign-out:
nuteq('identify', null);
window.NuteqWidget.identify(identity)exchanges the assertion at once. The widget keeps the resulting identity token in memory only, so a reload exchanges your fresh assertion again.window.NuteqWidget.identify(null)ends the widget's own session and continues the chat as a guest. Other tabs of the same account switch to guest too.- The widget ignores any value that is not
{ payload, userHash }with two non-empty strings, so a failed fetch never becomes an identity. - Calling
identify()again with the byte-identical assertion keeps the live session. A different assertion is exchanged again, because each assertion works once.
How does the widget refresh an expired identity?
When a signed-in session ends, the widget calls the function you register with onIdentityExpired. Return a fresh { payload, userHash } from your server, or null when you cannot. Return null unless the response is OK, as the snippet does with res.ok, so an error page is never treated as an identity.
A session idles out after 30 minutes without use and ends after 8 hours at most. If the hook returns nothing, or the fresh assertion is refused, the chat shows a card with Start a new guest chat.
How do I end a session when the customer signs out?
Call the revocation endpoint from your server on every sign-out. It is the only way to end a copied session, because the widget's identify(null) ends only its own.
- Send
POST /v1/public/text/identity/revokewith{ key, revocation: { payload, user_hash } }, signed the same way as an assertion. - The claims are
type: "revoke",version: 2,install_key,jti,iat,exp, and exactly one ofsession_idoruser_id. Sending both is a 400. - It ends the matching sessions issued at or before the revocation's
iat, and a later sign-in works. - Repeating a revocation is harmless and returns 200. The signature authenticates the call, so no origin check applies.
What happens when recognition is paused or verification is down?
Visitors always keep a working chat.
- Recognition off or paused: a verified exchange from anyone but the owner's marked test account is refused with 401
IDENTITY_OFF. The widget chats as a guest, shows no expired card, and keeps your identity so the nextidentify()exchanges again. - Verification unavailable (503): the widget says "We couldn't confirm your sign-in right now. You can keep chatting as a guest.", and the next message goes as a guest.
- A refused assertion (bad signature, replay, expiry, revoked session): the widget does not fall back to guest silently. It shows the signed-out card so the customer knows.
Recognition is turned on and paused by an owner or admin from the Website tab in Connections. See Publish the website widget for the embed itself.
By Nuteq AI TeamPublished 4 min read
Keep reading
Related content
- Publish the website widgetPublish the website widget: pick an agent, copy its snippet, and paste it into your site. Visitors can then talk or type with your agent.Read more
- Website voice widgetThe Nuteq AI website voice widget lets visitors talk to your agent on your site. They do not log in or dial a number.Read more
- ConnectionsNuteq AI connections are the channels that let customers reach your agent: phone calls, your website, WhatsApp, and text messages.Read more
Frequently asked questions
- Where must I sign the identity assertion?
- On your own server, after the customer signs in. The signing secret must never reach website HTML, the widget script, or browser JavaScript. The browser only forwards the signed payload and hash that your server rendered.
- What happens if recognition is paused or Nuteq cannot verify the sign-in?
- The widget keeps chatting as a guest. A paused or off install answers the exchange with IDENTITY_OFF, and a verification outage returns 503. Neither shows an expired card, and the next identify() call tries again.
- Do I need to call revoke when a customer signs out?
- Yes. Call the revocation endpoint from your server on every sign-out. identify(null) in the browser ends only that widget's own session, and cannot end a copied one.
Ready to stop missing calls?
Nuteq AI answers every call and text, books appointments, and captures every lead — 24/7.