Visitor identity
Anonymous visitors work with zero effort. Sign an identity token and your logged-in users see their real support history.
This is the one part of the widget where you write code — and the only part you really need to think about, because the anonymous/verified distinction has real consequences for what your users can see.
There are two kinds of widget visitor:
| Anonymous | Verified | |
|---|---|---|
| Who | Anyone browsing your site | A user logged in to your site |
| You do | Nothing | Sign an identity token on your server |
| They see | Only the chats they started in that browser | Their real, full support history, on any device |
Anonymous visitors (the default)
A visitor who isn't logged in to your site is anonymous, and this works with no effort from you at all.
On their first chat, Assistive creates a fresh customer record for them and gives their browser a persistent token. The widget stores it and reuses it, so the visitor can close the tab, come back tomorrow, and still find their own conversation waiting. You never handle that token — the widget does.
The important consequence, and the one your support team will eventually ask about:
An anonymous visitor sees only the chats they started in that browser.
Not on their phone. Not in a different browser. Not a conversation an agent started for them. Their identity is the browser, so their history is scoped to it. That's not a limitation to work around — it's the honest consequence of not knowing who they are. If you want better, verify them.
A self-typed email never matches an existing customer
An anonymous visitor always gets a brand-new customer record
If an anonymous visitor types an email into the pre-chat form, that email is stored as an attribute on their record — useful context for the agent — but it is never used to match them to an existing customer. They get a new record, every time, no matter what they type.
This is deliberate, and it's protecting you. A self-typed email proves nothing. If the
widget matched on it, then anyone could type [email protected] into your chat box and read
that person's entire support history.
Note that this is the exact inverse of the Public API's identity
resolution, where an email in
the request does match an existing customer. The rule isn't inconsistent — the
difference is who is speaking. On the Public API, the caller is your backend, holding
your key. In the widget, the caller is a browser, and the "email" is a string a stranger
typed into a text box.
To match a visitor to their real record, someone trustworthy has to vouch for them. That's you.
Verified visitors (sign an identity token)
If your users are already logged in to your site, your server can vouch for who they are — and the widget will then show them their real, full support history: every conversation on their customer record, from any device, including ones an agent started for them.
This is the highest-value thing you can add to a widget install, and it costs you one JWT.
Mint the token on your server
Sign a JWT with HS256, using your widget secret key (wsk_…).
| Claim | Required | Meaning |
|---|---|---|
user_id | yes | Your stable ID for the user. This becomes the customer's external_id. |
email | no | The user's email — vouched for by you. |
name | no | Display name. |
exp | no, but set it | Standard expiry. Enforced when present. See below. |
// On your server — an endpoint only a logged-in user can call.
import jwt from 'jsonwebtoken';
app.get('/api/assistive-identity', requireLogin, (req, res) => {
const token = jwt.sign(
{
user_id: req.user.id, // required — your stable ID for this user
email: req.user.email, // optional, and vouched for by you
name: req.user.name, // optional
},
process.env.ASSISTIVE_WIDGET_SECRET_KEY, // wsk_… — server-side only
{ algorithm: 'HS256', expiresIn: '1h' },
);
res.json({ token });
});Never sign the token in the browser
Signing requires the secret key. Signing in the browser means shipping the secret key to the browser — at which point any visitor can mint a token claiming to be any of your users, and read their support history.
Mint on your server, behind your login. Send the finished token to the page, never the key that made it. This is the single mistake this page exists to prevent.
Hand the token to the widget
The token goes in the userJwt field, and it can only be passed through
Assistive.init(). There is no data-user-jwt attribute — the widget reads userJwt
exclusively from the explicit init() argument.
An identified embed must NOT carry data-public-key
This is the trap, and it is silent, so read it twice.
The bundle auto-boots the moment it loads if the script tag has a data-public-key
attribute — and it boots anonymously. It then sets an internal "already started" flag,
and a later Assistive.init({ userJwt }) returns immediately and does nothing. Your JWT
is ignored entirely. No error, no warning.
The symptom is maddening: a widget that works perfectly and treats every logged-in user as a stranger.
So for an identified embed, drop data-public-key from the tag and pass the public key
through init() alongside the token. The attribute and init() are alternatives, not
partners.
<!-- Identified: no data-public-key. Both values go through init(). -->
<script
src="https://ws.asssistive.com/widget.js"
async
onload="Assistive.init({ publicKey: 'wpk_example_M4tQ8vC2xR7d', userJwt: '…' })"
></script>Don't split this into two script tags
It's tempting to load the bundle and then call init() from a separate inline <script>
below it. That races and throws. The bundle is async, so the inline script almost
always runs first, at which point window.Assistive doesn't exist yet.
Keeping the call in onload is what guarantees the bundle has executed before you call
into it.
The only global is window.Assistive, and the only method on it is init. There is no
Assistive.identify() — if you've seen that in an example somewhere, it doesn't exist.
There is no way to refresh a token in place
The config is captured once, at init(). There is no setter, no update(), and no
re-init — the "already started" flag blocks a second init() just as it blocks a late one.
So a fresh token can only be handed over by a full page reload. Plan for that when you choose an expiry.
HS256 is enforced
The server accepts HS256 and nothing else. A token that's unsigned, uses a downgraded
or none algorithm, is expired, or is missing user_id is rejected — and the visitor
falls back to being anonymous. They aren't locked out; they just don't get their history.
A rejected token fails completely silently
The widget sends your token regardless of whether it's valid, the server shrugs and treats the visitor as anonymous, and the widget says nothing at all. There is no error event, no callback, and no console warning for a bad JWT. The widget has no error surface to report it through.
So a broken signing setup does not look broken. It looks like a perfectly functional widget that quietly treats every logged-in user as a stranger — and you'll only notice when someone asks why their history is missing.
Test it deliberately: log in as a real user, open the widget, and confirm you can see a conversation you know is on their record. Don't infer from "the widget works" that identity works; those two facts are independent.
The two things the widget does warn about in the console are a missing public key and a
disabled or misconfigured site (a 401/403 on its config fetch). A bad JWT trips neither,
so an empty console is not evidence your token is fine.
Always set an expiry — and pick one longer than a page visit
The exp claim is optional, and you should always set it. Assistive enforces it when
it's present — but a token minted without one is valid forever.
We recommend about an hour, as in the examples above. Mint the token fresh when your page loads: the cost to you is nothing, and a token that leaks out of a browser log or a shared device stops being useful within the hour instead of never.
Expiry is a page-lifetime decision, because it can't be refreshed
Remember that a token can't be replaced in place.
The widget captures it once at init(), and only a full page reload can hand over a new
one.
So the expiry you choose has to comfortably outlast how long someone keeps your page open. If you set a five-minute expiry, a user who leaves a tab open through a meeting comes back to a widget that has silently demoted them to anonymous — and, per the callout above, nothing will tell them or you.
An hour is a reasonable balance. If your app is one people leave open all day, lean longer rather than trying to be clever about refreshing — there's no mechanism to be clever with.
How a verified visitor is matched
In order:
- Match on
external_id(youruser_idclaim) → use that customer. - Otherwise match on the token-vouched
email→ adopt that existing customer record. - Otherwise → create a new verified customer.
Step 2 is the one worth noticing. Here, matching on email is safe — because your server signed it. It's the same operation the widget refuses to perform for an anonymous visitor's self-typed email; the difference isn't the email, it's who vouched for it.
In practice, step 2 is how your existing customers get adopted the first time they log in.
An agent created their record from an email conversation months ago; the first signed token
you send carries the same address, and that record is claimed and linked to your user_id.
No data migration on your side.
The anonymous → verified merge
Here's the sentence you want: a user who chats as a guest, then signs up, then chats again does not lose their history.
When someone who chatted anonymously later logs in — and arrives still holding their anonymous browser token — Assistive folds the two together. Their anonymous conversations and tickets are reassigned to their verified customer record, their browser is repointed at that identity, and the empty anonymous record is retired.
It happens in a single transaction, and it's best-effort and idempotent: a merge that fails never breaks the request the visitor was making, and it's simply retried on their next one.
So the guest conversation from last Tuesday is just there, in their history, alongside everything else on their record. You wrote no code for that.