Identity verification

Sign visitor identities on your server so nobody can claim to be your customers.

A visitor opens the browser console on your website and types:

window.Assistify.identify({ email: '[email protected]' });

Anyone can claim to be John. This is why we use a no-trust approach: an unverified identify is recorded against a fresh, isolated contact, and it never attaches to the real John or surfaces his conversation history.

The catch: an unsigned claim from your genuinely signed-in customer looks exactly like the impostor's. There is no difference, so without a signature the backend must treat both the same way, and your real customer starts isolated too, reattaching their history only after proving the email through the magic link in the chat.

Identity verification spares your logged-in users that step. Your server signs the identity with a secret only it knows -> the widget sends the signature -> the backend recomputes it and trusts the identity only if it matches. A trusted identity attaches the session to the real contact at once (full history, across devices), while a forged signature is recorded as unverified and stays isolated.

Skip it and conversations still work: identities recorded through identify() or the in-chat form are stored as unverified, and unsigned boot-time identity is ignored entirely (the session starts anonymous). Set up signing as soon as your website has signed-in visitors and that you have access to the backend of course.

The anchor

The signature covers one field, picked in this order:

  1. email
  2. externalId
  3. discordId

The first non-empty field wins.

The algorithm

userHash = HMAC-SHA256(identitySecret, anchor)

64 lowercase hex characters with no prefix and no base64.

Your HMAC secret

The secret is in the dashboard under Widget → Settings → Credentials → Identity secret, with the rotate action on the same card. Keep it on your server: anyone who holds it can sign identities your backend will trust.

Where to pass the signature

PathSignature carrier
Script tag, at bootdata-user-hash attribute
React, at bootidentity.userHash on <AssistifyScript>
CMS-friendly, at bootwindow.ASSISTIFY_USER.userHash
SDK, at bootidentity.userHash in mount({ identity })
After bootwidget.user.identify({ email, userHash }). Listen for identified to confirm.

The anchor field (email, externalId, or discordId) is required alongside userHash, the hash is the proof, not the identifier.

A visitor verified once is never silently downgraded, so you can mix these paths without losing verification.

Identifying after the chat already started

If a visitor began an anonymous conversation (typed in an email through the in-chat form, sent a few messages) and then signs in to your website, calling widget.user.identify({ email, userHash }) does both:

  1. Records the visitor as HMAC-verified against the matching contact.
  2. Moves the anonymous transcript onto that contact. Same conversations, messages etc... now attached to the verified identity.

Listen for identified:

widget.events.on('identified', ({ verified, merged }) => {
  // verified: true  -> userHash matched the tenant secret
  // merged:   true  -> the session was attached to an existing contact
});

When an email recorded as unverified is proved later (the visitor clicks the magic link or completes Discord OAuth), the widget emits verified with { verifiedVia, merged }.

To switch between distinct visitors on a shared device, call widget.reset() before identifying as the new user. See Lifecycle.

Code

Each snippet reads the secret from the environment and picks the anchor in the documented order.

const crypto = require('crypto');
 
const anchor = user.email || user.externalId || user.discordId;
 
const userHash = crypto
  .createHmac('sha256', process.env.ASSISTIFY_IDENTITY_SECRET)
  .update(anchor)
  .digest('hex');

Check your output

Run your implementation once with these fixed inputs and compare:

anchor:   [email protected]
secret:   8f4f0c2ab19e44deb3a17c2a9c5e6f01d7b8a3549c0e12fd6b80a4de93715c2e
expected: eb6e97b033bcbfed50ae1dce919e2663acd04bf9abb29bc492830385b4206c59

A different output means one of the usual mistakes: plain SHA-256 instead of HMAC-SHA256, base64 output instead of hex, the secret hex-decoded into bytes instead of used as the literal string, or a trailing newline read along with the secret.

Rotating the secret

There's one secret per tenant and no overlap window, so rotation is a hard cutover: the instant you rotate, signatures made with the old secret stop matching.

  1. Rotate in the dashboard. This generates a new secret and reveals it; the old one is invalid immediately.
  2. Copy the new value to your signing servers and redeploy.

Between the two steps, signatures don't match, so visitors signing in are recorded as unverified until your servers run the new value. Keep the window short by staging the deploy before you rotate. Contacts already verified aren't downgraded; only verification attempted during the gap is affected.

Troubleshooting

Visitor still anonymous. The widget logs a console warning when boot identity arrives without a userHash. Inspect POST /api/widget/boot in the network tab: if userHash is missing from the payload, your templating engine stripped the attribute. Check the spelling: data-user-hash.

Signature there but visitor unverified. You signed the wrong field. The anchor order is email || externalId || discordId. Sign the one that wins.

Worked yesterday, broken today. The secret was rotated. Pull the new value and redeploy.

Use HMAC-SHA256. A plain SHA-256 or HMAC-SHA1 digest never matches the expected value, so the identity is recorded as unverified (or discarded at boot).