To deduplicate CRM accounts and contacts safely, match records only on hard identifiers (LinkedIn ID, LinkedIn URL, a non-generic domain or email, a stored phone), re-search the live CRM before every decision, merge automatically only on an exact, conflict-free match, and send every other cluster to a human who has 24 hours to approve; silence leaves the records apart. The CRM deduplication cookbook deploys this as two independent plays, one for accounts and one for contacts, that run daily on your go-to-market CRM once you enable them and merge with the CRM’s own merge action.
The problem: duplicates split an account’s history in two, so the won deal sits on one record and the marketing touches on the other, two owners work the same company, and scoring reads half the story. A quarterly cleanup fixes the records, not the arrival of new ones. For the reasoning behind the matching keys and survivor rules, see CRM hygiene as code; this page is the runnable version.
Fill the identifiers first #
A record with no matching key cannot be matched at all. If many accounts lack a domain or LinkedIn page, or contacts lack a LinkedIn URL or email, start with the CRM enrichment cookbook for a messy CRM: it fills blank identity fields without overwriting what a rep entered. Deduplication then merges on the identifiers it filled. It is a recommendation, not a dependency; each cookbook installs on its own.
How it works, end to end #
Both plays are written against HubSpot in the example and run on the CRM-backed models crm_accounts and crm_contacts, not a staging copy.
Accounts (deduplicate_accounts, daily at 07:00 UTC):
- Enrolls a company that has its record id and at least one of LinkedIn company ID, LinkedIn company page or domain. Company name is never a key: two companies can share a name.
- Calls the CRM’s
findRecordson those three properties, so the cluster comes from the live CRM, not from an extract that may be hours old. If the source record is gone or no duplicate remains, it stops. - A deterministic script normalizes the evidence, ignores generic domains (
linkedin.com,linktr.ee,github.comand similar), and picks one survivor: a protected business ID first, then customers, open deals, associated contacts, activity, filled properties, with a final tie-break so two runs never disagree. - Native scoring adds 60 for an exact LinkedIn company ID, 25 for an exact LinkedIn URL, 15 for an exact non-generic domain.
- It merges automatically only at 60 or more and with no identity, protected-ID or parent-subsidiary conflict, which in practice means a shared LinkedIn company ID. Everything else goes to Human Review in Slack.
Contacts (deduplicate_contacts, daily at 07:30 UTC):
- Searches by LinkedIn person ID, normalized LinkedIn URL variants, exact email and exact stored phone, then runs a second search with every identity value the first one found, so a chain of matches becomes one cluster.
- Merges automatically on an exact LinkedIn person ID, an exact LinkedIn URL without a person-ID conflict, an exact non-generic email without a LinkedIn conflict, or a conflict-free chain of those.
- Never merges automatically on a LinkedIn conflict, a generic or shared email (
info@,sales@,support@and the like) or a phone-only match. Those go to review, or stay untouched if you turn contact review off. - The survivor is the contact with the most associated deals, then activities, then the older record, then the more filled one, then the smaller id.
The review message shows the score, the survivor, the ids to merge, every conflict flag and the evidence per record, with Approve and Decline:
Review CRM account merge into 18234
Duplicate score: 40/100
Survivor: 18234
Records to merge: 20911
Identity conflict: false
Protected ID conflict: false
Parent/subsidiary warning: false
(Illustrative, fictional records: a shared LinkedIn page and domain, but no LinkedIn company ID, so it waits for a person.)
Approve merges; decline or 24 hours with no answer writes nothing. The merge is the last CRM write in both plays: normalization and enrichment belong to a separate workflow.
Run it #
npm install -g @cargo-ai/cli
cargo-ai login --email you@company.com # emailed code, no browser, 100 free credits, no card
cargo-ai project cookbook view crm-deduplication
cargo-ai project add cookbook/crm-deduplication # from your Cargo project's root
The install writes the procedure next to the code, and a coding agent works it with you, one path at a time: choose accounts, contacts or both; audit the live CRM (identifier coverage, candidate classes, conflicts) with no paid call and no write; approve that path’s policy; set the review channel; then cargo-ai project plan and cargo-ai project deploy. Both plays deploy disabled, serial (noConcurrency) and capped at 15 rows, so the first merge-capable run is a pilot small enough to check every survivor by hand. Enabling the daily schedule is a separate approval after the pilot.
What it needs and what it costs #
| Needs | Why |
|---|---|
| An authorized CRM connector (HubSpot in the example) | Search and merge; Salesforce and Attio adapt the connector, record id, search, merge and property slugs together |
| A Slack connector and a review channel | Human Review; required for accounts, optional for contacts |
| LinkedIn ID, LinkedIn URL, domain, email or phone on the records | The only keys it matches on |
Cost, from the code: HubSpot’s findRecords and mergeRecords, and Cargo’s native scoring and humanReview nodes, carry no Cargo credit price in their action definitions as of October 2026. The cookbook still has you read live action metadata (cargo-ai connection integration get hubspot) before each pilot, because prices are not hard-coded in it.
What it will not do #
- It does not match on company or person name.
- It does not merge a conflicting LinkedIn identity, a protected-ID conflict, a parent and its subsidiary, a generic email or a phone-only match without a person approving it.
- It does not merge on a decline or a timeout.
- It does not create a candidate or staging model, update fields after the merge, or enrich records.
- It does not run on a schedule until you enable it after the 15-row pilot.
The cookbook is marked to-be-approved: not yet deploy-verified against a live workspace. Its acceptance checklist, per path, is the test.
Frequently asked questions #
Not on its own. A shared non-generic domain scores 15 and a shared LinkedIn page 25, under the 60 needed, so the cluster goes to Human Review. Only an exact shared LinkedIn company ID with no identity, protected-ID or parent-subsidiary conflict merges unattended.
The request times out after 24 hours and takes the same path as a decline: nothing is written and the records stay separate.
The checked example is HubSpot. Salesforce and Attio are supported by adapting the connector, the extract, the record id field, the search and merge actions and the property slugs together, keeping one CRM shape across both paths.