> ## 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.

# Contact merge policy: the per-field survivorship decision tree

> How contact dedupe picks a survivor, how the per-field survivorship policy settles precedence between external_id, email, phone, and the other scalar fields, and which safety rails (undo window, audit row, webhook) surround every merge.

# 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](/guides/contact-merge).
* **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](/guides/audience-activation-pipeline). The
  [conversation merge troubleshooting](/troubleshooting/conversation-merge)
  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:

```text theme={null}
For one scalar field (e.g. email):
  named in the request's fieldStrategies?
    yes → that value survives, tenant policy bypassed
    no  →
      covered by the tenant survivorship policy?
        yes → the policy rule decides (e.g. secondary_wins)
        no  →
          fallback to mergeStrategy, or primary_wins when omitted
```

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:

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

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](/concepts/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:

```bash theme={null}
# Exact strategy: normalized email + normalized phone grouping
curl -X GET "https://api.orbit.devotel.io/api/v1/contacts/duplicates?strategy=exact" \
  -H "X-API-Key: dv_live_sk_your_key_here"

# Fuzzy strategy: exact grouping + a name-similarity pass, threshold
# adjustable between 0.70 and 0.95 (default 0.80)
curl -X GET "https://api.orbit.devotel.io/api/v1/contacts/duplicates?strategy=fuzzy&threshold=0.85" \
  -H "X-API-Key: dv_live_sk_your_key_here"

# Aggregate count for a banner or progress check
curl -X GET "https://api.orbit.devotel.io/api/v1/contacts/duplicates/count?strategy=exact" \
  -H "X-API-Key: dv_live_sk_your_key_here"
```

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](/guides/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](/concepts/inbound-message-resolution).
* **Two threads on the same contact** belong to the conversation surface
  instead - see
  [conversation merge troubleshooting](/troubleshooting/conversation-merge).

Pick the surface first; only then pick the merge policy.

## Related route surface

| Method | Path                                | Purpose                                              |
| ------ | ----------------------------------- | ---------------------------------------------------- |
| `GET`  | `/api/v1/contacts/duplicates`       | Scan for duplicate groups (exact or fuzzy)           |
| `GET`  | `/api/v1/contacts/duplicates/count` | Aggregate duplicate count for banners                |
| `POST` | `/api/v1/contacts/merge`            | Fold secondaries into a survivor; returns `merge_id` |
| `POST` | `/api/v1/contacts/unmerge/:mergeId` | Revert one merge inside the 30-minute window         |
| `GET`  | `/api/v1/contacts/merge-history`    | Audit rows for every merge and unmerge               |

## See also

* [Merge duplicate contacts end-to-end](/guides/contact-merge) - the
  operator runbook this decision tree feeds
* [Identity resolution guide](/guides/identity-resolution) - where
  deterministic scans and the review queue stage the candidates
* [Consent and suppression model](/concepts/consent-and-suppression-model) -
  the consent checks to run on the survivor before sending
* [Audience activation pipeline](/guides/audience-activation-pipeline) -
  the list-side dedupe surface that does not write to contacts
