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 valuenpx claudepluginhub jeremylongshore/claude-code-plugins-plus-skills --plugin hubspot-pack2plugins reuse this skill
First indexed Jul 18, 2026
Identifies duplicate company records in HubSpot by domain and name, exports audit CSVs, and guides merging via UI (no bulk merge API).
Bulk-migrate CRM data into HubSpot from Salesforce, Pipedrive, or Copper — or export off HubSpot — with field mapping, ID continuity, association re-linking, dedup safety, and rate-limit budgeting.
Deduplicates Podium contacts in production, normalizing phone numbers to e164, preserving opt-out state through merges, and handling cross-location duplicates and race conditions.