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
- Strip every non-digit character.
+254 711 …,254-711-…and254711…already collapse here. - Require
^[0-9]{9,15}$. Anything shorter or longer is not a subscriber number. - Reject all-zeros at any length —
0,0000000000are placeholders. - Reject anything in the blocklist, tested against both the digits and the raw trimmed input.
- Parse with libphonenumber using a region hint: the tenant's
defaultCountry, elseINBOX_DEFAULT_PHONE_REGION, elseKE. This is what canonicalises a bare national number:0711…becomes254711…. - 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:
- 239,733 of 239,746 contacts carry a phone, but only 83,071 (35%) are plausible.
0appears 11,843 times;*********appears 2,953 times.254711823970— a syntactically perfect Kenyan mobile — sits on 2,136 rows.
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
- Contact resolution never blocks ingest. A resolver outage costs you the enrichment, not the
message. If
contactIdis absent on a thread, that is a normal transient state, not corruption. - Identifier values are PII and never reach event payloads. The
contact.identifier_addedevent carries only the non-PIIkind. - DELETE is an archive, not a delete. The contact row survives because other tenants may know the same person.