For developers
Signed visitor identity
Your server signs a token, and the chat knows which signed-in visitor is writing.
On this page
If visitors sign in on your site, your server can tell the chat who is writing. Then:
- operators see the account and the fields you send;
- the visitor finds their conversation on any device and on any of the project's domains.
Without a signature, the browser keeps a conversation per site. On a new device or mirror, the visitor starts afresh.
How it works
- Your server signs a token for the signed-in user with the project's identity secret.
- The page passes it to the chat:
data-identityon the tag, oridentify(token). - The widget sends it to
POST https://api.tolkly.io/v1/projects/<project>/identify. The server checks it and says which conversation is this visitor's: the one in this browser or, if this browser has none, their latest open one.
The widget checks nothing itself. If the server refuses a token, no part of it is trusted: the chat goes on as an anonymous one.
The token
An HS256 JWT. The server always reads the algorithm as HS256, whatever the header says. A header
naming any other algorithm, none included, is refused.
| Claim | Required | What it is |
|---|---|---|
sub | yes | Your user id, a string (a number is taken as one), up to 200 characters. A longer one refuses the token. |
name | no | The name operators see, up to 120 characters. |
email | no | Dropped if it doesn't look like an email. |
phone | no | Dropped if it doesn't look like a phone number. |
fields | no | An object of the fields the project declared (below). |
exp | no | Expiry, in seconds since the epoch. Without it, a token works as long as the secret does. |
exp is checked with 60 seconds of slack, so a slightly slow clock doesn't refuse a fresh token.
A token longer than 8192 characters is refused unread.
The payload is signed, not encrypted: the visitor can read it in the page. Send only what the visitor may see.
Fields
Fields are the project's own: an account number, a KYC status, a VIP level, a link to the user in
your admin. The project declares each field with a key and a type. Anything else in fields is
dropped, and so is a value of the wrong type. Owners and admins of the company declare them.
| Type | Takes |
|---|---|
text | a string up to 500 characters (a number is taken as text) |
number | a number, or a string that is one |
date | YYYY-MM-DD or a full ISO 8601 time |
list | an array of strings or numbers, up to 100 characters each; the first 20 are kept. A single value counts as one item. If any item is empty, too long or of another type, the whole list is dropped. |
link | an http: or https: URL, up to 2000 characters |
boolean | true or false (also "true", "false", 1, 0) |
Fields are declared in the project's «Install» section, under «Advanced settings», in the block «Visitor fields»: a title operators see, the key your server writes, the type, and whether operators see the value. A key stays once made: your server already sends it, so a new key is a new field. Turn «Show to operators» off for a value kept for rules only, such as a risk score.
The secret
Owners and admins of the company find it in the project's «Install» section: «Advanced settings» › block «Visitor identity» › «Show the secret».
Keep it on your site's server, in an environment variable such as TOLKLY_IDENTITY_SECRET. Never
put it in browser code or a page: whoever has it can sign in as any of your customers.
Sign on your server
Node.js:
// npm install jsonwebtoken
import jwt from 'jsonwebtoken'
const token = jwt.sign(
{
sub: String(user.id),
name: user.name,
email: user.email,
},
process.env.TOLKLY_IDENTITY_SECRET,
{ algorithm: 'HS256', expiresIn: '12h' },
)PHP:
// composer require firebase/php-jwt
use Firebase\JWT\JWT;
$token = JWT::encode([
'sub' => (string) $user->id,
'name' => $user->name,
'email' => $user->email,
'exp' => time() + 12 * 3600,
], getenv('TOLKLY_IDENTITY_SECRET'), 'HS256');Python:
# pip install pyjwt
import os, time, jwt
token = jwt.encode({
"sub": str(user.id),
"name": user.name,
"email": user.email,
"exp": int(time.time()) + 12 * 3600,
}, os.environ["TOLKLY_IDENTITY_SECRET"], algorithm="HS256")Add fields next to the other claims, for example fields: { vip: user.vip }.
Pass the token to the chat
If the page is rendered for a signed-in user, put the token on the tag:
<tolkly-chat endpoint="https://api.tolkly.io" project="your-project"
data-identity="<token>"></tolkly-chat>If the user signs in without a page reload, pass the token from a script:
await customElements.whenDefined('tolkly-chat')
document.querySelector('tolkly-chat').identify(token)The element also watches data-identity: change the attribute and the chat names the visitor
again.
On sign-out, call reset() and stop rendering data-identity. The chat forgets the visitor and
the conversation kept in this browser, so the next person at this computer starts afresh. If the
browser holds another signed visitor's conversation, the server gives it a fresh one too.
Hand it to an AI agent
Owners and admins get «Hand it to an AI agent» in the project's «Install» › «Advanced settings», block «Visitor identity». It gives the prompt below, with the project filled in, for the coding agent that edits the site (Claude Code, Cursor). With it, the agent:
- signs on the server with the secret from
TOLKLY_IDENTITY_SECRET; - passes the token by
data-identityoridentify(token); - calls
reset()on sign-out; - checks the result in the inbox's visitor card.
The prompt names the environment variable, never the secret. Here it has your-project in place
of the project's id. «Open in…» → «Prompt for an AI agent» copies it.
Without a signature
identify({ id, name, email, phone, fields }) names the visitor without a token. Operators see it
marked «not verified». It stays on that conversation only. It never joins a history across
devices and never takes over a signed visitor's conversation.
A refused token
The chat goes on as an anonymous one and logs the reason in the browser console:
| Code | Why |
|---|---|
malformed | Not three base64url parts of JSON, no sub or one over 200 characters, an exp that is not a number, or longer than 8192 characters. |
algorithm | The header names an algorithm other than HS256. |
signature | Signed with another secret: an old one, or another project's. |
expired | exp has passed, beyond the allowed clock skew. |
Rotate the secret
Press «Issue a new secret» in the app (owners and admins only). The old secret stops working at once. Tokens
signed with it are refused with signature until your server signs with the new one. So rotate
the secret together with your deploy.