Why use it
Without identity verification, everything the widget knows about a visitor comes from the visitor. Anyone can type someone else’s email address into a conversation, and a visitor ID in a browser proves nothing about who is typing. Keloa’s AI therefore never uses a contact’s email address to look up customer data on an anonymous website conversation, whatever address the contact has. The store sections in your team’s inbox are different: see What your team sees. With identity verification:- Nobody can chat as one of your users. Keloa only accepts identity that your backend signed with a secret only you and Keloa know. There is no unsigned way to identify a visitor.
- Your team knows who they’re talking to. The context drawer shows the user ID, the profile and any extra attributes your site sends.
- Conversations follow the user. A signed-in user sees their own conversations in the widget on every browser and device.
- The AI can use the verified email. Tools that look up contact data by email (Shopify orders, and TRONVoice invoices, quotes and subscriptions) use it on that user’s own conversations, and a TRONVoice link your team made from that user’s conversation also counts for email written from that verified address.
How it works
- A user signs in to your site or app.
- Your server creates a token for that user: a JSON Web Token (JWT) with their user ID and profile, signed with HS256 and your secret.
- Your page passes the token to the widget with
Keloa('identify', { getToken }): a function that fetches it from your own endpoint. The token is never part of the page’s HTML. - The widget sends the token with every request. Keloa verifies the signature, the expiry and the audience, then links the conversation to that user’s contact.
- When the token expires or Keloa rejects it, the widget calls
getToken()for a fresh one. - When the user signs out, your page calls
Keloa('logout'), the widget forgets them in that browser, and your other open tabs reload.
Before you start
- A backend with signed-in users. The token must be signed on your server. A static site or a page without accounts can’t use identity verification.
- HTTPS. With identity verification on, only https pages of your allowed sites, on the standard https port (443), can show the widget. For development,
localhost,127.0.0.1,*.testand*.localhostalso work over http and on any port. - The right role. Only workspace owners and admins can create or revoke secrets and change the mode. See Members & roles.
- The widget installed. Your site already loads the embed snippet. See Web widget.
Set it up in Keloa
Open the agent's web widget settings
Add your site under Allowed origins
https://, a port or a path. Changes save automatically.app.acme.commatches exactly that host.acme.comdoes not coverwww.acme.com; add both if you need both.*.acme.commatches every subdomain ofacme.com, but notacme.comitself.- Only add a wildcard for a domain you control. Keloa refuses wildcards over public suffixes (from the Public Suffix List) and over known shared-hosting platforms, such as
*.com,*.co.uk,*.vercel.app,*.github.ioor*.myshopify.com. Keloa can’t know every hosting platform, so a wildcard it accepts isn’t proof that you control every site under it. For a hosted site, add its exact address (acme.vercel.app). - A host typed with non-ASCII letters is saved in its punycode form, which is what browsers send:
*.bücher.debecomes*.xn--bcher-kva.de.
Create a secret
- Key ID (kid) — public. It goes in the token header so Keloa knows which secret you used.
- Secret — private. It starts with
kis_. Your server uses it, exactly as shown, as the HMAC key.
Sign tokens and pass them to the widget
POST, protect it with your framework’s CSRF check and return the token as JSON ({"token": "…"}). If your endpoint returns another format, make sure getToken() reads that format and resolves to the token string.Choose who can chat
Sign the token on your server
The token is a compact JWT signed with HMAC-SHA256 (HS256). Every sample below does the same thing:
- Build the header:
algHS256,typJWTandkidset to your key ID. - Build the claims:
user_id,aud(your agent ID),iat,expand the profile fields you want to share. - Encode the header and the claims as JSON, then base64url without padding (no trailing
=). - Sign
<header>.<claims>with your secret and append the base64url signature.
agent value in your embed snippet’s loader URL. Keep the secret, key ID and agent ID in configuration, never in code. Give tokens a short lifetime: 15 to 60 minutes is a good range, and Keloa refuses anything over 24 hours. Never let a token outlive the user’s session on your site.
kis_ prefix. Don’t hex-decode or base64-decode it before signing.When users change their email
email_verified must be false for an address the user hasn’t confirmed, including a new address right after they change it. The Laravel sample above signs true only when your User model implements Illuminate\Contracts\Auth\MustVerifyEmail and email_verified_at is set, so it is only correct if your app clears email_verified_at whenever the email changes. Whether it does depends on your own code:
- Breeze’s
ProfileControllerand the current Laravel starter kits clear it on every email change. - Fortify’s and Jetstream’s
UpdateUserProfileInformationaction only clears it when yourUserimplementsMustVerifyEmail. Jetstream’s defaultUserdoesn’t. - Custom profile forms, admin tools, imports, seeders and social sign-in code can set
email_verified_ator leave it in place.
email_verified_at is empty until they click the confirmation link sent to the new address. In any framework, also require the user’s current password to change their email. Otherwise anyone at a signed-in browser can switch the account to an address they control, confirm it and take the account over, including the user’s conversations in the widget.
Add a token endpoint
The widget gets every token from this endpoint: once when a signed-in page loads, and again whenever the current token expires or Keloa rejects it. Give it an endpoint on your own site that:- only answers a signed-in session, and answers
401(not a redirect to your login page) otherwise; - accepts
POSTwith your framework’s CSRF protection, so only your own pages can call it; - sends no CORS headers, and stays out of any CORS configuration that reflects the request’s origin with credentials;
- is rate limited, for example to 30 requests per minute per user;
- returns the token as JSON (
{"token": "…"}) withCache-Control: no-store, private; - doesn’t keep an idle session alive: while a signed-in chat is open, the widget calls it in the background whenever its token expires, so these calls must not count as user activity or extend the session, and the endpoint answers
401once the user has been idle longer than your timeout.
401 once that time is older than your timeout. In Laravel, also keep this route from saving the session back (its StartSession renews the stored session and its cookie), so a refresh never moves the session’s own expiry either.Hand the token to the widget
Put this on every page where a user is signed in. It can go before or after your embed snippet: the first line is the same small stub your snippet starts with, soKeloa(…) works before the widget has loaded.
csrf-token meta tag, as above (header names are case-insensitive, so X-CSRF-Token works for both). Django reads it from the csrftoken cookie and expects it in the X-CSRFToken header.
When the user signs out
CallKeloa('logout') whenever the user signs out. The widget stops showing the signed-in user’s conversations, clears what it kept in that browser and starts a new anonymous visitor. The conversations stay open in your inbox.
Keloa('logout') only acts in the browser tab that calls it, and it doesn’t invalidate tokens on Keloa’s side: a token your backend already issued stays valid until its exp. So also tell your other open tabs, and let them reload. A reloaded tab is rendered by your server as signed out, so it shows neither the widget’s signed-in conversations nor your own app’s private pages. Put this on every page, signed in or not, and call keloaSignOut() when the user signs out:
keloaSignOut():
- Sign-out with a full page load (a form
POSTand a redirect): call it on the page the user lands on. Flash a value on the redirect, for examplekeloa_logout, and only call it when that value is present. - Single-page apps (React, Vue, Inertia, Livewire
wire:navigate, Turbo): always call it when the user signs out, right after your sign-out request succeeds. The widget lives on across page swaps, so nothing else tells it. Do this even when your sign-out also loads a new page.
getToken() gets 401 and the widget ends the signed-in session. Keep tokens short-lived to bound what a tab that missed the message can do, and revoke the secret if you need to cut off every token at once (see Rotate or revoke a secret).
Laravel Blade partial
A complete partial for a Laravel layout, built on theKeloaIdentityToken class and the endpoint above. It:
- identifies signed-in users with
getTokenalone, so no token is ever part of the HTML; - signs the widget out when the user signs out, and reloads every other open tab that showed a signed-in page;
- reloads a page the browser restores from its back/forward cache after it identified someone;
- loads the widget.
KELOA_AGENT_ID and KELOA_LOADER_URL are set, and the widget stays anonymous if signing fails. The script runs again on every Livewire wire:navigate or Turbo page swap, which is how it notices that the session is gone.
Keloa('logout') there and reloads your other tabs:
wire:navigate sign-out that never flashes is covered too: the partial runs again on the next page, sees that the session is gone and signs out everywhere. An Inertia app renders the partial once, in its root view, and never runs it again on page visits, so call window.__keloaSignOut() from your sign-out handler, right after the sign-out request succeeds.
getToken, which gets 401. To keep your own pages out of the browser’s disk cache, send Cache-Control: no-store, private on pages for signed-in users.Claims reference
Header
Claims
email, name, first_name, last_name, phone, language, company and the attributes):
- Text that is too long is cut to the limit: a name, company or attribute string is shortened, not dropped.
- A value that is invalid, such as an
emailthat isn’t a valid address or is longer than 255 characters, or aphonein the wrong format, is dropped and never stored. The rest of the token still counts. - Control characters and invisible formatting characters in text are replaced with a space, and repeated spaces are collapsed.
nbf that isn’t a number, a typ other than JWT (both malformed) and an aud without this agent’s ID (wrong_audience). Claims nested more than 8 levels deep, for example deeply nested attribute values, also reject the token as malformed.
How Keloa uses the claims
- Contact. The first time Keloa sees a user, it joins an existing contact only when
email_verifiedistrueand the address exactly matches a contact email Keloa already trusts, and that contact doesn’t already belong to another signed-in user or to an anonymous website visitor. Otherwise it creates a new contact. Your team can still merge the two contacts later. - Profile sync. On later visits, Keloa fills empty fields and updates a field when your site’s value changed since the last sync. A change a teammate made stays until your site sends a different value.
- Email. A verified address becomes the contact’s email when the contact has none. It also replaces an address the visitor typed into the conversation (which Keloa never trusted), unless another signed-in user shares the contact, and it replaces the address this user’s own token verified before when your site sends a new verified one. Keloa never replaces an address your team entered or one that arrived by email. An address sent without
email_verified: trueonly fills an empty email, is not trusted and never replaces an address. - Company. A company needs a name. With an
id, users with the sameidshare one company that Keloa created for that ID from this agent. With only a name, Keloa creates a company for that user alone. Keloa never joins a company your team created, or any existing company by name. - Attributes. They appear in the Verified user block in the inbox. A key that matches the key of a contact custom field fills that field.
nullnever clears a value. When several signed-in users share a contact, the AI and macros on a website conversation only use a synced field when that conversation’s own user sent that value.
JavaScript API reference
Every call goes through theKeloa function. Calls made before the widget loads are queued by the stub and run in order once it loads.
identify options
getToken() must resolve to the token string. When it rejects, resolves to nothing or to an object, returns text that isn’t a token (for example your login page), or takes longer than 10 seconds, the widget ends the signed-in session. The widget calls it at most 3 times per minute; if Keloa keeps rejecting the tokens it returns, the widget stops asking and warns in the browser console.
Security model
What Keloa guarantees
- Only signed identity counts. Keloa accepts identity only from a token signed with one of the agent’s active secrets. There is no unsigned identify call;
Keloa('identify')without a token does nothing. - Every request is checked. The token travels with every widget request, and Keloa verifies the signature, expiry and audience each time.
- A rejected token is never downgraded. A token that fails verification is never treated as an anonymous visitor. Keloa answers
401with a reason code and the widget asks your page for a fresh token. - One agent, one set of users. Secrets belong to one agent, and Keloa keys each user on the agent plus the user ID. A token for one agent can never speak for another agent’s users.
- Conversations stay with their owner. A signed-in user’s conversations are only visible to that user. Anonymous visitors can never read them, and an anonymous visitor only reaches the conversations they started in that browser, even after a teammate merges contacts. The one exception is a website conversation from before this release, when Keloa didn’t yet record which browser started a conversation: it is open to every visitor on its contact, so after a contact merge another visitor’s browser can open it, until your team merges it with a newer conversation of the visitor who wrote it, which ties it to that visitor (see Identity matching and merging). A website conversation only merges with conversations of the same visitor or the same signed-in user: Keloa refuses to merge it with anyone else’s conversation or with another channel’s, so another person’s messages, subject and tags never reach it. A website conversation that a merge made before that rule mixed with another person’s or another channel’s conversation is never shown in the widget again, and the AI, emails and flows leave out what Keloa can’t attribute to the visitor (see Website conversations merged before the rule).
- Anonymous visitors are kept apart per workspace. The widget gives each browser a separate random visitor ID for every Keloa workspace, also when several shops share one host (for example
shop-a.example-host.comandshop-b.example-host.com). Keloa never shows that ID to your team: the contact card says Anonymous website visitor, and the inbox and data exports only hold a keyed code that opens nothing. So no team can use what it sees to open a visitor’s conversation with another workspace. Visitors who chatted before this change start a new anonymous conversation once; their earlier conversations stay in your inbox. The ID they used before is retired for good, in every workspace: a browser tab that still runs the widget from before the change can’t start or open a conversation with it, and chats again once the page reloads. - Only your sites can show the widget. With verification on, Keloa tells browsers that only your allowed origins, over https on the standard port, may embed the widget. Another site can’t frame it to push its own user’s token into a visitor’s conversation.
- Secrets are protected. Secrets are stored encrypted and shown only once. Only owners and admins can create or revoke them, and every change is recorded in the audit log. Keloa never logs tokens. Revoking a secret cuts off every token signed with it at once, including live-update connections that are already open (see Rotate or revoke a secret).
- Contact data needs a verified email. AI tools only use an email your site verified on that same user’s conversations. On an anonymous website conversation the AI never uses the contact’s email address for customer data: the Shopify order lookup asks the visitor for the email on the order, and TRONVoice data only comes through a link a teammate made from that same conversation. See Tools.
- Anonymous conversations only use what the visitor said there. On an anonymous website conversation, the AI, flow messages, conditions and action values, macros and the widget’s own messages only use what the visitor said in that conversation, or in their own earlier conversations your team merged into it: a name, email address or phone number they typed in the chat or the contact form.
{{contact.email}}and{{contact.phone}}render empty unless the visitor typed that address or number there. A name, company or language that your team, an email or a merge put on the contact, and every contact and company custom field, your team’s included, count as unknown; the language follows the visitor’s own messages. On a signed-in user’s conversation, the name, company, language and custom fields are the ones your site signed for that user, and the email and phone also show the verified email and the phone your site signed. An AI-drafted email on an anonymous conversation only greets the visitor by a name they gave there. On a conversation from before this release that is still open to every visitor on its contact (see above), what was typed there shows to whoever opens it. - Email replies stay with the visitor. Your team can answer a website conversation by email only at an address shown to be the visitor’s: one they typed in that conversation (in a message or the contact form) or in the contact form of one of their own earlier conversations your team merged into it, the verified email your site signed for that signed-in user, an address whose owner already emailed into that conversation, or an address your team corrected on a contact that wasn’t merged with another contact. Otherwise the reply goes to the chat. An email sent from the conversation only quotes and carries the visitor’s own messages and subject, and only names the visitor by a name they gave. See Replying to conversations. See Messages & actions and Conditions.
What your site must guarantee
Keloa trusts what your backend signs. Keep that trust deserved:- Sign only for authenticated users. Create tokens on your server for the user of the current session, never for an ID the browser sends you.
- Don’t sign for impersonated users. When your staff use a “log in as user” or impersonation feature, don’t sign a token for the impersonated user. Otherwise their messages are stored as that user’s conversations, follow the user to their devices, and unlock the user’s data through the AI.
- Keep the secret secret. Store it in your environment or secrets manager. Never ship it to a browser, a mobile app or a repository. Revoke it at once if it leaks.
- Use a stable user ID. Use an ID that never changes and is never reused for another person. Don’t use the email address as the user ID.
- Be honest about
email_verified. Sendtrueonly when the user proved they own the address, for example with a confirmation link. If users can change their email, sendfalseuntil they confirm the new address, and require their current password for the change (see When users change their email). A wrongtruelets someone take over another person’s contact and data (see the warning under Claims reference). - Keep tokens short-lived. A token is a bearer credential: anyone who holds it can chat as that user until it expires, and a live-update connection opened with it keeps receiving that conversation’s new replies for up to 13 minutes after its
exp(the 60-second clock-skew allowance, plus up to 12 minutes until the connection’s channel moves on). Use 15–60 minutes andgetTokento refresh, and never let a token outlive the user’s session on your site. - Serve your site over HTTPS.
- Protect the token endpoint. Require a session, use
POSTwith CSRF protection, send no CORS headers, rate-limit it and answer401when signed out. - Watch the scripts on signed-in pages. Any script on those pages can call your token endpoint and read the token. Keep third-party tags off them where you can, and protect them against XSS, for example with a Content Security Policy.
- Never put a token in a page. Pass
getTokenonly, nevertoken, so a page restored from the back/forward cache, a single-page framework’s memory, a CDN or a full-page cache can’t sign the widget in again. SendCache-Control: no-store, privateon the token endpoint, so no cache ever keeps its answer. - Keep tokens out of URLs, storage and logs. Don’t put them in query strings,
localStorage, analytics or error reports. - Call
Keloa('logout')on every sign-out, also in single-page apps, and reload your other open tabs (see When the user signs out). Logout only clears the browser tab that calls it; it doesn’t invalidate tokens you already issued.
Rotate or revoke a secret
Each agent can have at most 2 active secrets, so you can rotate without signing anyone out. The second secret exists for rotation only: both secrets sign for the same users, so never use it for another environment (see Staging and development).Create a second secret
Deploy the new secret
kid. Tokens signed with the old secret keep working meanwhile.Wait for old tokens to expire
Revoke the old secret
exp. Keloa accepts a token for 60 seconds after exp (clock skew), and a connection keeps its channel for at most 12 minutes after it was opened.
Signed-in users whose widget is open when you revoke keep chatting once your site hands the widget a token signed with the new secret. Live replies can pause for up to about 10 minutes until the widget renews its connection; they then appear from the conversation history.
If you revoke the last secret:
- in Optional mode, signed-in users chat anonymously until you create a new secret and update your site;
- in Required mode, nobody can chat until you do.
Staging and development
Use a separate AI agent for every environment that isn’t production, such as staging, a preview environment or a developer’s machine. Give it its own secrets and its own allowed origins. A separate workspace for testing is even better: Keloa joins a new signed-in user to an existing contact by verified email across the whole workspace, so test users with real addresses could otherwise land on real contacts. A second secret on your production agent is not a separate environment. Keloa keys each user on the agent and theuser_id, not on the secret that signed the token. Any token signed with any active secret of the production agent is the production user with that user_id, whichever environment signed it. If staging signs a token for its own user 42, that token opens production user 42’s conversations, can write in them, updates their contact and lets the AI use their verified email. Staging databases often copy production users or reuse their IDs, and more people can usually sign in there.
- Never copy a secret of your production agent, or its key ID, to staging or development.
- Never sign a token for a production user from staging or development.
- Don’t add staging or development hosts to your production agent’s Allowed origins. Add them to the separate agent instead.
What your team sees
In the Inbox, a conversation started by a signed-in user shows a Verified user block in the context drawer:- Signed in on your site or app during this chat, with when Keloa last verified the user.
- User ID — the
user_idyour site signed. - Every attribute your site sent. The first 4 are shown; click Show all to see the rest.
- On a signed-in user’s conversation it says Verified: the person is signed in on your site, and a link made there applies to their conversations, and on email only to mail from the address your site verified for them.
- On an anonymous conversation it says Not verified: a link made there applies to that conversation only, and only while that one visitor is the only one writing in it. It never applies to the contact’s email or other conversations, also not after a contact merge. Before linking, Keloa asks you to confirm with Link anyway, because the AI can then share that client’s invoices, quotes and subscriptions in the conversation. The AI stops using the link once the conversation has messages from anyone else: another visitor, messages merged in from another channel or another contact, or any email reply, including one from the address owner. Keloa refuses to link from a conversation that already has such messages, and from a mixed conversation (see Website conversations merged before the rule).
- On a website conversation from before Keloa told website visitors apart, the panel says a link can’t be made there. Keloa refuses the link, and a link made from it earlier no longer gives the AI any data. Link the client from the email conversation instead, or ask the visitor to sign in or start a new chat.
Guests, sign-in, sign-out and devices
Troubleshooting
When Keloa rejects a token, the widget request returns401 with {"error": "identity_invalid", "reason": "<code>"}. Find it in your browser’s Network tab on the request to /widget/v1/identify or /widget/v1/messages.
FAQ
Can I identify users without a backend?
Can I identify users without a backend?
What happened to identify with an email and name only?
What happened to identify with an email and name only?
identify call without a token or getToken is ignored with a console warning. Move the profile fields into a signed token.Can I use RS256 or another algorithm?
Can I use RS256 or another algorithm?
Can I use one secret for several AI agents?
Can I use one secret for several AI agents?
Does a guest's conversation move to their account when they sign in?
Does a guest's conversation move to their account when they sign in?
Is the token itself sensitive?
Is the token itself sensitive?
exp. Revoking the secret cuts both off at once. Keep it short-lived, out of your pages’ HTML, URLs, storage and logs, and never let a cache keep it.How do I test on my own machine?
How do I test on my own machine?
localhost or your *.test host under that agent’s Allowed origins. Point your local .env at that agent’s ID, key ID and secret. Don’t add development hosts to your production agent, and never use a production secret on your machine: any token it signs is the production user with that user_id. Over http, identity verification only works for localhost, 127.0.0.1, *.test and *.localhost. See Staging and development.What happens to verified identities when I delete a contact?
What happens to verified identities when I delete a contact?