Put chat on your site

Identify signed-in visitors

Tell the chat who is signed in on your site, in a way nobody can fake from the browser. Your server signs the user with the inbox identity secret; Kommio keeps what you signed with the visitor's session, creates or matches their contact the moment they chat, keeps their name and email current, and records every change.

Last updated

Why sign it

Without an identity every visitor is anonymous: the chat is tied to one browser, agents see a placeholder, and history is lost on another device. A signed identity ties the chat to the customer: when they write, the conversation lands on their contact (created at that moment, or matched to the one that already carries their id), so agents see who they are talking to and every past conversation across channels, the customer finds their history on any device, and you can start a chat with them from Visitors online, which lists signed-in visitors only. Signing in by itself adds nothing to Contacts: a customer who loads a page and never chats is kept only as a session.

The signature is what makes it safe. Anyone can type Kommio.identify({ id: 'someone-else' }) into a browser console; only your server, holding the inbox's identity secret, can produce a valid hmac for it. A wrong signature is refused, never downgraded to an anonymous chat.

  1. Get the identity secret

    Settings → Inboxes → your website chat inbox → Install → Identify signed-in users. Create a secret if the inbox has none. It is masked until revealed; revealing and rotating need the inboxes:manage permission and are recorded in the audit log (inbox.identity_secret_revealed, inbox.identity_secret_rotated). Keep it on your server only, like a password, for example in KOMMIO_IDENTITY_SECRET.

  2. Sign the user on your server

    Ready-made code in five languages. It is the same code the Install tab shows, and Kommio's test suite runs each snippet against the published test values.

    kommio-identity.js
    // Node.js — run on your server, never in the browser.
    const crypto = require('crypto');
    
    const SECRET = process.env.KOMMIO_IDENTITY_SECRET; // Settings → Inboxes → Install
    
    function sign(message) {
      return crypto.createHmac('sha256', SECRET).update(message, 'utf8').digest('hex');
    }
    
    // Keys sorted, no spaces, JSON.stringify's escaping and numbers.
    function canonical(attributes) {
      const sorted = {};
      for (const key of Object.keys(attributes).sort()) sorted[key] = attributes[key];
      return JSON.stringify(sorted);
    }
    
    // The identity to pass to Kommio.init({ key, identity }) or Kommio.identify(identity).
    function kommioIdentity(user, attributes) {
      const email = user.email.trim().toLowerCase();
      return {
        id: user.id,
        name: user.name,
        email,
        hmac: sign(user.id),
        email_hmac: sign(user.id + '\n' + email),
        attributes,
        attributes_hmac: sign(user.id + '\n' + canonical(attributes)),
      };
    }
  3. Pass the identity to the page

    Render the identity only for a signed-in user and start the chat with it:

    Your page template
    <script>
      // Rendered by your server for a signed-in user only
      window.kommioIdentity = /* JSON of kommioIdentity(user, attributes) */;
    </script>
    <script src="https://app.kommio.io/widget/widget.js" async
      onload="Kommio.init({ key: 'INBOX_PUBLIC_KEY', identity: window.kommioIdentity })"></script>

    In a single-page app, keep the plain snippet and call Kommio.identify(identity) after your sign-in completes (fetch the identity from your own API), and Kommio.logout() after sign-out. The ready event fires again with identified: true once the sign-in took effect.

The identity object

{
  id: 'cus_9f2k',                 // your user id (required)
  name: 'Maria K. Nowak',         // optional
  email: '[email protected]',     // optional
  hmac: '<hex hmac of the id>',             // required
  email_hmac: '<hex hmac of the email>',    // optional
  attributes: { plan: 'Business', seats: 12, vip: true }, // optional
  attributes_hmac: '<hex hmac of the attributes>', // required with attributes
}

Signature formats

All three signatures are lower-case hex HMAC-SHA256 with the inbox identity secret as the key.

FieldMessage signedNeeded for
hmacidAlways.
email_hmacid + "\n" + email, the email trimmed and lower-casedLetting the email match the contact, so their emails land on the same contact.
attributes_hmacid + "\n" + canonical JSON of attributesSending attributes (unsigned attributes are never accepted).

The canonical JSON of attributes is the object with its keys sorted, no spaces, strings escaped as JSON.stringify does (no < for <, no \/ for /, non-ASCII characters as they are) and numbers written as JSON.stringify writes them (249, 1.5). Send whole numbers as integers, not 12.0, and prefer strings for anything where the exact digits matter. Use plain ASCII keys and avoid U+2028 and U+2029 in values.

The traps the snippets above already handle:

  • Go: json.Marshal escapes <, > and &; use an encoder with SetEscapeHTML(false).
  • PHP: json_encode escapes / and non-ASCII; use JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE.
  • Python: needs separators=(",", ":") and ensure_ascii=False.

attributes is a flat object: at most 30 keys of up to 40 characters, with values that are strings (up to 200 characters), numbers or booleans.

Check your code with the test values

Sign this test user with this test secret. If your hmac, email_hmac and attributes_hmac equal the ones below, your code signs exactly as Kommio checks. The same values are in the Install tab.

InputValue
secretkommio-example-secret
idcus_9f2k
email" [email protected] " (signed as [email protected])
attributes{"plan":"Business","seats":12,"vip":true,"mrr":49.9,"company":"Nowak & Sons / Łódź <EU>"}
canonical attributes{"company":"Nowak & Sons / Łódź <EU>","mrr":49.9,"plan":"Business","seats":12,"vip":true}
hmac99eaafbd23484c11bf72f2e60648af76a52a5613923e60e48bd7256bcff8e8e2
email_hmac6c1c4e74440ed621a3d5ac854da98ab3961f0b1b7817bc34ce1171af5065f817
attributes_hmaca0f1189a711fe0c86e01eb1f5429593722c45aad861934edde42ec89353ff0b3
self-check.js
// KOMMIO_IDENTITY_SECRET=kommio-example-secret node self-check.js
const assert = require('assert');
// kommioIdentity from the Node snippet above
const id = kommioIdentity(
  { id: "cus_9f2k", name: "Maria K. Nowak", email: "  [email protected] " },
  {"plan":"Business","seats":12,"vip":true,"mrr":49.9,"company":"Nowak & Sons / Łódź <EU>"},
);
assert.equal(id.hmac, '99eaafbd23484c11bf72f2e60648af76a52a5613923e60e48bd7256bcff8e8e2');
assert.equal(id.email_hmac, '6c1c4e74440ed621a3d5ac854da98ab3961f0b1b7817bc34ce1171af5065f817');
assert.equal(id.attributes_hmac, 'a0f1189a711fe0c86e01eb1f5429593722c45aad861934edde42ec89353ff0b3');

The test attributes are chosen to catch the usual mistakes: unsorted keys, a decimal number, and &, /, < and non-ASCII letters that some JSON encoders escape.

Test a signature with your real secret

Install → Test a signature: paste what your site sends and Kommio checks each signature against the inbox's secret. Each comes back Valid, Invalid, Not given or Cannot check, with what was signed; the expected value is never shown and nothing is stored. The same check is POST /api/inboxes/{id}/identity-secret/test (inboxes:manage) with { user_id, email, attributes, hmac, email_hmac, attributes_hmac }.

When Kommio refuses an identity

SentAnswer
A wrong hmac, email_hmac or attributes_hmac403; never downgraded to an anonymous visitor
email_hmac without email422
attributes without attributes_hmac422
attributes_hmac without attributes422

Rotating the secret replaces it at once. Until your server signs with the new one, signed-in visitors are refused: they see an error, never someone else's chat.

What happens when a visitor signs in

A sign-in is a session; the contact comes with the conversation

A sign-in creates no contact. Kommio keeps what your site signed (the id, name, email and attributes) with the visitor's session: one per visitor and inbox, shared by every browser they sign in from, refreshed when your values change or an hour has passed. The contact is created, or matched to the one that already carries the external id, the moment something needs it, and it is then exactly the contact a sign-in would have made: same name, same email, same attributes, same audit rows.

MomentWhat happens
Your site signs a visitor in and no contact carries the idThe widget's POST /widget/{key}/session answers with a visitor_session_id and no contact_id; contact_name is the name you signed, so the pre-chat form does not ask for it. Nothing appears in Contacts.
Your site signs a visitor in and a contact already carries the id (they wrote before, or an import brought them)The contact is matched; your name, email and attributes are refreshed on it, and the browser's anonymous chat joins it (below).
The visitor sends their first messageThe contact is created with everything you signed, or matched to a contact that has appeared with that id since; the reply carries a token with contact_id. Two browsers of one visitor, or the same visitor signed in to two of your inboxes, writing at the same moment end with exactly one contact.
An agent starts a chat from Visitors onlineThe list shows the visitor by the name you signed, with a visitor_session_id; POST /conversations with it creates or matches the contact in the same transaction as the conversation.
The visitor leaves an email in the offline form before writingThe contact is created or matched, and the address kept as its display email.
A flow started by hand, a satisfaction survey, a follow-up emailThese need a conversation, so the contact exists by then.

While the session has no contact, the widget's socket follows visitor.s.{visitor_session_id}; a conversation an agent starts for the visitor is published there (and on visitor.{contact_id} once the contact exists), and the widget refreshes its session to continue with the contact's token. A token issued before the contact existed keeps working: it resolves to the contact its session has since, so the visitor's other browsers see the conversation at once. Anonymous visitors work the same way and always have: nothing is stored until they write, and their session is the token alone (below).

Retention and erasure

A session holds personal data about someone who may never write, so it is not kept for good: retention.widget_sessions_days (30 by default, under the workspace's retention settings) deletes sessions whose visitor was not seen for that long, whether or not they got a contact in the meantime. Erasing a contact deletes its sessions too, so a token issued before the contact existed stops working with it. A browser holding a swept or erased session's token gets 401 and simply signs in again.

The contact

  • id becomes the contact's external identity. Only visitors with one appear under Visitors online and can be offered a chat, before they write as a session, after as a contact.
  • email with email_hmac also becomes a matching identity of the contact, so their emails land on the same contact, unless another contact already owns that address; then it is left there for an agent to merge.
  • email without email_hmac is shown on the contact ("Email from their site") and never used to find or match a contact. The same goes for an email a visitor types into the pre-chat form: agents see "Possible match" with a one-click merge when a contact they may see owns that address, so a person decides.
  • attributes are stored on the contact as signed customer attributes (customer_attributes in the API), apart from the custom attributes agents edit, and shown to agents in the conversation's Customer card as verified. A signed session that carries attributes replaces them; one without leaves them alone. Until the contact exists they wait with the session and are applied when it is created.

Refresh: your site's name and email win

On every signed session, when the name or email differs from Kommio's, your site's values overwrite them: your system is the source of truth for the fields it sends. Nothing else is touched; company, notes, tags and custom attributes set by agents stay. Before the contact exists the refresh lands on the session, and the contact is created with the latest values.

Merge: the anonymous chat joins the customer

The widget sends the browser's stored session with the identity. If that session belongs to an anonymous contact (known only by its widget session):

  • the identity already has a contact: the anonymous contact is merged into it (the same merge as POST /contacts/{id}/merge), so its conversations move and the chat continues as the signed-in customer;
  • the identity has no contact yet: the anonymous contact simply receives the external id and keeps its conversations. It exists because the visitor wrote, so no new contact is made here either.

A previous contact that is itself known (an email, a phone or another external id, for example a different user who signed in on the same browser) is never merged; the session just switches.

Audit trail

Every overwrite and merge writes an audit row: actor widget_identity, action contact.identity_refresh or contact.merged_at_sign_in, with the field, the old and new value and the inbox. A signed attribute that is added, changed or removed is recorded as the field attributes.<key>. Rows show under Settings → Audit to people with audit:read, and on the contact's Changes tab to anyone who may see the contact (GET /contacts/{id}/changes). The usual events fire: contact.updated, contact.identity_added, contact.merged.

Anonymous visitors

A visitor who is not signed in gets a chat session that the loader keeps in your site's local storage. Nothing is stored in Kommio until they write, not even a session row: their first message creates the contact, named from the pre-chat form if the inbox asks for a name. The random session token in the browser is the only thing that connects an anonymous visitor to their earlier chat: a visitor who clears their storage or switches browser starts a new one. Kommio does not use browser fingerprints; the setting that once allowed them was removed on 26 September 2026, because two visitors on identical devices could share a fingerprint.

Try it without writing code

The demo store (Install tab → Open the demo store) has a "Try the widget" panel that signs in the inbox's test customer, "Jordan Demo" (kommio-demo-customer, with a signed email and attributes). It needs an identity secret on the inbox and a Kommio session in the same browser with inboxes:manage.

Every claim on these pages is covered by a test in the repository, and the API reference is generated from the same contract every server serves at /api/openapi. Corrections to [email protected].