Identity and deduplication

Contacts are not tenant-scoped. A person is one InboxContact row across the whole service, and identity is global via InboxContactIdentifier @@unique([kind, value]): one phone number is one identity. Workspace-specific facts — how they arrived, how engaged they are, whether they may be marketed to — live on InboxContactTenant, unique on (contactId, tenantId).

That split is the whole design. Unsubscribing from one workspace must not unsubscribe you from another, and an engagement score earned in one says nothing about another. But the person is one person, which is what makes a cross-workspace merge coherent.

This is also the highest-consequence code in the domain, because the two failure modes are not symmetric. Failing to merge two records for the same person is a nuisance someone fixes later. Merging two different people is unrecoverable: their conversations, tickets and marketing consent are now one record, and no later analysis can reliably tell them apart again. Every rule below leans that way.

Identifier kinds

phone | email | meta | instagram | whatsapp | widget | source_row

source_row is the escape hatch: a contact with nothing matchable still gets an identity of its own, keyed on where it came from. It never matches anything else, which is the point — it preserves the record without inventing a relationship.

Each identifier carries its own status (unknown | valid | invalid), because reachability is a property of the channel: someone's WhatsApp can be live while their email hard-bounces.

Network to kind

A thread participant carries only an externalUserId. What that id means is a property of the network, not of the participant, so the mapping lives in participant-identity.ts rather than being guessed from the shape of the string:

Network externalUserId is Resolved as
whatsapp, whatsapp_session, sms A dialable phone number phone
facebook, instagram A Meta-issued account id meta
livechat, ai A session id, not a person source_row, keyed network:id

Instagram DMs send a numeric account id, not the @handle, which is why both Meta networks map to meta. A handle that arrives in the payload is used as a name only.

The livechat and ai cases matter: the id is real and stable within its own world but identifies a session, not a human. If that visitor later gives a phone or email, the new identifier attaches to the same contact and the identity becomes real.

normalizePhone, in full

  1. Strip every non-digit character. +254 711 …, 254-711-… and 254711… already collapse here.
  2. Require ^[0-9]{9,15}$. Anything shorter or longer is not a subscriber number.
  3. Reject all-zeros at any length — 0, 0000000000 are placeholders.
  4. Reject anything in the blocklist, tested against both the digits and the raw trimmed input.
  5. Parse with libphonenumber using a region hint: the tenant's defaultCountry, else INBOX_DEFAULT_PHONE_REGION, else KE. This is what canonicalises a bare national number: 0711… becomes 254711….
  6. Re-check the blocklist against the canonical form, because a national placeholder can canonicalise into a blocked international value.

The stored value is digits-only E.164 with no leading +, which equals the old digits-only form for already-international numbers, so existing matches were preserved when the region step landed. So 0711234567 and 254711234567 are the same contact.

A region-less caller keeps the plain digits-only behaviour. If the number does not parse to a valid one, the plausible digits are kept rather than discarded — never worse than before.

derivePhoneCountry uses the same parse to set countryIso (ISO alpha-2) and countryCode (calling code) on the contact. Country is a property of the person, not part of the match key, so it is computed separately.

Email is lowercased and trimmed, tested against a deliberately permissive pattern — rejecting a real address is worse than accepting an odd one. Provider ids are trimmed and otherwise trusted: the provider guarantees uniqueness, and no format can be assumed.

Trust order

identifiersFor builds the match set in descending order of trust, and the first match wins:

meta → instagram → email → phone

A Meta id is issued and guaranteed unique by the provider. A phone number was typed by a human. An empty result means nothing is matchable, and the caller must mint a source_row identifier rather than merging the contact into anyone.

The blocklist, and why it is data

DEFAULT_BLOCKED_PHONE_VALUES holds values that are structurally valid but semantically meaningless. The production numbers say why a format check is not enough:

That last one is the case that matters. A placeholder number that passes every format check would merge thousands of unrelated people onto one contact, taking their conversations and their consent with it — irreversibly.

There is also no safe frequency threshold. At "shared by more than 5 rows" the rule would pool 62,005 rows into shared identities; even at more than 20 it touches 4,134. Meanwhile a real shared office line might legitimately appear on 24. So the blocklist is data an operator reviews, not a heuristic the module invents. deriveBlockedValues(counts, threshold) computes candidates at import time and reports them; nothing is promoted automatically.

suspectPlaceholders

Frequency alone cannot separate "one person with duplicate records" from "a placeholder several people share", but name agreement can. Measured across the 7,703 phone numbers shared by more than five rows:

Profile Count Verdict
At most two distinct name variants 7,524 (98%) The same human, recorded repeatedly — merge
Many distinct names 24 A placeholder — never merge

The sample makes it obvious: 254708548898 appears 24 times, always as "Ian Agent Opposite Popman"; 254711823970 appears 2,123 times as "CYNTHIA", "Cynthia", "Gopi" and blank. Same shape, opposite meaning.

suspectPlaceholders(profiles, { minRows = 5, maxNames = 2 }) only reports. It mutates no blocklist. A false positive silently splits one person in two; a false negative silently fuses two people into one. That decision stays with an operator.

Merging

curl -X POST "$INBOX_URL/v1/contacts/$TARGET_ID/merge" \
  -H "x-api-key: $ADMIN_KEY" -H "X-Axis-Tenant: ws_$GROUP_ID" \
  -H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \
  -d '{"sourceContactId":"'"$SOURCE_ID"'"}'

admin-scoped, not inbox — for the same reason /v1/connections/import is. It is irreversible in practice, and an ordinary inbox-capable key must not reach it. Returns 200 with { contact, identifiersMoved, tenantRowsReconciled }.

Every merge writes an InboxContactMerge audit row recording targetContactId, sourceContactId, actorUserId, identifiersMoved and an optional reason. Those contact ids are deliberately dangling — plain columns, not relations. A cascading foreign key would erase exactly the history the table exists for when the target is later deleted. actorUserId is null for a machine key with no user context (a migration run), which is itself worth recording.

Routes

Method Path Scope Notes
GET /v1/contacts inbox search, source, segment, country, limit, cursor, includeTotal
GET /v1/contacts/summary inbox Per-source counts
GET /v1/contacts/template inbox CSV header row for the import
GET /v1/contacts/export inbox Streams CSV; never buffers the tenant
POST /v1/contacts/import inbox 200. Body { rows, segmentId? }; the BFF pre-parses the file
POST /v1/contacts/upsert inbox 200. Contact + identifiers + segment membership in one call
POST /v1/contacts/bulk-archive inbox 200. Body { contactIds }
GET /v1/contacts/:id inbox
POST /v1/contacts inbox 200, not 201 — create-or-resolve, and an existing person is the normal outcome
PATCH /v1/contacts/:id inbox
POST /v1/contacts/:id/identifiers inbox 201
PATCH /v1/contacts/:id/identifiers/:identifierId inbox Sets validation status
POST /v1/contacts/:id/merge admin 200, irreversible
DELETE /v1/contacts/:id inbox 200. Archives from this workspace — the person and their identifiers persist for other tenants

/upsert and /bulk-archive are declared before :id so Nest matches them as static segments rather than as contact ids.

Gotchas

Related