User Identification
The Lovina Chat SDK supports both anonymous and identified users. This guide explains how each works and when to use them.
Anonymous Users (Default)
When no user is identified, the widget creates an anonymous session:
- A session token is auto-generated on the first message
- The token is saved to
localStoragefor session persistence - On page reload, the conversation resumes via the stored token
- The user appears as "Guest" in the CRM dashboard
- No personal data is collected
// No setUser() call needed — widget works out of the box
window.lovinaChatSDK.run({
websiteToken: 'YOUR_TOKEN',
baseUrl: 'https://chat.lovina.app',
});
- Public-facing websites where visitors aren't logged in
- Landing pages, marketing sites, help centers
- Quick support without requiring sign-in
Identified Users
When your app knows who the user is (e.g., after login), call setUser() to link the chat session to their identity:
Web
window.$lovina.setUser('user-123', {
name: 'Jane Doe',
email: 'jane@example.com',
avatarUrl: 'https://example.com/jane.jpg',
phoneNumber: '+628123456789',
customAttributes: {
company: 'Acme Inc',
plan: 'pro',
},
});
Android
val widget = LovinaChatWidget(this)
widget.configure(LovinaChatConfig(
websiteToken = "YOUR_TOKEN",
baseUrl = "https://chat.lovina.app",
user = LovinaChatUser(
identifier = "user-123",
name = "Jane Doe",
email = "jane@example.com",
)
))
iOS
let config = LovinaChatConfig(
websiteToken: "YOUR_TOKEN",
baseUrl: "https://chat.lovina.app",
user: LovinaChatUser(
identifier: "user-123",
name: "Jane Doe",
email: "jane@example.com"
)
)
React Native
<LovinaChatWidget
config={{
websiteToken: 'YOUR_TOKEN',
baseUrl: 'https://chat.lovina.app',
user: {
identifier: 'user-123',
name: 'Jane Doe',
email: 'jane@example.com',
},
}}
/>
Flutter
LovinaChatWidget(
config: LovinaChatConfig(
websiteToken: 'YOUR_TOKEN',
baseUrl: 'https://chat.lovina.app',
user: LovinaChatUser(
identifier: 'user-123',
name: 'Jane Doe',
email: 'jane@example.com',
),
),
)
User Data Fields
| Field | Type | Required | Description |
|---|---|---|---|
identifier | string | Yes | Unique user ID from your system |
name | string | No | Display name shown to agents |
email | string | No | Email address |
avatarUrl | string | No | Profile image URL |
phoneNumber | string | No | Phone number |
companyName | string | No | Company or organization name |
customAttributes | object | No | Any extra metadata (plan, tier, etc.) |
Note:
companyNameis stored on the contact record but isn't currently surfaced in the CRM dashboard — onlyname,avatarUrl,phoneNumber, andcustomAttributesreach agents today.
What Agents See
| Mode | CRM Dashboard Shows |
|---|---|
| Anonymous | "Guest User" — no name, email, or history across sessions |
| Identified | User's name, email, avatar, custom attributes, full conversation history |
Identity Verification
To cryptographically verify that a session belongs to the claimed user, pass the identity config option alongside setUser().
The identity Option
Pass a server-signed identity object alongside your other config -- on web, to run() rather than lovinaSettings, since it's a signed credential rather than a display setting:
window.lovinaChatSDK.run({
baseUrl: 'https://chat.ailovina.com',
websiteToken: 'YOUR_TOKEN',
identity: {
mode: 'user',
userId: 'user-123',
exp: 1735689600,
sigHash: 'server-computed-hmac-hash',
},
});
| Option | Type | Description |
|---|---|---|
mode | 'user' | Identity mode. Only 'user' is available today. |
userId | string | The same user ID you pass to setUser(). |
exp | number | Unix timestamp (seconds) after which sigHash expires. |
sigHash | string | HMAC signature computed on your server -- never in the browser or app. |
Enforcement Modes
Every channel has an enforcement mode that decides what Lovina does with identity. The mode is set on the channel behind your website token, so it applies to every SDK that uses that token -- web, React Native, iOS, Flutter, and Android alike, not only the web widget:
| Mode | What Lovina does | What your visitors see |
|---|---|---|
off | Ignores identity. | Chat works with or without identity. |
monitor | Verifies identity and records the result on the session, but never rejects. | Every visitor's messages send successfully -- signed or unsigned, valid signature or not. The verification result is only recorded server-side, so nothing about it is visible to the visitor. Use this mode to confirm your signatures verify before enforcing them. |
required | Verifies identity and rejects a missing or invalid one with 401 when the session is created. | Users with a valid signature chat normally. Anyone else can open the chat, but their first message fails to send. Existing sessions that were never verified can no longer be resumed: a visitor whose session was created before signing was verified starts a new conversation on their first visit after the switch and loses the earlier transcript, even if their new signature is valid. |
Omitting identity never blocks a visitor on off or monitor -- only required turns a missing or invalid signature into a failed conversation. Treat monitor as a dress rehearsal in which every session should already be signed, not as a mode to leave sessions unsigned in.
monitor and required need a signing secret on the channel, so ask your Lovina contact to set one up and to move your channel to monitor first and then to required.
A mode or secret change can take up to 30 seconds to apply.
When Verification Happens
Lovina verifies identity once, when the session is created -- that is, on the visitor's first message (or first attachment upload), not when the widget loads. Later messages in that session are not re-checked, and resuming the session on a later page load does not need a new signature.
This is why a short exp is safe: once the session exists, the signature expiring does not interrupt the conversation. exp only has to still be valid when the visitor sends their first message, so choose a lifetime that covers the time between page load and that message (for example, one hour), and sign a fresh identity on every page load.
Signature Rules
| Rule | Value |
|---|---|
| Signed message | <exp>:<userId>, exactly -- exp first. |
| Algorithm | HMAC-SHA256 with your channel's signing secret. |
sigHash format | Hex-encoded digest, 64 characters (not base64). |
exp unit | Unix time in seconds, not milliseconds -- Math.floor(Date.now() / 1000), not Date.now(). |
Latest exp | At most 24 hours after the session is created. |
| Expiry tolerance | Accepted until 60 seconds after exp has passed. |
userId | Non-empty, and must not contain a colon (:). |
Fail Closed When Signing Fails
A broken identity does not stop the widget. If the object is malformed -- for example, exp arrives as a string -- the SDK logs a console warning, drops it, and boots anonymously as if nothing were wrong. On a required channel the failure only shows up when the visitor's first message is rejected.
That console warning is a developer-experience aid only. The SDK never decides access: it forwards what it accepts, and the server alone verifies the signature and grants or refuses the session. So booting anonymously on a required channel is a failure state, not a supported fallback -- treat it as an outage of your chat, not as degraded-but-working behavior.
So fetch the signed identity from your own backend before starting the widget, and keep two cases apart. A visitor who is signed in but whose signature could not be minted is the failure case: on a monitor or required channel, do not call run() at all -- show your own "chat unavailable" state instead. A visitor with no signed-in user has nothing to sign -- on an off or monitor channel, boot the widget without identity and anonymous chat works normally, but on a required channel there is no anonymous chat, so don't boot for a visitor you cannot sign for: hide the launcher, or show your own "sign in to chat" state.
async function startLovinaChat() {
const config = {
baseUrl: 'https://chat.ailovina.com',
websiteToken: 'YOUR_TOKEN',
};
// Set to true and deploy this BEFORE asking Lovina to switch the channel to `required`.
const channelRequiresIdentity = false;
const bootAnonymously = () => {
if (channelRequiresIdentity) return;
window.lovinaChatSDK.run(config);
};
let identity;
try {
const res = await fetch('/api/lovina-identity', { credentials: 'include' });
if (!res.ok) throw new Error(`identity endpoint returned ${res.status}`);
identity = await res.json();
if (identity === null) return bootAnonymously();
if (
identity.mode !== 'user' ||
typeof identity.userId !== 'string' || identity.userId === '' ||
typeof identity.sigHash !== 'string' || identity.sigHash === '' ||
!Number.isFinite(identity.exp)
) {
throw new Error('identity endpoint returned a malformed identity');
}
} catch (err) {
// Booting now would look fine until the visitor's first message fails on a `required` channel.
console.error('Lovina chat not started:', err);
return;
}
window.lovinaChatSDK.run({ ...config, identity });
}
Call startLovinaChat() where the installation snippet calls run() (inside g.onload for the script tag). The null body is just this example's logged-out signal -- use whatever your own identity endpoint returns for a visitor with no signed-in user.
Each platform's LovinaChatConfig accepts the same fields in its native form -- see the Web, React Native, iOS, Flutter, and Android configuration references.
Generate the Hash (Server-Side)
Compute sigHash on your server, HMAC-SHA256 over exp:userId using your channel's signing secret. To verify your implementation against a known answer, see Test Vector.
- Java
- Node.js
- Python
- PHP
- Ruby
- Go
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec("YOUR_SIGNING_SECRET".getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] hash = mac.doFinal((exp + ":" + userId).getBytes(StandardCharsets.UTF_8));
String sigHash = HexFormat.of().formatHex(hash);
const crypto = require('crypto');
const sigHash = crypto
.createHmac('sha256', 'YOUR_SIGNING_SECRET')
.update(`${exp}:${userId}`)
.digest('hex');
import hmac
import hashlib
sig_hash = hmac.new(
b'YOUR_SIGNING_SECRET',
f'{exp}:{user_id}'.encode(),
hashlib.sha256,
).hexdigest()
$sigHash = hash_hmac('sha256', "$exp:$userId", 'YOUR_SIGNING_SECRET');
require 'openssl'
sig_hash = OpenSSL::HMAC.hexdigest('SHA256', 'YOUR_SIGNING_SECRET', "#{exp}:#{user_id}")
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
)
mac := hmac.New(sha256.New, []byte("YOUR_SIGNING_SECRET"))
mac.Write([]byte(fmt.Sprintf("%d:%s", exp, userID)))
sigHash := hex.EncodeToString(mac.Sum(nil))
Never compute sigHash in client-side code -- your signing secret must stay on your server.
Test Vector
Production exp is usually derived from the current time, so live hashes are hard to compare. Use these fixed inputs as an answer key: if your server code produces the expected sigHash, your canonical string, HMAC, and hex encoding are correct.
| Input | Value |
|---|---|
secret | lovina-dev-secret |
userId | u_42 |
exp | 1735689600 |
| Output | Value |
|---|---|
sigHash | f79ad2982298238635062d48b08aacb49b580ceac7f3b074cadb4538a52c3ab1 |
The signed message is exactly 1735689600:u_42 — order is exp:userId, not reversed. Reproduce independently with:
printf '%s' '1735689600:u_42' | openssl dgst -sha256 -hmac 'lovina-dev-secret'
lovina-dev-secret is a public fixture for documentation and local verification — not a real channel signing secret. Use your channel's secret in production; never ship the fixture as a live credential.
Rotating the Signing Secret
Rotating creates a new signing secret. The previous secret keeps verifying for 72 hours, so during that window signatures made with either secret are accepted. Deploy the new secret to every backend that signs identity before the window ends; Lovina tells you the exact UTC time the old secret stops being accepted. After that deadline, signatures made with the old secret fail: new sessions are rejected on a required channel and recorded as failed on a monitor channel.
Sessions created before the deadline are unaffected, because verification happens only once. Only one previous secret is kept -- rotating again during the window ends the older secret's overlap immediately.
Debugging a Rejected Session
The 401 never says why. A missing identity, the wrong secret, an expired or too-distant exp, and a malformed userId all return the same 401 with the same generic "identity verification failed" message. No further detail is sent, by design, so don't look for it -- check your inputs against Signature Rules and the Test Vector instead.
Check the console first. Alongside the malformed-identity warning above, the SDK logs a warning when userId contains a colon or sigHash is not 64 hex characters (for example, base64). It still forwards those values, and the server rejects them. These warnings are diagnostics for you, not validation -- a clean console proves nothing about access, because only the server decides whether a session is verified.
baseUrl is not the API host. baseUrl (for example https://chat.ailovina.com) is where the widget is served from. The API host the widget calls is built into the widget at build time and is a different host, so a curl against baseUrl tests the wrong server. Read the real host from your browser's DevTools Network tab -- the widget's requests to paths starting with /sdk/chat/.
Updating User Data
You can call setUser() multiple times — subsequent calls update the user's profile:
// Initial identification
window.$lovina.setUser('user-123', { name: 'Jane' });
// Later: update with more data
window.$lovina.setUser('user-123', {
name: 'Jane Doe',
email: 'jane@example.com',
customAttributes: { plan: 'enterprise' },
});
Resetting Identity
To log out the user and start a fresh anonymous session:
window.$lovina.reset();
On native SDKs:
// Android
widget.clearSession()
// iOS
widget.clearSession()
This clears the stored session token and conversation history. The next interaction starts as a new anonymous user.