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

# Identity resolution: resolve and merge duplicate contacts

> Resolve duplicate contacts into one golden profile: deterministic identifier rules, probabilistic merge review, survivorship policies, and consent-aware merges across channels.

# Identity resolution

Duplicate contacts corrupt everything downstream of them: a message history split across two records reads as two customers, a [segment](/guides/cdp-segments) counts one person twice, churn and LTV scores split, and a contact opted out on one record can still be reachable through its twin. Identity resolution collapses those duplicates into one golden record so every channel, campaign, and downstream analytics read the same profile.

This guide covers how Orbit matches identities, how you feed identifiers in, how to review and apply merges in the dashboard and via the API, how to control which values survive a merge, and how consent and suppression follow the merged record.

## 1. Why identity resolution matters

A person reaches you through different identifiers at different times — a web form (email), an inbound SMS reply (phone), a CRM import (external id), an [opt-out list](/guides/opt-out-lists) entry. Until those are stitched together your data lies:

* **Omnichannel history is fragmented.** The conversation timeline, customer 360, and [search across message history](/guides/search-message-history) show one thread per contact record, so duplicates split one person's story.
* **Segments and scores miscount.** [CDP segments](/guides/cdp-segments) filter live contact rows; duplicates inflate counts and can split a person's lifecycle stage, computed traits, or churn/LTV scores across two records.
* **Suppression and consent leak.** A person opted out on one record can still be mailed via its duplicate (see [section 6](#6-suppression-opt-outs-and-consent-after-a-merge)).

Resolution turns one person into one record, so those downstream consumers all read the same profile.

## 2. How Orbit matches — deterministic vs probabilistic

Orbit ships two matching surfaces, layered on the same identifier sets:

**Deterministic rules** are the always-on layer. `PUT /api/v1/cdp/identity-rules` authors a rule per identifier type (`external_id`, `email`, `phone`, `anonymous_id`) with a priority, and a contact lookup matches on the strongest enabled rule at the time it runs — so tracked events, APIs, and imports resolve to the right contact as soon as an identifier arrives.

```bash theme={null}
curl -X PUT https://api.orbit.devotel.io/api/v1/cdp/identity-rules \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "match_type": "email", "priority": 2, "enabled": true }'
```

**Probabilistic scan** is the review layer for the ambiguous remainder. `GET /api/v1/cdp/identity/merge-candidates` runs a bounded scan over your contacts and returns ranked pairs — the same person entered twice with a fuzzy similarity match on name, phone-format respellings, or shared non-unique channel ids (WhatsApp / Viber). Each candidate carries a confidence (0–100) and a band:

* **High confidence** (`auto_merge`) — the candidate is prepared to merge automatically (for example two records whose normalized phone and email corroborate each other).
* **Review band** (`review`) — the deterministic rules could not make the call; a steward confirms each one before it merges. A phone-only link where the members carry different emails (the classic shared-handset / reassigned-number ambiguity) lands here, not in an automatic merge.

Set `min_confidence` (0–100) on the query string to bound the scan to the band you actually want to see, and `include_auto_merge=false` to look only at the review queue.

## 3. Submit identities and map identifiers

Identity resolution can only think over the identifiers your contacts actually carry. Two paths feed them in:

**At contact creation or import.** Pass a phone, email, and your own `external_id` on `POST /api/v1/contacts` (or on campaign imports and webhook-driven upserts). Prefer your own system's stable key in `external_id` over the phone/email alone — the deterministic rules above match on `external_id` first when a rule for it is enabled.

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/contacts \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "first_name": "Ava",
    "last_name": "Chen",
    "email": "ava@acme.com",
    "phone": "+14155551234",
    "external_id": "crm-44882"
  }'
```

**Through the CDP ingest surface.** Web and mobile events arrive on the HMAC-signed ingest path (`POST /cdp/v1/:ingest_id/identify|track|alias|batch`). An `identify` upserts the profile and stamps `userId`, `anonymousId`, and traits; an `alias` merges two identities explicitly (the same deterministic merge the identifier rules above rely on). See the [CDP ingest reference](/api-reference/endpoints/cdp) for the full envelope, and the [data model](/concepts/data-model) for the id and envelope conventions every endpoint returns.

## 4. Resolve and review merges

The dashboard surface is **Audience → Identity resolution**. It is the merge console, the ranked match list, the rule simulator, and the survivorship policy editor on one page. By API you interact with the same queue:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/cdp/identity/merge-candidates?include_auto_merge=false" \
  -H "X-API-Key: dv_live_sk_your_key_here"
```

Each candidate shows the two contact records side by side with per-field similarity scores and the signals that drove them (which shared identifier or fuzzy field matched). From there either:

* **Dismiss it.** Mark the candidate as not-a-match and it leaves the queue (the pair can be re-filed later if the data changes).
* **Merge it.** With an accept verdict the secondary record collapses into the primary (see [section 5](#5-merge-contacts-safely)).

For the full review queue over the API, enqueue a specific pair for audit and a steward can decide it later:

```bash theme={null}
# Enqueue a pair for review (optional audit note)
curl -X POST https://api.orbit.devotel.io/api/v1/cdp/identity-resolution/match-reviews \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "primary_contact_id": "cnt_01f3…",
    "candidate_contact_id": "cnt_02a8…",
    "confidence": 0.91,
    "band": "review",
    "matched_fields": [ "phone", "email" ],
    "note": "CRM import probably duplicated a web signup"
  }'

# Steward decides it — accept applies the merge through the same
# pipeline the console uses.
curl -X POST https://api.orbit.devotel.io/api/v1/cdp/identity-resolution/match-reviews/<id>/verdict \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "verdict": "accept", "note": "matches phone+email" }'
```

Before you change a rule, use the simulator on the same page — `POST /api/v1/cdp/identity-resolution/simulate` returns a what-if preview (which pairs a proposed rule would merge, a per-field survivorship preview, and an over-merge warning when a proposed rule would collapse clearly-different people) without writing anything.

## 5. Merge contacts safely

A merge collapses one or more secondary records into a primary survivor, redirects channel membership, tags, custom-field values, conversation history, and CDP events onto the survivor, then marks the secondary as merged-away so ids can no longer be accidentally re-contacted as distinct people. The response returns a `merge_id` and a 30-minute undo window; `POST /api/v1/contacts/unmerge/:mergeId` reverts the fold-in while the window is open, and `GET /api/v1/contacts/merge-history` lists every merge for audit.

Which values win on the survivor is governed by your **survivorship policy** — a tenant-wide rule set authored on the Identity resolution page (or `GET`/`PUT`/`DELETE` `/api/v1/cdp/survivorship-policy`) that applies to every merge, console or API. Rules pick a strategy per governed field (the merge copies these scalar contact columns: phone, email, WhatsApp id, Viber id, first/last/display name, company, country code, timezone, language, external id):

* `prefer_target` / `prefer_source` — always keep the surviving (or folded-in) record's value.
* `most_recently_updated` / `most_recently_created` — take the value from the record that has the newer timestamps.
* `prefer_non_null` — fill in an empty survivor field from the folded-in record and leave populated values alone.
* `prefer_source_system` — prefer the value from whichever record's `source` column matches a named system (`crm`, `salesforce`, `import`), so provenance, not just recency, can decide golden fields.

A per-field rule looks like:

```bash theme={null}
curl -X PUT https://api.orbit.devotel.io/api/v1/cdp/survivorship-policy \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "rules": [
      { "field": "email", "strategy": "prefer_source_system", "source_system": "crm" },
      { "field": "phone", "strategy": "prefer_non_null" }
    ],
    "default_strategy": "most_recently_updated"
  }'
```

Two cautions before you sweep your duplicates: first, a rule governs only the scalar contact columns above — a per-merge request can still pin `fieldStrategies` or per-attribute `attribute.<key>` preferences for [custom fields](/guides/custom-fields), and a [custom field](/guides/custom-fields) survives a merge only when a rule or a pin says what to do with it. Second, run the simulator ([section 4](#4-resolve-and-review-merges)) against the full policy before enabling it so an over-merge warning catches a rule that would collapse two different people.

## 6. Suppression, opt-outs, and consent after a merge

Consolidating one person into one record only helps if the merged record carries the person's real consent state. When the survivor and the merged record carry opposite states (one opted out of SMS, the other opted in), check the [opt-out lists](/guides/opt-out-lists) and the consent verdicts before you trust the merged record for sending. On the API, `GET /api/v1/cdp/consent/:contactId` reads a contact's per-channel consent state and `POST /api/v1/cdp/consent/check` answers "may I contact this person on this channel, for this purpose?" — run one once against the survivor if the two records could be in dispute. When a contact revokes consent, the revocation propagates to downstream stores so a segment refreshed after the merge does not re-include someone who opted out on any of the records that were folded in. The same records surface in the [public consent form](/guides/public-consent-form) if you collect consent at intake.

## 7. Troubleshooting common cases

**Over-merging — two unrelated people got collapsed.** Two records with an identical phone but different emails are a shared-handset / reassigned-number ambiguity ([section 2](#2-how-orbit-matches--deterministic-vs-probabilistic)) and belong in the review band, not the high-confidence band. If one slipped through, the fix is the 30-minute `unmerge` on the same page ([section 5](#5-merge-contacts-safely)), then re-review the pair with a dismiss verdict on the queue. To prevent a rule from producing that shape, run `POST /api/v1/cdp/identity-resolution/simulate` before enabling it — it names the overlapping identifiers and flags when a proposed rule would collapse clearly different people.

**Cross-channel duplicates keep reappearing.** A re-imported duplicate whose email and phone are both already attached to the live record is forced to leave those columns null and can only repeat soft fields (name, company) or a shared non-unique channel id. Run the review scan (`GET /api/v1/cdp/identity/merge-candidates`) after every bulk import — if you imported with a stable `external_id`, the deterministic rules ([section 2](#2-how-orbit-matches--deterministic-vs-probabilistic)) catch the pair deterministically instead of re-queueing a fuzzy match.

**Conflicting identities on the survivor.** If the merged record suddenly has the wrong email or phone, the wrong survivorship strategy chose the losing side — check `GET /api/v1/cdp/survivorship-policy` for the rules that govern that field, then re-merge with a per-request `fieldStrategies` pin that overrides the tenant policy (per-request pins always win over the policy).

**Two stewards race on the same candidate.** A `verdict` on a `pending` review row is terminal — a second steward's accept or reject returns a 409. If a merge is rejected because one of the contacts was already merged or removed, the API returns a "stale candidate" error and the console drops the stale pair from your queue — refresh and the corrected pair list re-appears.

## See also

* [CDP audiences: build segments, computed traits, and activate](/guides/cdp-segments)
* [Custom fields](/guides/custom-fields)
* [Opt-out lists](/guides/opt-out-lists)
* [Data model conventions](/concepts/data-model)
* [CDP ingest (identify, track, alias) API reference](/api-reference/endpoints/cdp)
