From hubspot-pack
Deduplicates HubSpot contacts at scale, handling import storms, fuzzy matching, rate limits, and association orphans for post-merge recovery.
How this skill is triggered — by the user, by Claude, or both
Slash command
/hubspot-pack:hubspot-contact-dedupThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
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...
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:
Authorization: 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.
# 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":"[email protected]"}
]}],
"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.from 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:
import 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:
def 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")
import 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:
DAILY_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:
# 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 |
# 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":"[email protected]"}]}],
"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}'
python3 - <<'EOF'
import json, subprocess, sys
TOKEN = "{your-token}"
EMAIL = "[email protected]"
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
curl -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 value2plugins reuse this skill
First indexed Jul 18, 2026
npx claudepluginhub pw00kt/fuzzy-sniffle --plugin hubspot-packDeduplicates HubSpot contacts at scale, handling import storms, fuzzy matching, rate limits, and association orphans for post-merge recovery.
Identifies duplicate company records in HubSpot by domain and name, exports audit CSVs, and guides merging via UI (no bulk merge API).
Syncs contacts, deals, and campaigns bi-directionally with Salesforce, HubSpot, Zoho, or Pipedrive. Includes deduplication, field mapping, audit trails, and rollback.