Identity verification
What it gives you
- The agent knows the user: it can greet them by name and use the details you send (plan, company…).
- Their chats land on one contact in IntelliDesk, across devices, with what they told the agent before.
- Actions such as order lookups use the verified user, not whatever someone types.
- Without a signature, a user id is only a claim: anyone could open the widget and say they're someone else. With one, IntelliDesk only trusts ids your server signed.
Set it up
- In the dashboard, open Deploy → Chat widget → Embed and turn on Identity verification. Copy the identity secret into your server's environment (for example
INTELLIDESK_IDENTITY_SECRET). Never put it in browser code. - For each signed-in user, compute
user_hash: the HMAC-SHA256 of theiruser_idwith that secret, as lowercase hex. Use the secret as text, exactly as shown; don't hex-decode it. - Give both to the widget, either before the script loads or later with
identify:
<script>
window.intellideskUserConfig = {
user_id: "u_123", // your user's id
user_hash: "HASH_FROM_YOUR_SERVER", // HMAC-SHA256 of user_id, below
name: "Ada Lovelace", // optional: shown to the agent
email: "ada@example.com",
plan: "pro",
};
</script>
<script src="https://YOUR_APP/widget.js" data-agent="AGENT_SLUG_OR_ID" async></script>In a single-page app, fetch the signed values from your server once the user signs in and call intellidesk("identify", { user_id, user_hash, name, email }). On logout, call intellidesk("resetUser"): the next person on that browser starts as a new visitor. See React and Next.js.
Sign on your server
Each of these returns the same hex string for the same secret and user id.
import { createHmac } from "node:crypto";
// INTELLIDESK_IDENTITY_SECRET: Deploy → Chat widget → Embed → Identity secret. Server only.
export function intellideskHash(userId: string) {
return createHmac("sha256", process.env.INTELLIDESK_IDENTITY_SECRET!).update(userId).digest("hex");
}
// Express: the signed-in user's identity, for the browser to pass to identify().
app.get("/api/intellidesk-identity", requireLogin, (req, res) => {
res.json({ user_id: req.user.id, user_hash: intellideskHash(req.user.id) });
});import hashlib, hmac, os
def intellidesk_hash(user_id: str) -> str:
secret = os.environ["INTELLIDESK_IDENTITY_SECRET"].encode()
return hmac.new(secret, user_id.encode(), hashlib.sha256).hexdigest()<?php
function intellidesk_hash(string $userId): string {
return hash_hmac('sha256', $userId, getenv('INTELLIDESK_IDENTITY_SECRET'));
}require "openssl"
def intellidesk_hash(user_id)
OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("INTELLIDESK_IDENTITY_SECRET"), user_id.to_s)
endimport (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"os"
)
func IntelliDeskHash(userID string) string {
mac := hmac.New(sha256.New, []byte(os.Getenv("INTELLIDESK_IDENTITY_SECRET")))
mac.Write([]byte(userID))
return hex.EncodeToString(mac.Sum(nil))
}User ids
Up to 256 visible characters, no spaces: database ids, emails (ada@example.com) and sign-in provider ids (auth0|64f1…, google-oauth2|1234) all work. Sign exactly the string you send. Prefer an id that never changes: a user who changes their email would otherwise become a new contact.
Or send a JWT
A JWT carries the user's details inside the signature, so the agent can trust their email and name too, and it can expire. Sign it with HS256 and the same identity secret.
import jwt from "jsonwebtoken";
// A short-lived token with the details you want the agent to trust.
const token = jwt.sign(
{
user_id: user.id, // derive from the authenticated session
aud: process.env.INTELLIDESK_AGENT_ID,
workspace_id: process.env.INTELLIDESK_WORKSPACE_ID,
customer_id: user.accountId, // optional account mapping owned by your server
email: user.email,
name: user.name,
phone: user.phone,
custom_attributes: { plan: "pro", company: "Acme" },
},
process.env.INTELLIDESK_IDENTITY_SECRET,
{ algorithm: "HS256", expiresIn: "1h" },
);window.intellidesk("identify", { token: TOKEN_FROM_YOUR_SERVER });| Claim | What it is |
|---|---|
| user_id or sub | Required. The user's id (same rules as above). |
| email, name, phone | Saved on the contact and trusted by the agent. |
| custom_attributes | An object of extra details (plan, company…), shown to your team and the agent. |
| exp, nbf, iat | Numeric dates in seconds. Expired, not-yet-valid, or malformed dates are refused. Set exp: a legacy token without it never expires. |
| aud / agent_id, workspace_id | Use the agent ID as aud and include its workspace ID. Supplied scopes must match; keep each agent identity secret separate. |
| customer_id | Optional account ID, selected by your authenticated server session. Never copy it from browser input. |
| stripe_accounts | Optional. The user's Stripe customer IDs (cus_…), as a list. Lets the agent answer billing questions from your Stripe account. |
Details outside the signature
Other fields you pass (name, plan, current_page, or a metadata object) are shown to the agent as context, but anyone can change them in their browser, so treat them as hints. Don't put secrets or payment ids there; use JWT claims for anything the agent must trust.
While verification is on
| Where | What happens |
|---|---|
| Chat widget, help page | With Only signed-in users can chat on (the default), only verified users can chat; others see “Please sign in to chat with us.”, and your browser console explains why. Turn it off to let anonymous visitors chat too. Unsigned user IDs are ignored when verification is configured. An explicitly presented forged or expired proof is refused, even on an existing chat. |
| REST API and MCP | Not affected: the secret key already proves the call is yours. Name the user with user (REST API). |
| Email, WhatsApp, Messenger, Instagram, Slack | Not affected: those channels identify people their own way. |
| Playground | Not affected. |
identify before it expires; every signed chat continuation and history request needs fresh proof.Authorize customer actions
Anonymous visitors can still ask FAQs. Account lookups require current server-verified identity; a remembered contact, typed email, order number, or model assertion cannot grant account access. HMAC verifies the user ID only. Use JWT claims or the authenticated REST API user for trusted email and account IDs.
For Custom Action HTTP, enable Verified customer only. Use {{verified_user_id}}, {{verified_email}}, or {{verified_customer_id}} in your URL/body. These placeholders also enable the policy automatically. The AI cannot fill or replace them. Missing required signed claims block the request.
Your API receives X-IntelliDesk-Customer-Id and, when signed, X-IntelliDesk-Customer-Email. Configure a server credential in the action headers and authenticate it at your API before trusting those headers. Your API must check every order/resource against that customer before reading or changing it. Return a JSON ownership envelope:
{
"customer": { "user_id": "u_123" },
"data": { "status": "shipped" }
}The returned customer.user_id must match the signed subject. A mismatched or missing owner suppresses customer data and reports unconfirmed completion. Check the provider's state before retrying writes; IntelliDesk cannot undo a request your API already accepted.
Shopify order lookup compares the returned order email with the email signed in your JWT or authenticated API user. It ignores emails typed into chat. The generic Procedures order endpoint uses the same headers and requires customer.user_id beside its existing order object. Update existing integrations before using them for account data.
Stripe billing answers
Turn on Stripe (customer billing) in Actions and your agent can tell a signed-in customer about their plan, invoices and card on file, straight from your Stripe account. It only reads; it can't cancel, refund or change a plan.
- Sign the customer's Stripe IDs in your JWT as
stripe_accounts, for example[{ "stripe_id": "cus_123" }]. Or sign their email: it works when it matches exactly one Stripe customer. - An HMAC
user_hashonly proves the user ID, so it isn't enough for billing on its own. - An email or customer ID typed into the chat never unlocks billing details.
Check the signed customer on your MCP server
When an MCP server under Actions → MCP server has Only for verified customers on, each tool call tells your server which signed-in customer is asking. The note comes in the X-IntelliDesk-Customer header and again in params._meta["io.intellidesk/customer"]. It holds the customer's id, their email when it was signed, and a time window of 5 minutes.
- On the server's page, click Show key under Customer signing key and copy it into your server's environment. You see it once. Until then, tools for verified customers don't run.
- On every tool call, check the note with the code below and reject the call if it fails.
- Only read or change data that belongs to the returned
id. An order lookup, for example, must match both the order number and this customer. The note says who is asking; it doesn't make any order theirs.
import { createHmac, timingSafeEqual } from "node:crypto";
// INTELLIDESK_MCP_CUSTOMER_KEY: Actions → MCP server → Customer signing key. Server only.
function verifyCustomer(token, meta, secret, now = Math.floor(Date.now() / 1000)) {
try {
if (typeof token !== "string" || token.length > 4096) return null;
const parts = token.split(".");
if (parts.length !== 2 || parts.some(part => !/^[A-Za-z0-9_-]+$/.test(part))) return null;
const [payload, sig] = parts;
const expected = createHmac("sha256", secret)
.update("io.intellidesk/customer.v1." + payload).digest();
const actual = Buffer.from(sig, "base64url");
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) return null;
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
if (!claims || typeof claims !== "object" || Array.isArray(claims) ||
Object.keys(claims).some(key => !["id", "email", "iat", "exp"].includes(key)) ||
typeof claims.id !== "string" || !claims.id ||
(claims.email !== undefined && typeof claims.email !== "string") ||
!Number.isSafeInteger(claims.iat) || !Number.isSafeInteger(claims.exp) ||
claims.iat > now || claims.exp <= now || claims.exp <= claims.iat ||
claims.exp - claims.iat > 300) return null;
if (!meta || typeof meta !== "object" || Array.isArray(meta) ||
Object.keys(meta).length !== Object.keys(claims).length + 1 ||
meta.id !== claims.id || meta.email !== claims.email ||
meta.iat !== claims.iat || meta.exp !== claims.exp || meta.sig !== sig) return null;
return claims;
} catch { return null; }
}
// In your tools/call handler, before doing anything:
// const token = req.headers["x-intellidesk-customer"];
// const meta = body.params?._meta?.["io.intellidesk/customer"];
// const customer = verifyCustomer(token, meta, process.env.INTELLIDESK_MCP_CUSTOMER_KEY);
// if (!customer) return res.status(401).end();
// Then only read or change data that belongs to customer.id.Test it
- Open your site signed in, send a message, and open the chat in Activity: it shows the user as verified.
- Change one character of
user_hashin the browser: the widget asks the visitor to sign in, and the console says why. - Sign out (your code calls
resetUser) and sign in as another user: the chat starts empty.