Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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, rate-limit budgeting, and rollback mitigation across 100K+ record datasets. Use when migrating any source CRM to HubSpot, recovering from a failed import with duplicate or unlinked records, or exporting out of HubSpot before switching platforms. Trigger with "hubspot bulk migration", "salesforce to hubspot", "pipedrive to hubs
.claude/skills/jeremylongshore-hubspot-bulk-migration/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 43% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 146% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 151% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 159% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 138% | 0% |
Move CRM data into HubSpot from Salesforce, Pipedrive, or Copper — or extract it back out — without losing cross-system IDs, breaking associations, or flooding the portal with duplicates. This is not a data-mapping worksheet. It is the code, sequencing, and guardrails your migration runs at 2am against 150K records when the daily API quota is finite, HubSpot has no bulk-delete API, and a bad import leaves permanent junk that requires a support ticket to remove.
The six production failures this skill addresses:
source_crm_id property on every object type before the first record lands, and write the source ID into it during import.batch/upsert not batch/create for contacts.M/D/Y format fail HubSpot's ISO 8601 validation; multi-picklist values not in HubSpot's allowed enumeration are silently dropped. Fix: run a pre-migration dry-run that validates every field against HubSpot's property schema before writing a single record.source_id → hubspot_id mapping file throughout the migration so batch/archive calls are programmable.Auth: set HUBSPOT_ACCESS_TOKEN environment variable to a private app token with CRM write scopes. For token caching, rotation, and multi-portal routing see the hubspot-auth skill in this pack.
crm.objects.contacts.write, crm.objects.companies.write, crm.objects.deals.write, crm.associations.write, crm.schemas.contacts.writepip install requests)HUBSPOT_ACCESS_TOKEN set in environmentBefore importing a single record, create a custom property on every object type to store the source CRM's record ID. Treat 409 CONFLICT as success — property creation is idempotent.
pythonimport os, requests TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"] BASE = "https://api.hubapi.com" HDRS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"} def create_source_id_property(object_type: str, source_crm: str) -> None: prop_name = f"{source_crm}_id" payload = { "name": prop_name, "label": f"{source_crm.title()} ID", "type": "string", "fieldType": "text", "groupName": f"{object_type}information", "description": f"Original {source_crm.title()} record ID. Do not modify.", } resp = requests.post(f"{BASE}/crm/v3/properties/{object_type}", headers=HDRS, json=payload) if resp.status_code not in (200, 201, 409): resp.raise_for_status() for obj in ["contacts", "companies", "deals"]: create_source_id_property(obj, "salesforce") # or "pipedrive", "copper"
Pull HubSpot's property schema and validate every field in your source CSV before touching the API. Catches date format mismatches and invalid enum values upfront.
pythonimport csv, json, re from datetime import datetime def get_property_schema(object_type: str) -> dict: resp = requests.get(f"{BASE}/crm/v3/properties/{object_type}", headers=HDRS) resp.raise_for_status() return {p["name"]: p for p in resp.json()["results"]} def validate_record(record: dict, schema: dict) -> list[str]: errors = [] for field, value in record.items(): if not value or field not in schema: continue prop = schema[field] if prop.get("type") == "date": try: datetime.strptime(str(value), "%Y-%m-%d") except ValueError: errors.append(f"{field}='{value}' must be YYYY-MM-DD") if prop.get("type") == "enumeration": allowed = {o["value"] for o in prop.get("options", [])} if allowed and str(value) not in allowed: errors.append(f"{field}='{value}' not in allowed values: {sorted(allowed)}") return errors
Full dry-run driver and field-by-field transform helpers in implementation-guide.md.
Import in dependency order. Track the source_id → hubspot_id mapping per batch. Save the map to disk after every batch — a mid-run crash leaves a complete map for the records that did land.
pythonimport time, json def batch_import(records, object_type, source_id_field, batch_size=100): """Import records and return {source_id: hubspot_id} mapping.""" endpoint = "upsert" if object_type == "contacts" else "create" url = f"{BASE}/crm/v3/objects/{object_type}/batch/{endpoint}" id_map, errors = {}, [] batches = [records[i:i+batch_size] for i in range(0, len(records), batch_size)] for n, batch in enumerate(batches, 1): if endpoint == "upsert": payload = {"inputs": [{"idProperty": "email", "id": r.get("email",""), "properties": r} for r in batch]} else: payload = {"inputs": [{"properties": r} for r in batch]} resp = requests.post(url, headers=HDRS, json=payload) daily_left = int(resp.headers.get("X-HubSpot-RateLimit-Daily-Remaining", 500_000)) if resp.status_code == 429: time.sleep(int(resp.headers.get("Retry-After", 10))) resp = requests.post(url, headers=HDRS, json=payload) if resp.status_code not in (200, 201, 207): errors.append({"batch": n, "status": resp.status_code}) continue # Daily quota guard — 500K is the Professional/Enterprise daily limit if daily_left < 10_000: raise RuntimeError(f"Daily quota critical: {daily_left} remaining. Resume tomorrow.") for result_item, orig in zip(resp.json().get("results", []), batch): src_id = orig.get(source_id_field, "") hs_id = result_item.get("id", "") if src_id and hs_id: id_map[src_id] = hs_id print(f" Batch {n}/{len(batches)}: {len(batch)} records, daily_left={daily_left}") time.sleep(0.12) # stay below 90 req/10s burst ceiling return id_map, errors def save_id_map(id_map: dict, path: str) -> None: existing = {} try: with open(path) as f: existing = json.load(f) except FileNotFoundError: pass existing.update(id_map) with open(path, "w") as f: json.dump(existing, f, indent=2)
pythondef relink_associations(source_assocs, from_id_map, to_id_map, from_type, to_type, type_id, batch_size=100): """Re-create associations using v4 API. Returns {linked, skipped, errors}.""" url = f"{BASE}/crm/v4/associations/{from_type}/{to_type}/batch/create" linked = skipped = 0 errors = [] batches = [source_assocs[i:i+batch_size] for i in range(0, len(source_assocs), batch_size)] for batch in batches: inputs = [] for a in batch: fhs = from_id_map.get(a["from_source_id"]) ths = to_id_map.get(a["to_source_id"]) if not fhs or not ths: skipped += 1; continue inputs.append({"from": {"id": fhs}, "to": {"id": ths}, "types": [{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": type_id}]}) if not inputs: continue resp = requests.post(url, headers=HDRS, json={"inputs": inputs}) if resp.status_code == 429: time.sleep(int(resp.headers.get("Retry-After", 10))) resp = requests.post(url, headers=HDRS, json={"inputs": inputs}) if resp.status_code not in (200, 201, 207): errors.append({"status": resp.status_code, "body": resp.text[:200]}) else: linked += len(inputs) time.sleep(0.12) return {"linked": linked, "skipped": skipped, "errors": errors} # Standard association type IDs — full table in API_REFERENCE.md CONTACT_TO_COMPANY, DEAL_TO_CONTACT, DEAL_TO_COMPANY = 1, 3, 5
pythondef rollback_migration(id_map_path: str, object_type: str, dry_run: bool = True) -> dict: """Archive all records created during migration. Always dry_run=True first.""" with open(id_map_path) as f: id_map = json.load(f) hubspot_ids = list(id_map.values()) archived, errors = 0, [] for i in range(0, len(hubspot_ids), 100): batch = hubspot_ids[i:i+100] if dry_run: print(f"Would archive: {batch[:3]}...") archived += len(batch); continue resp = requests.post(f"{BASE}/crm/v3/objects/{object_type}/batch/archive", headers=HDRS, json={"inputs": [{"id": hid} for hid in batch]}) if resp.status_code == 204: archived += len(batch) else: errors.append({"status": resp.status_code}) time.sleep(0.12) return {"archived": archived, "errors": errors, "dry_run": dry_run}
| HTTP Status | Error | Root Cause | Action | |---|---|---|---| | 400 | INVALID_PROPERTY_NAME | Field name not in HubSpot schema | Run dry-run; remap source fields to valid HubSpot property names | | 400 | INVALID_ENUMERATION_PROPERTY_VALUE | Picklist value not in allowed options | Pull GET /crm/v3/properties and remap; drop unmappable values | | 400 | INVALID_DATE | Date not in YYYY-MM-DD or epoch ms | Run convert_date() helper (see implementation-guide.md) before import | | 400 | REQUIRED_PROPERTY_MISSING | Upsert idProperty value is empty | Normalize email; route no-email contacts to separate batch/create call | | 207 | Partial batch success | Some records succeeded, some failed | Parse both results[] and errors[]; re-queue failed records | | 409 | DUPLICATE_PROPERTY | Custom property already exists | Treat as success — property creation is idempotent | | 429 | RATE_LIMIT | Burst (100/10s) or daily (500K) quota | Respect Retry-After; switch to CSV import API for daily budget relief | | 500 | HubSpot internal error | Transient portal error | Retry up to 3 times with 2× backoff, 30s cap | | 503 | SERVICE_UNAVAILABLE | HubSpot maintenance | Back off 60s and retry; check status.hubspot.com |
python# 1. Create ID properties for obj in ["contacts", "companies", "deals"]: create_source_id_property(obj, "salesforce") # 2. Import companies first import csv company_rows = [{"salesforce_id": r["Id"], "name": r["Name"], "domain": r.get("Website","").replace("https://","").rstrip("/")} for r in csv.DictReader(open("sf_accounts.csv"))] company_map, _ = batch_import(company_rows, "companies", "salesforce_id") save_id_map(company_map, "company_id_map.json") # 3. Import contacts (upsert by email for dedup) contact_rows = [{"salesforce_id": r["Id"], "email": r.get("Email","").strip().lower(), "firstname": r.get("FirstName",""), "lastname": r.get("LastName","")} for r in csv.DictReader(open("sf_contacts.csv"))] contact_map, _ = batch_import(contact_rows, "contacts", "salesforce_id") save_id_map(contact_map, "contact_id_map.json") # 4. Re-link contact → company source_assocs = [{"from_source_id": r["Id"], "to_source_id": r["AccountId"]} for r in csv.DictReader(open("sf_contacts.csv")) if r.get("AccountId")] print(relink_associations(source_assocs, contact_map, company_map, "contacts", "companies", CONTACT_TO_COMPANY))
bashcurl -s "https://api.hubapi.com/crm/v3/objects/contacts?properties=email,salesforce_id&limit=5" \ -H "Authorization: Bearer $HUBSPOT_ACCESS_TOKEN" | \ jq '.results[] | {id, email: .properties.email, sf_id: .properties.salesforce_id}'
pythonprint(rollback_migration("contact_id_map.json", "contacts", dry_run=True)) # Confirm output, then: # rollback_migration("contact_id_map.json", "contacts", dry_run=False)
salesforce_id, pipedrive_id, or copper_id) created on contacts, companies, and deals before first writesource_id → hubspot_id mapping files written per-batch for rollback coverage| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-13 | pass→pass | 19,623 | 10,539 | -46% | 1 | 1 | 0% | 2,265 | 6,100 | +169% | 0 | 0 | — |
case-12 | pass→pass | 14,543 | 13,872 | -5% | 1 | 1 | 0% | 1,669 | 5,864 | +251% | 0 | 0 | — |
case-01 | fail→pass | 45,286 | 49,226 | +9% | 1 | 1 | 0% | 8,287 | 11,811 | +43% | 0 | 0 | — |
case-02 | fail→pass | 20,080 | 17,223 | -14% | 1 | 1 | 0% | 2,777 | 6,841 | +146% | 0 | 0 | — |
case-03 | fail→pass | 21,929 | 17,308 | -21% | 1 | 1 | 0% | 3,102 | 7,801 | +151% | 0 | 0 | — |
case-04 | pass→pass | 14,839 | 16,759 | +13% | 1 | 1 | 0% | 2,617 | 6,463 | +147% | 0 | 0 | — |
case-05 | fail→pass | 18,540 | 14,961 | -19% | 1 | 1 | 0% | 2,331 | 6,048 | +159% | 0 | 0 | — |
case-06 | fail→fail | 18,498 | 23,257 | +26% | 1 | 1 | 0% | 3,492 | 7,877 | +126% | 0 | 0 | — |
case-07 | pass→pass | 21,017 | 19,882 | -5% | 1 | 1 | 0% | 3,010 | 7,669 | +155% | 0 | 0 | — |
case-08 | fail→pass | 14,410 | 16,013 | +11% | 1 | 1 | 0% | 2,687 | 6,393 | +138% | 0 | 0 | — |
case-09 | pass→pass | 11,935 | 15,856 | +33% | 1 | 1 | 0% | 2,125 | 6,105 | +187% | 0 | 0 | — |
case-10 | pass→pass | 12,489 | 3,850 | -69% | 1 | 1 | 0% | 1,356 | 4,835 | +257% | 0 | 0 | — |
case-11 | pass→pass | 24,138 | 20,185 | -16% | 1 | 1 | 0% | 3,170 | 7,463 | +135% | 0 | 0 | — |
case-14 | pass→pass | 19,073 | 19,300 | +1% | 1 | 1 | 0% | 2,482 | 6,972 | +181% | 0 | 0 | — |
case-15 | pass→pass | 11,715 | 17,917 | +53% | 1 | 1 | 0% | 1,179 | 6,894 | +485% | 0 | 0 | — |
case-16 | fail→pass | 23,507 | 15,132 | -36% | 1 | 1 | 0% | 2,727 | 6,122 | +124% | 0 | 0 | — |
case-17 | fail→fail | 11,953 | 8,161 | -32% | 1 | 1 | 0% | 1,175 | 4,806 | +309% | 0 | 0 | — |
case-18 | pass→pass | 11,880 | 4,201 | -65% | 1 | 1 | 0% | 1,200 | 4,923 | +310% | 0 | 0 | — |
case-19 | pass→fail | 23,448 | 27,184 | +16% | 1 | 1 | 0% | 2,671 | 8,910 | +234% | 0 | 0 | — |
case-20 | pass→pass | 20,100 | 28,465 | +42% | 1 | 1 | 0% | 2,538 | 8,705 | +243% | 0 | 0 | — |
case-21 | pass→pass | 17,697 | 23,774 | +34% | 1 | 1 | 0% | 3,341 | 8,163 | +144% | 0 | 0 | — |
case-22 | pass→fail | 10,173 | 12,726 | +25% | 1 | 1 | 0% | 1,894 | 6,699 | +254% | 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. 22 cases were attempted. The headline lift of +18 percentage points is the difference between those two pass rates over the 22 comparable cases. 2 cases got worse with the skill loaded, and they are included in that figure.
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.