Merge duplicate contacts end-to-end
The identity resolution guide covers how Orbit detects duplicates and queues them for review. This guide is the operator runbook for what happens next: deciding a merge is right, previewing the change before any data moves, applying it, and doing the follow-up checks so personalization and consent stay correct on the survivor. The dashboard surface is Audience → Merge duplicates (/audience/merge). Every step below also runs over the API.
1. When a merge is appropriate
Merge when two contact records are the same person with one duplicated identity. The usual sources:- A candidate resolved through the identity resolution queue — deterministic rules or the probabilistic scan, as covered in the identity resolution guide.
- A scan on this page: Scan All Contacts runs a server-side duplicate scan (
GET /api/v1/contacts/duplicates) with anexactstrategy (normalized email + phone grouping) or afuzzystrategy (exact grouping plus a name-similarity pass, threshold adjustable between 0.70 and 0.95, default 0.80). - A re-import that created a twin row — bulk imports only match on phone or email by default, so a partial-identifier duplicate lands here instead of in the deterministic rules.
2. Constraints you commit under
Three constraints gate every merge. Report them to whoever owns the decision before you click:- No unmerge after the 30-minute window.
POST /api/v1/contacts/mergereturns amerge_id;POST /api/v1/contacts/unmerge/:mergeIdreverts it only inside that window. After the window closes the fold-in is durable — the secondary records are marked merged-away and there is no operator path back. - A full audit row is captured regardless. Every merge and unmerge lands in the audit log with the acting user, the survivor id, the folded-in ids, and the strategy used.
GET /api/v1/contacts/merge-historyserves the same rows the dashboard’s Merge History card shows; the audit trail survives even when the undo window has closed. - Secondary records are retired, not deleted. The survivor absorbs the identifiers and history; the merged-away records stop being reachable as separate profiles. Downstream readers — segments, scores, channel subscriptions — resolve to the survivor, so an incorrect merge corrupts every one of them at once.
3. Preview with the dry-run validator
The merge API has no explicitdry_run flag — the preview is hold the request, don’t send it. Fetch the two records, resolve the survivorship rule per field, and compare your computed survivor against what the merge would write.
The merge dialog on the dashboard does this same walk in front of you; on the API you do it yourself. Only the fields the merge actually honors for per-field precedence are in play — channel memberships, tags, conversation history, and CDP events are merged server-side as unions and are not per-field decisions.
fieldStrategies:
fieldStrategies keys are a closed enum — only the scalar contact fields the merge honors: phone, email, whatsapp_id, viber_id, first_name, last_name, display_name, company, country_code, timezone, language, external_id. Values are primary_wins or secondary_wins; a pinned value beats your tenant survivorship policy for that field, and any field you omit falls back to the tenant policy, or to mergeStrategy when no policy rules apply.
Validator checklist before you post the merge:
- The survivor id is the one your external systems hold references to (CRM sync keys, webhook subscriptions, ingested
external_id). The survivor id never changes across a merge — pick the record that already has the longest-lived references. - Every non-null value on the folded-in record is accounted for: either pinned in
fieldStrategies, covered by the tenant survivorship policy, or genuinely superseded. - The preview survivor state passes the merged record’s consent check (section 5), and your segments and agent memory (section 4) both reference the survivor id, not the folded-in ids.
4. Apply per-case merge-tree strategies
The survivorship policy from the identity resolution guide is the tenant-wide rule set that fills in anything you do not pin. The merge itself needs a decision per case: Oldest-owner case. When the CRM or the legacy system holds the canonical record, pinfieldStrategies to secondary_wins only for fields that the CRM touched most recently (name, company, timezone) and let the policy take the rest. The survivor id picks the record your external references already point at, not whichever was created first — the created-at column is a trap here.
Most-identifiers case. When the two records each carry a different channel identity (one has WhatsApp, the other has the phone), the survivor takes the union of both and keeps whichever conflicting scalar you pin. Channel membership is union-merged server-side with no pin available, so the only choice is which conflicting scalar (email, display name) wins.
Merged-profile-as-of-timestamp case. When you need the survivor to reflect a specific known-good snapshot (for example the pre-import state), use most_recently_updated in the tenant policy and re-run the merge from the simulator before it touches the live queue. If the window has already closed the only path back is the audit row — see section 2.
5. Re-build AI personalization after a merge
AI agent memory is keyed by the contact id. A merge re-points the survivor’s memory in the same transaction, so the right behavior depends on which record was the survivor:- Merge went through, both records had memory — memory survives on the survivor; the folded-in memory is unreachable by its old id. Any external integration that cached per-contact personalization needs to invalidate that cache entry and re-fetch under the survivor id.
- Agent personalization pipelines — anything built on agent memory (caller greeting, CDP profile, follow-up recommendations) reads the survivor record from the next request. If you cache agent context outside Orbit, invalidate the cache key for the folded-in ids as part of your merge handler, exactly as you would for a contact update.