Contacts, visitors, and users
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
Contacts, visitors, and users
How Chatly models the people on the other side of every conversation — and how identity stitches anonymous visitors into a single contact record.
There are three closely related concepts in Chatly's identity model: visitor, contact, and user. Confusing them is the most common source of "why didn't this identify call do what I expected" bugs, so it's worth understanding the distinctions clearly.
Info — Cheat sheet
Visitor: anonymous end-user, browser-scoped.
Contact: identified end-user, cross-channel.
User: a member of your team — an agent or admin.
Visitor (anonymous end-user)
A visitor is an end-user we don't know yet. On the widget's first init the server mints a visitor key — vt_<uuid> — and returns it wrapped in a signed session token, which the widget stores in the browser and sends on every subsequent request. The same key sticks across visits from the same browser, so a returning anonymous visitor is still recognized as the same person.
Parameters
Name | Type | Description |
|---|---|---|
|
|
|
|
| An HMAC-signed envelope ( |
|
| A token is minted for ONE channel and is inert on any other, so a leaked token does not travel. |
|
| Different browsers / devices / incognito sessions are different visitors. |
|
| None of these — visitors are anonymous by definition. |
|
| Events fire and are stored against the visitor. They follow into the contact on identify. |
|
| Treated as personal data under GDPR even without an email, because of IP + behavioral data. |
Info — A visitor is already a contact row
The visitor key IS
contacts.external_id. An anonymous visitor is a contact whoseexternal_idhappens to bevt_<uuid>— which is why "stitching" on identify is an update rather than a migration between two different kinds of record.
Contact (identified end-user)
A contact is a person you've identified. As soon as you call:
Chatly('identify', { externalId: 'u_42', email: '[email protected]', name: 'Jamie' });…Chatly upserts a contact keyed by (workspaceId, externalId). The previous anonymous visitor's conversations, messages, and CDP events are stitched to the same contact row, so their history follows them across browsers and devices.
Contact fields
Name | Type | Description |
|---|---|---|
|
| Your stable identifier. Always set this if you have one. The merge key Chatly uses to stitch visitors → contacts. |
|
| Secondary stitching key. If two contacts have the same email, we suggest merging (configurable to auto-merge). |
|
| Used for SMS/voice channel attribution. |
|
| Display only. |
|
| Drives UI language + business-hours / scheduling decisions. |
|
| Arbitrary JSON. Used by triggers + audiences. Keep keys snake_case, values primitive. |
|
| For filtering, segmentation, and audience membership. Written through |
|
| Contact-level outbound opt-out — "do not contact this person again". Set through |
|
| INBOUND suppression, distinct from unsubscribe. A blocked contact's messages are dropped before they become a row, and the sender is told nothing. |
|
| Set on the LOSING row of a merge, pointing at the survivor. Readers filter |
|
| Activity envelopes. |
Warning — No
externalSourcesand nomarketingOptInThere is no per-integration ID map on the contact row — CRM ids live on the integration's own tables, not here, and nothing syncs them back onto the contact. And marketing consent is not a three-valued enum: it is the
unsubscribedAttimestamp above (NULL = never opted out). Per-purpose consent records are separate, atGET/POST /v1/contacts/{id}/consent.
The upsert body (POST /v1/contacts) accepts only externalId, email, phone, name, avatarUrl, locale, timezone, and attributes. Tags, unsubscribe, and block state are each their own endpoint.
User (a Chatly agent)
A user is a member of your workspace — an agent, admin, or owner. Users log in to the dashboard, are assigned conversations, and have a role.
A user is not a contact, and a user's email matching a contact's email is purely coincidental. The two never share a row.
Info — Same email, different rows
If your CEO emails support with a customer complaint, they exist as both a user (workspace member) and a contact (customer). The dashboard surfaces a "match" badge so agents see who's on the other side.
Where identification happens
Widget snippet
The most common path. Inside your authenticated page, call:
Chatly('identify', {
externalId: window.__USER_ID__,
email: window.__USER_EMAIL__,
name: window.__USER_NAME__,
userHash: window.__CHATLY_USER_HASH__, // required if IDV is on
attributes: { plan: 'business', mrr: 199 },
});Idempotent — call it every page load (don't try to "remember" you called it). Lets us pick up attribute changes in real time.
Server SDK
import { ChatlyClient } from '@livechat/sdk-node';
const lc = new ChatlyClient({ apiKey: process.env.CHATLY_API_KEY! });
await lc.contacts.upsert({
externalId: user.id,
email: user.email,
name: user.name,
attributes: { plan: user.plan, mrr: user.mrr },
});Authoritative — bypasses the widget. Use this for back-office syncs or initial data load.
Bulk import
curl -X POST https://api.chatlychat.com/v1/contacts/bulk \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @contacts.jsonUp to 5000 per call — more is a 409, not a truncation. This route, like GET/POST /v1/contacts and the tag/unsubscribe/merge routes, accepts a workspace API key as well as a user bearer token. For larger imports, use Contacts → Import data in the dashboard, which takes a CSV or an Intercom / Zendesk / Crisp export and reports per-row outcomes.
Identity Verification
When IDV is on for a channel, the JS identify() call must include userHash — an HMAC the customer's backend computes. Without it, the identify is rejected, and the visitor falls back to anonymous. The widget surfaces the error in DevTools so you can debug.
Stitching rules (what happens on identify)
Look up
(workspaceId, externalId)incontacts.If found, use that contact. Update
email,name,attributes(only if the new values are non-empty — we don't clobber).If not found, create a new contact with the supplied fields.
Look at the anonymous visitor token in the request. If it has open conversations, CDP events, or attributes:
Merge them onto the contact (move FK references, copy attributes that aren't already set on the contact).
Deactivate the visitor token.
Mint a new contact-scoped session token and return it to the widget.
The merge is idempotent: re-running identify with the same data is a no-op, not a duplicate row.
Merging contacts (duplicates)
Sometimes the same person appears as two contacts (e.g. they messaged you before they had an account, then signed up with the same email). You can merge:
POST /v1/contacts/{survivorId}/merge — Bearer token
{ "mergedId": "9f2c1b7e-0c4a-4f39-b6d1-8a2e5c17d940" }The :id in the path is the survivor — the contact you are looking at absorbs the other. The body field is mergedId.
Warning — There is no
strategyparameterThe merge takes no conflict-resolution strategy.
newest_wins,oldest_winsand friends are not accepted, and sending one is rejected. The merge rule is fixed.
The losing row is not deleted. It gets mergedIntoId set to the survivor and mergedAt stamped — a tombstone rather than a delete, because conversations and CDP events still reference the old id, so keeping the row means history resolves and the merge can be explained afterwards. Live-contact reads filter mergedIntoId IS NULL.
Merging requires contacts:write. (There is no contacts:merge permission.)
What a contact can't do
Cross workspaces. A contact in workspace A is invisible to workspace B even if the email matches. RLS enforces this at the DB.
Bypass authorization. Every contact-scoped endpoint enforces workspace context via the access token's claims.
See another contact's conversations. Even with the right widget identity hash, a session token can only read its own contact's data.
Privacy + GDPR
Both live under /v1/gdpr, not under /v1/contacts:
GET /v1/gdpr/contacts/{id}/export — Bearer token
DELETE /v1/gdpr/contacts/{id} — Bearer token
Right to access: the export returns the contact record plus their conversations and events.
Right to erasure: the delete removes the contact and its dependent rows. It takes no
?reason=parameter.Right to portability: same as access — the export is the portable artifact.
Requests can also be raised and reviewed rather than executed directly: GET /v1/gdpr/requests, then POST /v1/gdpr/requests/{id}/approve or /reject.
See GDPR tools for the operator workflow.
What the API does not let you do
Danger — There is no update-contact endpoint
There is no
PATCH /v1/contacts/{id}. To change a contact's email, name, locale or attributes, callPOST /v1/contactsagain with the sameexternalId— the create route is an upsert keyed on it. The full contact surface is:GET /v1/contacts,POST /v1/contacts,POST /v1/contacts/bulk,GET /v1/contacts/{id}, plus the per-action routes (/tags,/unsubscribe,/block,/unblock,/merge,/notes,/memories,/consent,/journey,/mood,/score,/custom-objects).