Claude-code-plugins hubspot-migration-deep-dive
install
source · Clone the upstream repo
git clone https://github.com/jeremylongshore/claude-code-plugins-plus-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/jeremylongshore/claude-code-plugins-plus-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/saas-packs/hubspot-pack/skills/hubspot-migration-deep-dive" ~/.claude/skills/jeremylongshore-claude-code-plugins-hubspot-migration-deep-dive && rm -rf "$T"
manifest:
plugins/saas-packs/hubspot-pack/skills/hubspot-migration-deep-dive/SKILL.mdsource content
HubSpot Migration Deep Dive
Overview
Comprehensive guide for migrating CRM data into HubSpot, including data mapping, batch imports via API, validation, and rollback procedures.
Prerequisites
- Source CRM data exported (CSV or API access)
- HubSpot account with required scopes
- Custom properties created in HubSpot for non-default fields
Instructions
Step 1: Data Inventory and Mapping
// Map source CRM fields to HubSpot properties interface FieldMapping { sourceField: string; hubspotProperty: string; transform?: (value: string) => string; required: boolean; } const contactFieldMap: FieldMapping[] = [ { sourceField: 'Email', hubspotProperty: 'email', required: true }, { sourceField: 'First Name', hubspotProperty: 'firstname', required: false }, { sourceField: 'Last Name', hubspotProperty: 'lastname', required: false }, { sourceField: 'Phone', hubspotProperty: 'phone', required: false }, { sourceField: 'Company', hubspotProperty: 'company', required: false }, { sourceField: 'Lead Status', hubspotProperty: 'lifecyclestage', transform: (val) => { // Map source values to HubSpot lifecycle stages const map: Record<string, string> = { 'New': 'lead', 'Qualified': 'marketingqualifiedlead', 'Won': 'customer', }; return map[val] || 'lead'; }, required: false, }, ]; function mapRecord( source: Record<string, string>, fieldMap: FieldMapping[] ): Record<string, string> { const mapped: Record<string, string> = {}; for (const field of fieldMap) { const value = source[field.sourceField]; if (value !== undefined && value !== '') { mapped[field.hubspotProperty] = field.transform ? field.transform(value) : value; } else if (field.required) { throw new Error(`Missing required field: ${field.sourceField}`); } } return mapped; }
Step 2: Create Custom Properties Before Import
import * as hubspot from '@hubspot/api-client'; const client = new hubspot.Client({ accessToken: process.env.HUBSPOT_ACCESS_TOKEN!, }); // Create custom properties that don't exist in HubSpot async function ensureCustomProperties(objectType: string) { const customProps = [ { name: 'source_crm_id', label: 'Source CRM ID', type: 'string', fieldType: 'text', groupName: 'contactinformation', description: 'Original record ID from source CRM', }, { name: 'migration_date', label: 'Migration Date', type: 'date', fieldType: 'date', groupName: 'contactinformation', description: 'Date record was migrated to HubSpot', }, ]; for (const prop of customProps) { try { // POST /crm/v3/properties/{objectType} await client.crm.properties.coreApi.create(objectType, prop); console.log(`Created property: ${prop.name}`); } catch (error: any) { if (error?.body?.category === 'DUPLICATE_PROPERTY') { console.log(`Property already exists: ${prop.name}`); } else { throw error; } } } }
Step 3: Batch Import with Progress Tracking
interface MigrationResult { total: number; created: number; updated: number; errors: Array<{ record: any; error: string }>; durationMs: number; } async function migrateContacts( records: Record<string, string>[], fieldMap: FieldMapping[] ): Promise<MigrationResult> { const start = Date.now(); const result: MigrationResult = { total: records.length, created: 0, updated: 0, errors: [], durationMs: 0, }; // Process in batches of 100 (HubSpot batch limit) const batchSize = 100; for (let i = 0; i < records.length; i += batchSize) { const batch = records.slice(i, i + batchSize); const mapped = []; for (const record of batch) { try { const properties = mapRecord(record, fieldMap); properties.migration_date = new Date().toISOString().split('T')[0]; properties.source_crm_id = record.Id || record.id || ''; mapped.push({ properties }); } catch (error: any) { result.errors.push({ record, error: error.message }); } } if (mapped.length === 0) continue; try { // Use batch upsert to handle existing contacts // POST /crm/v3/objects/contacts/batch/upsert const response = await client.apiRequest({ method: 'POST', path: '/crm/v3/objects/contacts/batch/upsert', body: { inputs: mapped.map(m => ({ properties: m.properties, idProperty: 'email', id: m.properties.email, })), }, }); const data = await response.json(); result.created += data.results?.length || 0; } catch (error: any) { // On batch failure, try individual records for (const item of mapped) { try { await client.crm.contacts.basicApi.create({ properties: item.properties, associations: [], }); result.created++; } catch (err: any) { if (err?.body?.category === 'CONFLICT') { // Contact exists, update instead const existing = await client.crm.contacts.searchApi.doSearch({ filterGroups: [{ filters: [{ propertyName: 'email', operator: 'EQ', value: item.properties.email }], }], properties: ['email'], limit: 1, after: 0, sorts: [], }); if (existing.results.length > 0) { await client.crm.contacts.basicApi.update(existing.results[0].id, { properties: item.properties, }); result.updated++; } } else { result.errors.push({ record: item.properties, error: err.message }); } } } } // Progress logging const progress = Math.min(i + batchSize, records.length); console.log(`Progress: ${progress}/${records.length} ` + `(${result.created} created, ${result.updated} updated, ${result.errors.length} errors)`); // Rate limit: max 10 requests/second await new Promise(r => setTimeout(r, 200)); } result.durationMs = Date.now() - start; return result; }
Step 4: Migrate Deals with Associations
async function migrateDeals( deals: any[], contactEmailToId: Map<string, string> ): Promise<MigrationResult> { const result: MigrationResult = { total: deals.length, created: 0, updated: 0, errors: [], durationMs: 0, }; const start = Date.now(); // Get pipeline stages const pipelines = await client.crm.pipelines.pipelinesApi.getAll('deals'); const defaultPipeline = pipelines.results[0]; for (const deal of deals) { try { const associations = []; // Associate with contact if we have a mapping if (deal.contactEmail && contactEmailToId.has(deal.contactEmail)) { associations.push({ to: { id: contactEmailToId.get(deal.contactEmail)! }, types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 3 }], }); } await client.crm.deals.basicApi.create({ properties: { dealname: deal.name, amount: String(deal.amount || 0), pipeline: defaultPipeline.id, dealstage: defaultPipeline.stages[0].id, closedate: deal.closeDate || new Date().toISOString(), source_crm_id: deal.id || '', }, associations, }); result.created++; } catch (error: any) { result.errors.push({ record: deal, error: error.message }); } } result.durationMs = Date.now() - start; return result; }
Step 5: Post-Migration Validation
async function validateMigration( expectedCounts: { contacts: number; deals: number } ): Promise<{ valid: boolean; checks: any[] }> { const checks = []; // Count contacts const contacts = await client.crm.contacts.searchApi.doSearch({ filterGroups: [{ filters: [{ propertyName: 'migration_date', operator: 'HAS_PROPERTY', value: '' }], }], properties: ['email'], limit: 1, after: 0, sorts: [], }); checks.push({ check: 'Contact count', expected: expectedCounts.contacts, actual: contacts.total, passed: contacts.total >= expectedCounts.contacts * 0.95, // 95% threshold }); // Check for required fields const missingEmail = await client.crm.contacts.searchApi.doSearch({ filterGroups: [{ filters: [ { propertyName: 'migration_date', operator: 'HAS_PROPERTY', value: '' }, { propertyName: 'email', operator: 'NOT_HAS_PROPERTY', value: '' }, ], }], properties: ['firstname'], limit: 1, after: 0, sorts: [], }); checks.push({ check: 'Contacts with email', missing: missingEmail.total, passed: missingEmail.total === 0, }); return { valid: checks.every(c => c.passed), checks, }; }
Output
- Field mapping from source CRM to HubSpot properties
- Custom properties created before import
- Batch upsert with progress tracking and error recovery
- Deal migration with contact associations
- Post-migration validation with threshold checks
Error Handling
| Issue | Cause | Solution |
|---|---|---|
| Custom property not created | Run first |
| Contact email already exists | Use batch upsert instead of batch create |
| Batch partial failure | Some records invalid | Fall back to individual creates |
| Association failure | Contact not yet created | Import contacts before deals |
Resources
Next Steps
For advanced troubleshooting, see
hubspot-advanced-troubleshooting.