Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Deduplicate HubSpot contacts at production scale — surviving import storms, wrong-winner merges, fuzzy-match blind spots, association orphans, rate-limit exhaustion, and silent merge failures on conflicting lifecycle or opt-out status. Use when cleaning a CRM after a bulk import, running a nightly dedup pipeline on millions of records, recovering from a merge that destroyed the wrong timeline, or building fuzzy matching beyond HubSpot's native email-uniqueness. Trigger with "hubspot dedup", "hub
.claude/skills/jeremylongshore-hubspot-contact-dedup/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-03 | ✗→✓ | ▲ Improved | 35% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 256% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 88% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 203% | 0% |
| case-12 | ✗→✓ | ▲ Improved | 372% | 0% |
Merge duplicate contacts in HubSpot and operate that process in production, at scale, without data loss. This is not a one-click cleanup guide — it is the logic your pipeline runs when a sales ops team imports 80,000 leads from a tradeshow CSV that already exist in the CRM, when a merge destroys the "winner" contact's email history, when a fuzzy match on "Jon" vs "John" leaves a six-figure deal associated to a ghost record, and when on-call discovers that 40,000 contacts were merged without checking opt-out flags.
The six production failures this skill prevents:
POST /crm/v3/objects/contacts/merge requires a primaryObjectId. Picking the wrong one demotes the older contact's full activity timeline — calls, emails, form submissions — to the discarded record's history.hs_email_optout=true overriding the primary's opted-in status. HubSpot's "most recently updated value wins" rule is wrong for compliance flags.Authenticate with a private app token (pat-na1-*) or OAuth access token. Pass it on every request:
bashAuthorization: Bearer {your-token}
Required scopes: crm.objects.contacts.read, crm.objects.contacts.write, crm.associations.read, crm.associations.write. See the hubspot-auth skill for token caching, OAuth refresh, and scope-drift detection.
requests, phonenumbers, rapidfuzz) for the full pipelinejq for shell examplesFind exact duplicates by email using the search API. Never pull all contacts into memory for comparison — use the search endpoint with specific filter values.
bash# Find all contacts sharing a normalized email curl -s -X POST "https://api.hubapi.com/crm/v3/objects/contacts/search" \ -H "Authorization: Bearer {your-token}" \ -H "Content-Type: application/json" \ -d '{ "filterGroups": [{"filters": [ {"propertyName":"email","operator":"EQ","value":"jane.doe@example.com"} ]}], "properties": ["email","firstname","lastname","hs_object_id","createdate", "lifecyclestage","hs_email_optout","hs_email_hard_bounce_reason_enum"], "sorts": [{"propertyName":"createdate","direction":"ASCENDING"}], "limit": 10 }' | jq '[.results[] | {id, created:.properties.createdate}]'
For full-portal scans across millions of contacts use the four-stage Python pipeline in implementation-guide.md. The pipeline writes a local SQLite checkpoint so rate-limit interruptions do not require starting over.
The oldest contact by createdate is the primary — its timeline is most historically complete. Two overrides apply:
hs_email_optout=true and the newer one does not, prefer the opted-in record as primary to avoid propagating unsubscribe status.@mailinator.com, @example.com, @test.com), always make the real-address contact the primary.pythonfrom datetime import datetime def pick_primary(contacts: list[dict]) -> tuple[dict, list[dict]]: """Return (primary, secondaries). contacts is a list of HubSpot result dicts.""" TEST_DOMAINS = {"mailinator.com","example.com","test.com","yopmail.com"} def is_test(email: str) -> bool: return (email or "").split("@")[-1].lower() in TEST_DOMAINS # Sort oldest first (default primary) sorted_c = sorted(contacts, key=lambda c: c["properties"]["createdate"]) primary = sorted_c[0] # Opt-out override if primary["properties"].get("hs_email_optout") == "true": opted_in = next((c for c in sorted_c[1:] if c["properties"].get("hs_email_optout") != "true"), None) if opted_in: primary = opted_in # Test email override if is_test(primary["properties"].get("email", "")): real = next((c for c in sorted_c if not is_test(c["properties"].get("email", ""))), None) if real: primary = real secondaries = [c for c in contacts if c["id"] != primary["id"]] return primary, secondaries
Exact-email dedup leaves a shadow population. Normalize before comparing:
pythonimport phonenumbers def normalize_email(raw: str) -> str: lower = (raw or "").strip().lower().replace("@googlemail.com", "@gmail.com") local, _, domain = lower.partition("@") if domain == "gmail.com": local = local.split("+")[0].replace(".", "") return f"{local}@{domain}" if domain else lower def normalize_phone(raw: str, region: str = "US") -> str | None: try: p = phonenumbers.parse((raw or "").strip(), region) if phonenumbers.is_valid_number(p): return phonenumbers.format_number(p, phonenumbers.PhoneNumberFormat.E164) except Exception: pass return None
For name similarity and the full confidence-scoring matrix, see implementation-guide.md § Stage 2.
Before merging, verify neither contact has blocking compliance flags:
pythondef pre_merge_check(a: dict, b: dict) -> tuple[bool, str]: """Returns (can_merge, reason). False = queue for human review.""" pa, pb = a["properties"], b["properties"] if pa.get("hs_email_hard_bounce_reason_enum") or pb.get("hs_email_hard_bounce_reason_enum"): return False, "hard_bounce_present" # Asymmetric GDPR legal basis requires human review a_gdpr = bool(pa.get("hs_legal_basis")) b_gdpr = bool(pb.get("hs_legal_basis")) if a_gdpr != b_gdpr: return False, "gdpr_basis_asymmetry" return True, "ok" # Expected post-merge opt-out: conservative — opted out if either contact is opted out def resolve_optout(a: dict, b: dict) -> bool: return (a["properties"].get("hs_email_optout") == "true" or b["properties"].get("hs_email_optout") == "true")
pythonimport time, requests MERGE_URL = "https://api.hubapi.com/crm/v3/objects/contacts/merge" _window_start = time.monotonic() _window_calls = 0 def rate_gate(burst_limit: int = 90) -> None: """Enforce burst limit (90/10s — leaves buffer below HubSpot's 100/10s cap).""" global _window_start, _window_calls elapsed_ms = (time.monotonic() - _window_start) * 1000 if elapsed_ms >= 10_000: _window_start = time.monotonic() _window_calls = 0 if _window_calls >= burst_limit: time.sleep((10_000 - elapsed_ms) / 1000 + 0.05) _window_start = time.monotonic() _window_calls = 0 _window_calls += 1 def merge_contacts(token: str, primary_id: str, secondary_id: str) -> bool: headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} for attempt in range(3): rate_gate() resp = requests.post(MERGE_URL, headers=headers, json={"primaryObjectId": primary_id, "objectIdToMerge": secondary_id}, timeout=30) if resp.status_code == 200: return True if resp.status_code == 429: time.sleep(int(resp.headers.get("Retry-After", "10"))) continue if resp.status_code >= 500: time.sleep(min(60, 5 * 2 ** attempt)) continue # Non-retryable (400, 404, 409) print(f"Merge failed {resp.status_code}: {resp.text}") return False return False
Stop the pipeline before hitting the daily quota:
pythonDAILY_STOP_AT = 480_000 # Stop at 96% of 500K quota def check_quota(resp: requests.Response) -> None: remaining = int(resp.headers.get("X-HubSpot-RateLimit-Daily-Remaining", 500_000)) if (500_000 - remaining) >= DAILY_STOP_AT: raise SystemExit("Daily quota near limit — stopping. Resume after midnight UTC reset.")
After merging, verify that the surviving contact's hs_email_optout matches the expected value (Step 4) and patch it if it drifted. Then audit associations that may not have transferred automatically:
bash# Check associations on surviving contact (replace 12345 with actual primary contact ID) curl -s "https://api.hubapi.com/crm/v4/objects/contacts/12345/associations/deals" \ -H "Authorization: Bearer {your-token}" | jq '[.results[].toObjectId]' # Manually create a missing association (replace 12345 with primary ID, 67890 with deal ID) curl -s -X PUT \ "https://api.hubapi.com/crm/v4/objects/contacts/12345/associations/deals/67890" \ -H "Authorization: Bearer {your-token}" \ -H "Content-Type: application/json" \ -d '[{"associationCategory":"HUBSPOT_DEFINED","associationTypeId":3}]'
The full four-stage Python pipeline (scan → pair → qualify → execute) with automatic association repair is in implementation-guide.md.
| HTTP Status | Error | Root Cause | Action | |---|---|---|---| | 400 | CONTACT_ALREADY_MERGED | Secondary was already merged into another record | Re-fetch secondary; check hs_merged_object_ids for surviving primary ID | | 400 | SAME_OBJECT_MERGE | Both IDs are identical | Remove self-merge pairs from candidate list before executing | | 400 | INVALID_OBJECT_TYPE | One ID belongs to a different CRM object type | Verify via GET /crm/v3/objects/contacts/{id} before merging | | 404 | OBJECT_NOT_FOUND | Contact was deleted between discovery and merge | Re-fetch to confirm existence; skip if deleted | | 409 | MERGE_IN_PROGRESS | A concurrent merge is already running for this contact | Retry after 30 seconds | | 429 | Rate limit | Burst or daily quota exceeded | Honor Retry-After header; check X-HubSpot-RateLimit-Daily-Remaining | | 500 | INTERNAL_ERROR | Transient HubSpot platform fault | Exponential back-off, max 3 retries; log X-HubSpot-Correlation-Id for support | | 200 (silent) | Opt-out propagated incorrectly | "Most recently updated wins" resolved compliance flag wrong | Run post-merge hs_email_optout verification; patch via PATCH endpoint |
bash# Step 1: find the duplicate pair sorted oldest-first SEARCH=$(curl -s -X POST "https://api.hubapi.com/crm/v3/objects/contacts/search" \ -H "Authorization: Bearer {your-token}" -H "Content-Type: application/json" \ -d '{"filterGroups":[{"filters":[{"propertyName":"email","operator":"EQ","value":"jane.doe@example.com"}]}], "properties":["email","createdate"],"sorts":[{"propertyName":"createdate","direction":"ASCENDING"}],"limit":5}') PRIMARY_ID=$(echo "$SEARCH" | jq -r '.results[0].id') SECONDARY_ID=$(echo "$SEARCH" | jq -r '.results[1].id') echo "primary=$PRIMARY_ID secondary=$SECONDARY_ID" # Step 2: merge curl -s -X POST "https://api.hubapi.com/crm/v3/objects/contacts/merge" \ -H "Authorization: Bearer {your-token}" -H "Content-Type: application/json" \ -d "{\"primaryObjectId\":\"$PRIMARY_ID\",\"objectIdToMerge\":\"$SECONDARY_ID\"}" \ | jq '{id, email: .properties.email}'
bashpython3 - <<'EOF' import json, subprocess, sys TOKEN = "{your-token}" EMAIL = "jane.doe@example.com" out = subprocess.run([ "curl","-s","-X","POST","https://api.hubapi.com/crm/v3/objects/contacts/search", "-H",f"Authorization: Bearer {TOKEN}","-H","Content-Type: application/json", "-d", json.dumps({"filterGroups":[{"filters":[{"propertyName":"email","operator":"EQ","value":EMAIL}]}], "properties":["email","firstname","lastname","createdate","lifecyclestage"], "sorts":[{"propertyName":"createdate","direction":"ASCENDING"}],"limit":10}), ], capture_output=True, text=True).stdout data = json.loads(out) contacts = data["results"] if len(contacts) < 2: print("No duplicates found"); sys.exit(0) print(f"Found {len(contacts)} contacts for {EMAIL}:") for c in contacts: p = c["properties"] print(f" ID {c['id']} | created {p['createdate']} | stage {p.get('lifecyclestage')}") print(f"\nWould merge: primary={contacts[0]['id']}, secondaries={[c['id'] for c in contacts[1:]]}") EOF
bashcurl -s -X POST "https://api.hubapi.com/crm/v3/objects/contacts/batch/read" \ -H "Authorization: Bearer {your-token}" -H "Content-Type: application/json" \ -d '{ "inputs": [{"id":"101"},{"id":"202"},{"id":"303"}], "properties": ["email","phone","firstname","lastname","createdate", "lifecyclestage","hs_email_optout","hs_email_hard_bounce_reason_enum"] }' | jq '[.results[] | {id, email:.properties.email, created:.properties.createdate}]'
hs_email_optout on surviving records matches expected value| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-22 | pass→pass | 8,023 | 13,365 | +67% | 1 | 1 | 0% | 1,560 | 6,190 | +297% | 0 | 0 | — |
case-23 | pass→pass | 21,164 | 19,987 | -6% | 1 | 1 | 0% | 3,029 | 7,632 | +152% | 0 | 0 | — |
case-24 | pass→pass | 10,398 | 9,466 | -9% | 1 | 1 | 0% | 1,333 | 6,090 | +357% | 0 | 0 | — |
case-06 | pass→pass | 14,069 | 20,491 | +46% | 1 | 1 | 0% | 2,649 | 7,485 | +183% | 0 | 0 | — |
case-01 | fail→fail | 38,049 | 18,937 | -50% | 1 | 1 | 0% | 5,866 | 5,234 | -11% | 0 | 0 | — |
case-02 | fail→fail | 20,054 | 18,625 | -7% | 1 | 1 | 0% | 4,398 | 8,391 | +91% | 0 | 0 | — |
case-03 | fail→pass | 48,375 | 35,486 | -27% | 1 | 1 | 0% | 7,954 | 10,753 | +35% | 0 | 0 | — |
case-04 | fail→pass | 13,925 | 19,655 | +41% | 1 | 1 | 0% | 2,308 | 8,228 | +256% | 0 | 0 | — |
case-05 | pass→pass | 11,405 | 11,248 | -1% | 1 | 1 | 0% | 2,010 | 5,719 | +185% | 0 | 0 | — |
case-07 | pass→pass | 14,523 | 11,886 | -18% | 1 | 1 | 0% | 2,852 | 6,921 | +143% | 0 | 0 | — |
case-08 | fail→pass | 24,802 | 10,144 | -59% | 1 | 1 | 0% | 3,363 | 6,321 | +88% | 0 | 0 | — |
case-09 | fail→pass | 17,851 | 13,278 | -26% | 1 | 1 | 0% | 1,953 | 5,910 | +203% | 0 | 0 | — |
case-10 | pass→pass | 16,318 | 8,211 | -50% | 1 | 1 | 0% | 1,772 | 5,844 | +230% | 0 | 0 | — |
case-21 | pass→pass | 19,022 | 13,036 | -31% | 1 | 1 | 0% | 2,077 | 6,838 | +229% | 0 | 0 | — |
case-11 | pass→pass | 17,138 | 14,286 | -17% | 1 | 1 | 0% | 2,070 | 6,286 | +204% | 0 | 0 | — |
case-12 | fail→pass | 22,792 | 5,666 | -75% | 1 | 1 | 0% | 1,195 | 5,638 | +372% | 0 | 0 | — |
case-13 | fail→pass | 20,454 | 18,167 | -11% | 1 | 1 | 0% | 1,821 | 6,943 | +281% | 0 | 0 | — |
case-14 | pass→pass | 19,948 | 20,364 | +2% | 1 | 1 | 0% | 2,758 | 7,880 | +186% | 0 | 0 | — |
case-15 | fail→pass | 26,017 | 25,619 | -2% | 1 | 1 | 0% | 3,473 | 8,630 | +148% | 0 | 0 | — |
case-16 | fail→fail | 8,085 | 11,620 | +44% | 1 | 1 | 0% | 1,489 | 5,689 | +282% | 0 | 0 | — |
case-17 | fail→pass | 13,529 | 8,157 | -40% | 1 | 1 | 0% | 1,509 | 6,173 | +309% | 0 | 0 | — |
case-18 | fail→pass | 12,508 | 3,859 | -69% | 1 | 1 | 0% | 1,327 | 5,342 | +303% | 0 | 0 | — |
case-19 | pass→pass | 10,931 | 6,447 | -41% | 1 | 1 | 0% | 1,200 | 5,943 | +395% | 0 | 0 | — |
case-20 | fail→pass | 13,901 | 9,050 | -35% | 1 | 1 | 0% | 1,307 | 5,271 | +303% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 24 cases were attempted, and 23 counted toward the lift figure. The other 1 produced results that are not comparable between the two arms, so they are excluded from the headline rather than averaged into it. The headline lift of +42 percentage points is the difference between those two pass rates over the 23 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.