purserDOCS

Identity tokens: telling purser who the customer is

Sign a short-lived, single-use token on your server with the identity secret so the chat widget recognises a signed-in customer.

The chat widget runs in the visitor's browser, so anything it says about who the visitor is cannot be trusted. That is why purser's chat API has no "customer ID" parameter at all: the only way purser will believe that a visitor is one of your customers is a token your server signed with the identity secret.

Without a token, or with one that fails verification, the conversation is anonymous. In an anonymous conversation the AI reveals no order information; asking "where is my order?" gets a prompt to sign in.

What changes once a customer is recognised

Anonymous visitorVerified customer
Orders and trackingThe AI gives no order informationThe AI gets their 5 most recent orders (status, items, total, shipment) up front and does not ask for an order number; shipping address and email are never given to the model
Customer segmentsIn no segmentServed under the segment you defined (VIP and so on), see Customers and segments
RefundsThe AI never offers a refundThe AI only offers refunds for their own orders, when your policy allows it, see Refunds
Previous conversationAgents see a device-matched history marked "may not be the same person"; the AI never sees itIf they talked to you in the last 30 days, the AI gets a summary of that conversation as background; agents see the previous conversation in the inbox

The customer ID in the token (sub) must be the same ID you use as customerId when you push orders through the API, otherwise the AI cannot find their orders. Pushing data is covered in REST API and Node SDK.

The two values you need

In the console, on the Integration (接入) page, card "4. Recognise signed-in customers" (4. 识别已登录的顾客):

  • Workspace ID (工作区 ID(iss)): goes into the token's iss;
  • Identity secret (身份签名密钥): the key you sign with.

Only the workspace's owners and admins can see this card. The identity secret is separate from your API keys (sk_…): a leaked API key cannot be used to impersonate a customer.

If the identity secret leaks, click "Replace secret" (更换密钥) on the card. The new secret takes effect at once and tokens signed with the old one stop working: until your server's environment has the new secret, signed-in customers are treated as anonymous visitors (they can chat but see no orders). So be ready to update your server before you replace it.

Keep the secret on your server

The identity secret is the power to sign in to the chat as any of your customers. Keep it in your server's environment only; never put it in a web page, an app bundle or front-end code.

Token format

The token is a JWT signed with HS256:

// header
{ "alg": "HS256", "typ": "JWT" }

// payload
{
  "iss": "<workspace ID>",
  "sub": "<the customer's ID in your system>",
  "vid": "<visitor ID>",
  "jti": "<a random value unique to this token>",
  "iat": 1790000000,
  "exp": 1790000300
}
ClaimMeaning
issThe workspace ID, exactly as the console shows it
subThe customer's ID in your system (a string)
vidThe visitor ID. On a website it is purser_vid, which the embed script stores in localStorage; on the in-app page it is the vid parameter you pass to the page
jtiA random unique value, such as a UUID
iatIssued at, Unix seconds
expExpires at, Unix seconds

The signing key is the identity secret string itself (its UTF-8 bytes). Only HS256 is accepted; any other algorithm is treated as invalid.

Lifetime and single use

  • At most 10 minutes: a token whose exp - iat or exp - now is more than 600 seconds is refused, even if the signature is correct. The SDK signs for 300 seconds (5 minutes) by default.
  • Single use: each jti is accepted once per workspace. The same token presented a second time is treated as a replay and the conversation becomes anonymous.
  • Bound to the visitor: vid must match the visitor ID of the browser (or app) that starts the chat. A token copied out of a log or a screenshot is useless in another browser.

So do not sign one token when the page renders and keep it around. Fetch a fresh one from your server when the chat opens; the setup below does exactly that. The chat widget and the feedback tool each use up a token, so if you use both, sign one for each.

Signing a token in Node.js

No third-party library needed, only Node's built-in crypto (Node 20 or later):

import { createHmac, randomUUID } from "node:crypto";

const b64url = (s) => Buffer.from(s).toString("base64url");

export function signIdentityToken({ secret, workspaceId, customerId, visitorId, ttlSeconds = 300 }) {
  const now = Math.floor(Date.now() / 1000);
  const header = { alg: "HS256", typ: "JWT" };
  const payload = {
    iss: workspaceId,
    sub: String(customerId),
    vid: String(visitorId),
    jti: randomUUID(),
    iat: now,
    exp: now + Math.min(ttlSeconds, 600),
  };
  const data = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
  const sig = createHmac("sha256", secret).update(data).digest("base64url");
  return `${data}.${sig}`;
}

This matches exactly how purser verifies tokens. Any standard JWT library in any language works too, as long as it uses HS256, the claims above and a lifetime of 10 minutes or less.

@purser-ai/node has a signIdentityToken with the same parameters (it returns a Promise). That package is not published on npm yet; until it is, use the code above.

Wiring it into your website

1. Server: sign a token for the signed-in customer. With Express, for example:

app.get("/purser-token", (req, res) => {
  const customer = req.session?.customer;          // your own login session
  const vid = req.query.vid;
  if (!customer || typeof vid !== "string") return res.status(204).end();
  const token = signIdentityToken({
    secret: process.env.PURSER_IDENTITY_SECRET,
    workspaceId: process.env.PURSER_WORKSPACE_ID,
    customerId: customer.id,
    visitorId: vid,
  });
  res.set("cache-control", "no-store").type("text/plain").send(token);
});

2. Page: tell the embed script where to get the token. It can go before or after the embed code:

<script>
  async function purserToken() {
    const vid = localStorage.getItem("purser_vid");
    if (!vid) return null;
    const r = await fetch("/purser-token?vid=" + encodeURIComponent(vid), { credentials: "same-origin" });
    return r.status === 200 ? r.text() : null;
  }
  window.Purser = window.Purser || {};
  window.Purser.identity = purserToken;
</script>

Purser.identity can be a token string, or a function that returns a token (or a Promise of one). The embed script only calls it when the chat opens, so every opening gets a fresh token. The chat waits at most 1.5 seconds for it and then starts anonymously, so keep that endpoint fast.

In a single-page app, on sign-in and sign-out, call Purser.identify():

// after the customer signs in
Purser.identify(purserToken);

// after the customer signs out
Purser.identify(null);

When the identity changes (anonymous to signed in, a different customer, signed out), the chat reloads and starts a new conversation. Conversations never cross from one customer to another.

The embed code itself is covered in Website chat widget.

In-app page

When your app opens the /c/<publishable key> page, put the token after the # in the link:

https://askpurser.com/c/pk_…?vid=<visitor ID>&lang=en#token=<identity token>

The part after # is never sent to any server, and the page wipes it from the address bar as soon as it has read it. vid is a UUID your app generates once, stores on the device and reuses every time; use it as visitorId when signing. The app can also inject window.__PURSER_IDENTITY__ = "<token>" into the page instead. Details in In-app page.

Identity is never inherited

Every time a chat starts, the page has to state who the customer is again, with a fresh token. No token means anonymous, and a new conversation:

  • on a shared computer, the previous customer's verified session is not carried over to the next visitor;
  • a verified conversation and an anonymous one never continue each other;
  • the same customer only continues their conversation when they reload in the same browser tab and come back with a new token.

This is deliberate: on a shared computer it is the only way to make sure the next person cannot see the previous customer's orders and tracking links.

Troubleshooting tokens

A bad token never shows an error in the chat; the chat simply runs anonymously. To see why: open your browser's developer tools, Network panel, and find the sessions request made by the chat iframe (POST /api/v1/widget/sessions). If identity in the response is verified_hmac, the customer was recognised; otherwise read identityError:

identityErrorCause
malformedNot a valid JWT, or sub / jti / exp is missing
wrong_algorithmNot HS256
bad_signatureWrong signature: the wrong secret, or signed for another workspace
wrong_issueriss is not this workspace's ID
expiredExpired (check your server's clock)
lifetime_too_longValid for more than 10 minutes
visitor_mismatchvid does not match this browser's visitor ID
replayedThis token was already used

In the Go-live checklist (上线清单) at the top of the Integration (接入) page, the step "Recognise signed-in customers" (认出已登录的顾客) is ticked automatically after the first verified conversation.

On this page