Skip to main content
Deduplication is asynchronous and runs in two explicit steps: a scan finds duplicate groups across your workspace’s saved contacts and shows you exactly what each merge would produce, then a separate merge call applies the groups you choose. Nothing is changed until you start the merge, and neither step costs credits. All three endpoints require a key with the contacts scope — see Scopes. The scan covers your team’s saved contacts, Fuse and custom alike: everyone on your own lists and on your teammates’ public lists, not one list.

Start a scan

POST /business/contacts/dedupe-scan with the fields duplicates must match exactly on:
matchFields accepts any combination of linkedinUrl, email, phone, fullName, jobTitle, industry, location, companyName, companyDomain — with a floor: the selection must include at least one unique identifier (linkedinUrl, email or phone) or at least three profile fields. Anything weaker (say, jobTitle alone) would group unrelated people, and is rejected with 422 VALIDATION_FAILED. Values are compared after trimming and lowercasing. email and phone compare each contact’s full set of addresses or numbers, so contacts that share only one of them are not grouped. A contact missing any selected field is left out of the scan. The API responds 202 with a job id:
A 202 means accepted, not finished. Hold on to jobId — it is both your handle for polling and the id the merge step consumes.

Poll the scan

GET /business/contacts/dedupe-jobs/{jobId} returns the job’s current state:
status moves through the same lifecycle as every long-running job: pending → processing → completed or failed, and the terminal states are final. When the scan completes, scanResult carries one page of the duplicate groups — pageNum and limit page through them (default 50 per page, max 200, largest groups first), and scanResult.pagination says how many pages there are. Poll without paging params until status is completed, then page through the groups; a completed scan’s full result can run to a megabyte, so re-fetching every group on every poll is wasted transfer.
Each group is one set of contacts the scan believes are the same person:
  • contactIds is every member; contacts previews the first 25 of them with their own fields, emails and phones.
  • mergePreview is what the surviving contact will look like if you merge the group: the winner’s identity (winnerContactId), empty fields filled from the other members, and the combined email/phone unions. emails and phones carry the first 10 entries each; emailCount/phoneCount are the full totals.
  • lists names the lists the members live in, up to 10 of them, and listCount is the full total. Useful for judging blast radius before merging.
  • groupId is the stable id the merge step selects by.
A scan stores at most 200 groups. truncated: true means more exist — merge what you have and scan again. Note the two totals side by side: totalGroups counts every group the scan found (it can exceed 200), while pagination.totalRecords counts the stored, pageable groups. Groups larger than 50 contacts are dropped and counted in skippedOversizedGroups; that many contacts matching exactly almost always means the field selection is too weak, not that one person has 50 records. Scan results expire with the job after roughly 24 hours. Polling an unknown or expired jobId returns 404 JOB_NOT_FOUND; start a new scan.

Merge the groups

Review the groups, then POST /business/contacts/dedupe-merge with the scan’s job id. Omit groupIds to merge every group, or pass a subset of groupId values to merge only those:
The API responds 202 with a new jobId — poll it at the same GET /business/contacts/dedupe-jobs/{jobId} endpoint. Within each group, the merge follows a fixed contract, the same one the in-app Merge Duplicates dialog applies:
  • A Fuse contact beats a custom one as the survivor; among custom-only groups, the most recently created wins.
  • The survivor’s empty fields are filled from the other members.
  • All emails and phones across the group are combined onto the survivor.
  • Custom-column values are carried over.
  • The losing contacts are archived, and every list membership they held is repointed to the survivor — no list loses a row.
When the merge job completes, mergeResult carries the totals:
Merging cannot be undone: the losing contacts are archived and their list memberships move to the survivor. Review mergePreview for every group you submit before starting the merge.

Groups that will not merge

Two safety rules can make mergedGroups come out lower than the number of groups you submitted:
  • Conflicting Fuse identities. Two Fuse contacts with different PDL ids or different LinkedIn URLs are never combined, even when the scan grouped them (they matched on your selected fields, but Fuse knows they are different people). Such groups are split into their consistent parts or skipped.
  • Stale groups. A group whose members were already merged or archived by the time the job ran — by a teammate, or an earlier merge — is counted in skippedGroups rather than failing the job.
A scanJobId that is unknown or has expired answers 404 SCAN_NOT_FOUND. Starting a merge against a scan that is still running answers 409 SCAN_NOT_COMPLETED; poll the scan to completed first. A scan that found nothing answers 422 SCAN_HAS_NO_GROUPS — there is nothing to merge, which is the good outcome.

A compact end-to-end loop

Next steps

Clean and enrich your CRM data

Import a CRM export, merge its duplicates, enrich it and sync it back in one workflow

API reference

Full request and response schemas for the dedupe endpoints