# Signed visitor identity

> Your server signs a token, and the chat knows which signed-in visitor is writing.

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

1. Your server signs a token for the signed-in user with the project's identity secret.
2. The page passes it to the chat: `data-identity` on the tag, or `identify(token)`.
3. 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](#fields)). |
| `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:

```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:

```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:

```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:

```html
<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:

```js
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-identity` or `identify(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.

```text
Add signed visitor identity to the Tolkly support chat on this website, so operators see which customer writes.

Project: your-project
Widget endpoint: https://api.tolkly.io
Secret: the environment variable TOLKLY_IDENTITY_SECRET on the site’s server. The owner copies it from Tolkly: «Install» › «Advanced settings» › «Visitor identity» › «Show the secret».

1. Read https://tolkly.io/docs/en/identity.md. If it does not load, this prompt is enough.
2. Find where the site’s server knows the signed-in user (the session, the auth middleware) and the layout that renders the <tolkly-chat> tag. If the site has no <tolkly-chat> tag yet, stop and tell me: the chat is installed first.
3. On the server, sign an HS256 JWT for the signed-in user with the secret from TOLKLY_IDENTITY_SECRET, using the platform’s usual JWT library. Never sign in browser code and never send the secret to the page. If the variable is not set, stop and ask me to set it; never write the secret into the repository.
4. Claims: "sub" is the user’s id as a string (required, up to 200 characters); "name" is the name operators see (up to 120); "email" and "phone" when the site has them; "exp" is the expiry in seconds since the epoch, for example 12 hours ahead. Extra data goes in "fields": {…}, only the keys the project declared (listed under «Fields your site may send» in the same block); anything else is dropped. The payload is readable in the page: send only what the visitor may see.
5. Hand the token to the chat. When the page is rendered for a signed-in visitor, put it on the tag: data-identity="<token>". After a sign-in without a page reload, call identify(token) on the element: await customElements.whenDefined('tolkly-chat'); document.querySelector('tolkly-chat').identify(token). An anonymous visitor gets no data-identity.
6. On sign-out, call document.querySelector('tolkly-chat').reset() and stop rendering data-identity, so the next person at this computer starts afresh.
7. Check it in a browser: sign in on the site, open the chat and write a message. In Tolkly’s inbox, under «All», the conversation’s visitor card «Account on the site» shows the user id, marked «Verified». The browser console has no line "tolkly: the identity token was refused (…)"; if it has one, the word in brackets says why (malformed, algorithm, signature, expired). Then sign out and open the chat again: it is anonymous, with a new conversation. Then list the files you changed.
```

## 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:

```text
tolkly: the identity token was refused (signature)
```

| 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.
