Skip to main content

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 an external_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.
Use contact merge when the pair is the same person; use the audience pipeline when you are shaping a send list and want to drop duplicates before send time, without altering the underlying records.

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:
  1. A per-request pin. fieldStrategies on POST /api/v1/contacts/merge carries an explicit primary_wins or secondary_wins for the scalar fields you name. A pinned field ignores the tenant policy for this merge.
  2. 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.
  3. The request fallback. When a field matches no policy rule and no pin, mergeStrategy (primary_wins or secondary_wins, default primary_wins) decides it.
The decision tree for one field - here email, but the same shape applies to every scalar:
The pin-able fields are the closed set the merge honors: 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 ingested external_id and 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.
Record the canonical resolution as the payload you intended, before you send it:
That JSON is the merge plan for the pair: each listed field resolves by its pin, every unlisted scalar falls to the tenant policy then the fallback, and channel memberships, tags, conversation history, and CDP events fold in as unions regardless. Two people reviewing the same pair should converge on the same payload - that is what “deterministic” means here.

Safety rails around every merge

The preview is “hold the request.” The merge route has no dryRun 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:
The returns are candidate groups; the per-pair decision tree above still runs once per group, and each merge request folds at most 20 secondary records into one survivor. A fuzzy scan widens recall on name variants and so widens the review surface - use exact when you want only pairs whose normalized email or phone provably agrees.

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.
Pick the surface first; only then pick the merge policy.

See also