API Docs

Contacts, visitors, and users

AdminUpdated Sep 19, 2026

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

Identifier

visitor key

vt_<uuid>. Server-minted, not generated client-side — a client cannot assert a visitor key it was not given.

Credential

signed token

An HMAC-signed envelope (vs1…) carrying the visitor key, the channel id, and how the identity came to be trusted. Stateless — there is no server-side session table. Re-minted on every init.

Portability

channel-bound

A token is minted for ONE channel and is inert on any other, so a leaked token does not travel.

Scope

browser-scoped

Different browsers / devices / incognito sessions are different visitors.

Email / phone / name

no

None of these — visitors are anonymous by definition.

Tracked CDP events

yes

Events fire and are stored against the visitor. They follow into the contact on identify.

GDPR posture

pseudonymous

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 whose external_id happens to be vt_<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

externalId

string

Your stable identifier. Always set this if you have one. The merge key Chatly uses to stitch visitors → contacts.

email

string

Secondary stitching key. If two contacts have the same email, we suggest merging (configurable to auto-merge).

phone

E.164

Used for SMS/voice channel attribution.

name, avatarUrl

string

Display only.

locale, timezone

BCP-47 / IANA

Drives UI language + business-hours / scheduling decisions.

attributes

object

Arbitrary JSON. Used by triggers + audiences. Keep keys snake_case, values primitive.

tags

string[]

For filtering, segmentation, and audience membership. Written through POST /v1/contacts/{id}/tags, not through the upsert body.

unsubscribedAt, unsubscribeReason

timestamp? + string?

Contact-level outbound opt-out — "do not contact this person again". Set through POST /v1/contacts/{id}/unsubscribe. This is what gates campaigns.

blockedAt, blockReason

timestamp? + string?

INBOUND suppression, distinct from unsubscribe. A blocked contact's messages are dropped before they become a row, and the sender is told nothing.

mergedIntoId, mergedAt

fk? + timestamp?

Set on the LOSING row of a merge, pointing at the survivor. Readers filter mergedIntoId IS NULL for live contacts.

firstSeenAt, lastSeenAt

timestamp

Activity envelopes.

Warning — No externalSources and no marketingOptIn

There 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 unsubscribedAt timestamp above (NULL = never opted out). Per-purpose consent records are separate, at GET/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.json

Up 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)

  1. Look up (workspaceId, externalId) in contacts.

  2. If found, use that contact. Update email, name, attributes (only if the new values are non-empty — we don't clobber).

  3. If not found, create a new contact with the supplied fields.

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

  5. 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 strategy parameter

The merge takes no conflict-resolution strategy. newest_wins, oldest_wins and 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, call POST /v1/contacts again with the same externalId — 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).

Was this page helpful?