> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Merge duplicate contacts end-to-end

> Run the full duplicate-merge workflow: find duplicates, preview the merge dry-run, pick the survivor, re-sync agent memory, and carry consent forward on the merged record.

# Merge duplicate contacts end-to-end

The [identity resolution guide](/guides/identity-resolution) 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](/guides/identity-resolution).
* A scan on this page: **Scan All Contacts** runs a server-side duplicate scan (`GET /api/v1/contacts/duplicates`) with an `exact` strategy (normalized email + phone grouping) or a `fuzzy` strategy (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.

Do **not** merge when the pair only shares a weak signal — a shared handset, a reassigned number, or a family email is a classic false positive. When a pair is ambiguous on both sides, leave it in the review queue and reject it rather than undoing a bad merge.

## 2. Constraints you commit under

Three constraints gate every merge. Report them to whoever owns the decision before you click:

1. **No unmerge after the 30-minute window.** `POST /api/v1/contacts/merge` returns a `merge_id`; `POST /api/v1/contacts/unmerge/:mergeId` reverts 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.
2. **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-history` serves the same rows the dashboard's **Merge History** card shows; the audit trail survives even when the undo window has closed.
3. **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.

Because of (3), only two things belong before the merge: preview the dry run (section 3) and verify the survivor choice against the merge tree (section 4).

## 3. Preview with the dry-run validator

The merge API has no explicit `dry_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.

```bash theme={null}
curl -X GET https://api.orbit.devotel.io/api/v1/contacts/cnt_primary_id \
  -H "X-API-Key: dv_live_sk_your_key_here"

curl -X GET https://api.orbit.devotel.io/api/v1/contacts/cnt_secondary_id \
  -H "X-API-Key: dv_live_sk_your_key_here"
```

A minimal pre-merge preview payload — compute this yourself from the two fetches, then pass it to the merge as `fieldStrategies`:

```json theme={null}
{
  "primaryId": "cnt_01f3…",
  "secondaryIds": ["cnt_02a8…"],
  "fieldStrategies": {
    "phone": "primary_wins",
    "email": "secondary_wins",
    "display_name": "secondary_wins",
    "timezone": "primary_wins"
  },
  "mergeStrategy": "primary_wins"
}
```

The `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, pin `fieldStrategies` 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.

See [agent memory](/agents/memory-plane) for how memory resolves across channels when the contact id behind a session changes.

## 6. Opt-outs and consent carry forward tenant-owned

Consent is tenant-owned: any opt-in or opt-out a person recorded on either side of the pair must still be honored on the survivor after the merge. Fold-in does not reset consent, and a revocation recorded on the folded-in record continues to suppress the survivor on that channel.

Before you trust the merged record for sending, read the survivor's consent state:

```bash theme={null}
curl -X GET "https://api.orbit.devotel.io/api/v1/cdp/consent/cnt_primary_id" \
  -H "X-API-Key: dv_live_sk_your_key_here"

curl -X POST https://api.orbit.devotel.io/api/v1/cdp/consent/check \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "contact_id": "cnt_primary_id", "channel": "sms", "purpose": "marketing" }'
```

When the pair disagreed before the merge (one record opted out, the other opted in) the consent check is the authoritative answer, not either record's raw flags. For a tenant-wide proof of the state behind the decision — which is what an auditor asks for — export the consent ledger; see [export consent and suppression records](/compliance/consent-suppression-export).

## See also

* [Identity resolution: resolve and merge duplicate contacts](/guides/identity-resolution)
* [Import and reconcile contacts](/guides/import-contacts)
* [Opt-out lists](/guides/opt-out-lists)
* [Export consent and suppression records](/compliance/consent-suppression-export)
