Contact merge policy: the per-field survivorship decision tree
Duplicate contacts accumulate wherever identifiers arrive from different directions - a re-import that only matched on email, an inbound channel that created a new record with only a phone, a CRM sync that carried anexternal_id nobody else knew. Two different mechanisms remove them, and
picking the right one is the first decision:
- Contact merge (this page) resolves one pair of contact records into
one survivor. It is per-pair, per-field, and audit-backed. The dashboard
surface is Audience → Merge duplicates (
/audience/merge); the runbook is the contact merge guide. - Audience-side dedupe is a different route and a different mental model. It treats dedupe as part of the activation pipeline rather than a record-by-record identity decision - see audience activation pipeline. The conversation merge troubleshooting page covers conversation-thread folding, which does not touch contact records at all.
The merge policy decision tree
Every merge writes one survivor record. Each scalar field on that survivor is settled by exactly one of three rule layers, applied in this order:- A per-request pin.
fieldStrategiesonPOST /api/v1/contacts/mergecarries an explicitprimary_winsorsecondary_winsfor the scalar fields you name. A pinned field ignores the tenant policy for this merge. - The tenant survivorship policy. The policy set under identity
resolution fills in every field you did not pin. This is where the
precedence between candidate identifiers -
external_id,email,phone, plus the name and locale fields - becomes a published, tenant-wide rule instead of a per-merge judgement call. - The request fallback. When a field matches no policy rule and no
pin,
mergeStrategy(primary_winsorsecondary_wins, defaultprimary_wins) decides it.
email, but the same shape applies
to every scalar:
phone, email,
whatsapp_id, viber_id, first_name, last_name, display_name,
company, country_code, timezone, language, and external_id. A
field outside this set is a 400, not a silent no-op - designing the pin
list down to the honored set is what makes the policy auditable.
Identify the canonical record first, then write the policy:
- When your CRM or warehouse holds the canonical record, settle precedence
toward
external_id: pick the survivor that carries the ingestedexternal_idand pin name/locale fields to whichever side the CRM updated most recently. The survivor id is permanent, so choose the record your integrations already reference, not the oldest row. - When each record holds a different channel identifier (one has WhatsApp, the other has the phone), channel memberships union-merge server-side with no pin available; the only per-field decisions left are the conflicting scalars (email, display name, timezone).
- When ambiguity persists (shared handset, reassigned number, family email), reject the pair in the review queue - an incorrect merge corrupts every downstream reader at once, and rejecting costs you a re-scan, not a rollback.
Safety rails around every merge
The preview is “hold the request.” The merge route has nodryRun
flag; the dry-run habit is to compute the payload above before you post
it - compare the computed survivor against the two fetched records first.
The dashboard’s merge dialog renders the same walk inline, so a GUI merge
is already the preview. Only send when the computed plan matches what you
expect the survivor to hold.
Undo is windowed, and rollback is idempotent. The response returns a
merge_id. POST /api/v1/contacts/unmerge/:mergeId restores the
pre-merge snapshot for 30 minutes; the gate lives on the history row, so a
second POST of the same merge id inside the window converges on the same
state instead of double-reverting. After the window the fold-in is durable
and the revert path moves to support intervention - treat merges past 30
minutes as permanent.
Every merge leaves an audit row success or fail. The merge history
API (GET /api/v1/contacts/merge-history) serves the operator rows the
Merge History card in the dashboard shows: the acting user, the
survivor id, the folded-in ids, and the strategy logic used. The audit
trail outlives the undo window.
Webhooks decouple the decision from the side effects. Merge events fan
out over the outbound webhook surface, so the integrations that cached
per-contact personalization can invalidate their caches on the event
instead of polling. The merge write is rate-limited per tenant (10 merges
per minute) precisely so webhook handlers can drain at a bounded pace -
see webhook delivery semantics.
Bulk dedupe over the duplicates scanner
For more than a handful of pairs, never page through the contact list client-side. The server-side scanner groups duplicates for you:When the right surface is a different route
Before you reach for this page, check you are solving the contact problem and not the audience or conversation one:- Contact merge (this page) deliberately changes the contact graph: one person, two records, folded into one durable survivor.
- Audience dedupe shapes a send list. If the columns are merely “duplicate-looking for this campaign,” fix the list, not the contacts - the audience activation pipeline covers list-side dedupe, and it post-dates the contact graph by design.
- Inbound message resolution to a contact is its own decision; see inbound message resolution.
- Two threads on the same contact belong to the conversation surface instead - see conversation merge troubleshooting.
Related route surface
See also
- Merge duplicate contacts end-to-end - the operator runbook this decision tree feeds
- Identity resolution guide - where deterministic scans and the review queue stage the candidates
- Consent and suppression model - the consent checks to run on the survivor before sending
- Audience activation pipeline - the list-side dedupe surface that does not write to contacts