sales-i to HubSpot: a keyed batch migration validated in a sandbox

A dual-brand packaging distributor

  • Distribution
  • Canada
  • Migration, delivered in 2026
Company records in the export
28,818
Companies in the reconciled load
7,942
Contacts in the reconciled load
10,861
Call records in the reconciled load
26,006
Failed records in the reconciled load
0

A packaging distributor was retiring the sales layer that sat on top of its Pronto XI ERP. We profiled the export, 28,818 company records, 11,875 contacts after dedupe, 2,641 opportunities and 31,556 call records, then cleaned it in code behind a batch upsert keyed on a source identifier per object. The account scope moved to an ERP-first set before the full run, which reconciled in a sandbox at 7,942 companies, 10,861 contacts, 3,598 deals and 26,006 calls, with zero failed records.

01 The Challenge

The deadline was a contract date, not a preference. That layer, sales-i, has no database of its own, so the four files are effectively a snapshot of the ERP’s customer, contact, opportunity and call data, and once the layer went away there was no second export to go back for.

Two built-in import routes existed, and neither fit. The no-code importer caps a job at about two objects, so a four-object load with six association types becomes a chain of manual, click-driven imports that nobody can repeat for a sandbox and then a production portal. The imports API can do the whole thing in one job, but records loaded that way are not filterable in lists, reports and workflows afterwards, and activities cannot be updated once created, which removes the fix path on a load nobody gets perfect first time.

The data closed a third route. Email could not be the contact key: 5,036 contacts have no email address at all, and 416 email addresses are shared by 912 contacts. One fact made the rest workable. Referential integrity across the four files was clean, with no orphans on any foreign key, so a keyed load in parent-first order would resolve every link.

02 The Solution

Clean in code, then upsert each object on a custom property that holds its source identifier, then build associations in a pass of their own. The first phase creates those properties and the deal pipeline, reads back the generated stage identifiers, and stamps a source system and a migration batch on every record, which makes the cohort queryable and reversible later.

Companies load first, keyed on the account number, so contacts, deals and calls resolve against records that already exist. Contacts key on the source contact id: the first holder of a shared email address keeps it, later collisions move to a text property, and contacts with no email address load anyway.

Calls were the object that forced a design change. Activities carry no unique property, so they cannot be upserted at all. The loader creates them and deduplicates against a map of source note ids read from the portal, which keeps a re-run safe. Each call needs an explicit historical timestamp or the log collapses onto the day of the load, and an association or it is invisible on a record.

Deals take internal pipeline and stage identifiers rather than labels, and the 105 rows with a blank stage land in a deliberate stage. Under all of it, a batch that returns a 4xx is split and retried down to single records, and a contact with an invalid email address is salvaged by blanking the address and keeping the record.

The scope then changed. The loader was validated in early June. Two days later the plan was revised to load accounts and 2026 invoices from a Pronto snapshot rather than from the export’s customer and opportunity files, dropping the sales-i opportunities. A second transform and loader were built for that source, on the same keyed mechanism.

03 The Outcome

That first validation run settled five things documentation alone had not. A company can carry a unique property, so the search then upsert workaround older guidance implies was unnecessary. Calls cannot be upserted by any key, so they are created and deduplicated instead. Call duration is stored in milliseconds. The stage mapping lands on the right internal identifiers, and a second full run updates in place rather than duplicating.

The full sandbox run reconciled exactly: 7,942 companies, 10,861 contacts, 3,598 deals and 26,006 calls, the transform output matching the portal on every object, with zero failed records. Every contact, deal and call carried its company association. Web-shop accounts were left out by agreement, accounts with no purchase activity since 2020 were dropped, and the products and line items phase stopped on a missing API scope.

Four days later the pipeline was re-pointed at an aggregate export from the ERP, one authoritative customer set instead of the four files. That run reconciled at 12,176 companies and 10,797 line items with zero failures. The loader then gained a recurring mode: an incremental pass on a last-change watermark with a daily reconcile. Production cutover waited on the client’s sign-off.

How the lifecycle runs now

  1. Export

    The retiring sales layer and the ERP

    1. Source: Four exported files from sales-i Next: Cleaned in code into one record per line, in Clean.
    2. Source: Pronto snapshot of accounts and 2026 invoices Next: Cleaned in code into one record per line, in Clean.
  2. Clean

    Python scripts, in code

    1. Cleaned in code into one record per line Next: Key properties and deal pipeline created first.
    2. In HubSpot: Key properties and deal pipeline created first Next: Source system and batch stamped on every record.
    3. Source system and batch stamped on every record Next: Companies upsert on the account number, in Load.
  3. Load

    Keyed batch upsert, parents first

    1. In HubSpot: Companies upsert on the account number Next: Contacts upsert on the source contact id.
    2. In HubSpot: Contacts upsert on the source contact id Next: Email already held by an earlier contact?.
    3. Decision: Email already held by an earlier contact? Yes: Email moves to a text property. No: Deals set by internal pipeline and stage ids.
    4. Email moves to a text property Next: Deals set by internal pipeline and stage ids.
    5. In HubSpot: Deals set by internal pipeline and stage ids Next: Calls created, deduplicated on source note id.
    6. In HubSpot: Calls created, deduplicated on source note id Next: Five association types from load responses, in Associate.
  4. Associate

    A separate pass after the load

    1. In HubSpot: Five association types from load responses Next: A second full run updates in place, in Reconcile.
  5. Reconcile

    Sandbox first, gated on counts

    1. A second full run updates in place Next: Transform output compared with the portal.
    2. Transform output compared with the portal Next: Zero failed records in the sandbox.
    3. Outcome, done: Zero failed records in the sandbox
The map follows the migration run, from the exported files and the ERP snapshot that replaced part of their scope to a reconciled sandbox. Each object loads on its own source key, parents first, and associations follow in a separate pass.

04 Stack

Python with its standard library and one HTTP client, files in, one JSON record per line out. Writes go to the HubSpot CRM batch endpoints for companies, contacts and deals, the create endpoint for calls, and the version 4 default association endpoint, all on API version 2026-03. There is no hosted service, and the recurring pass runs the same code again on a watermark rather than on a scheduler of its own.

Operations is a runbook rather than a dashboard: dry run and read the payloads, smoke test a couple of hundred records, load the sandbox in full, run it again to prove idempotency, reconcile, then swap the token and run the same code against production. Rollback is a query on the migration batch stamp, then an archive of the cohort, then a corrected re-run.

05 Architecture and data model

Nothing streams. Files are profiled, cleaned into one record per line, and loaded object by object in parent-first order, with each phase gated on its own counts. Associations are built afterwards from identifiers resolved out of the load responses, so no edge is guessed. A reconciliation step compares the source against the portal before the next phase starts, and no child object loads before the parent it will hang from.

Source fileHubSpot objectDirectionMatch key
CustomersCompanyTo HubSpotAccount number, a unique custom property
ContactsContactTo HubSpotSource contact id, never the email address
OpportunitiesDealTo HubSpotSource opportunity id
Notes, which are a call logCall engagementTo HubSpotSource note id, deduplicated at create time

That is the mapping as built for the export. Three of the four survived the scope change: the opportunity mapping was dropped, and the deals in the reconciled load are 2026 invoices from the ERP’s sales history instead. Each remaining key is the source system’s own identifier, held in a custom property that HubSpot enforces as unique, which is what lets a second run update rather than duplicate. Calls are the exception the portal forces: activities cannot hold a unique property, so their key lives in the loader instead, as a map of source note ids read back from the portal before each run.

Transform legend: clean is trim and null-token handling, phone is a best-effort E.164 rewrite, country normalizes 18 spellings, date to ms is epoch milliseconds with the 1900 sentinel nulled, dt to iso is ISO 8601, min to ms converts minutes.

Twelve rows of the field mapping:

Source columnHubSpot propertyTransformNote
Account Numbersalesi_account_numbercleanUnique, the company upsert key
Customer Namenameclean
Telephone 1phonephoneVanity numbers are left for review
Countrycountrycountry18 spellings normalized
Last Order Datesalesi_last_order_datedate to ms17,565 rows carry a 1900 sentinel
Customer TypelifecyclestagecleanProspect becomes lead, customer becomes customer
sales-i Contact Idsalesi_contact_idcleanUnique, the contact upsert key
Emailemail or salesi_shared_emailclean and validateThe first holder keeps the email
Value Of Quoteamountnum
Stagedealstage and pipelinestage mapInternal ids, never labels
Note Call Datehs_timestampdt to isoRequired, sets the timeline position
Call Durationhs_call_durationmin to msA 60 minute call stores 3,600,000
Four exported files are profiled and cleaned in code into one record per line. An ERP snapshot replaces part of that scope before the full run. Companies load first, keyed on the account number, then contacts, deals and the call log, each keyed on its own identifier. A separate pass builds five association edge types, and a reconciliation step compares counts against the portal.
Hosting
No hosted service, the loader runs from a workstation
Runtime
Python scripts over the HubSpot CRM batch and association endpoints
Cadence
One-time load in gated phases, then a recurring incremental sync
Pattern
One-time keyed batch migration
Fields mapped
52

Fifty-two mapping rows across four objects in the export, each object keyed on its own source identifier rather than on an email address or a name.

What we would do again, and differently

What we would do again

  • Key every object on its own source identifier and never on an email address. It is the decision the whole load rests on, and it is cheap only while the transform is still being written.
  • Prove the unknowns against a sandbox before the mapping is final. Three of the answers changed the loader, and meeting any of them during a production run would have cost a rollback.

What we would do differently

  • Settle the authoritative source before mapping the whole export. The scope moved twice, to an ERP-first set days before the full run and again to the ERP’s aggregate export four days after it. The opportunity mapping was dropped along the way.
  • Ask for every API scope the load needs at provisioning time. The products and line items phase stopped on a scope the private app did not have, which is an avoidable pause in the middle of a gated run.

Systems in this build

Partnerships and accreditations

  • HubSpot Elite Solutions Partner
  • HubSpot Custom Integration Accreditation
  • HubSpot CRM Data Migration Accreditation
  • HubSpot Solutions Architecture Design Accreditation

HubSpot accreditations

  • HubSpot Onboarding Accreditation
  • HubSpot Academy Accreditations

Industry specializations

  • HubSpot Healthcare Industry Specialist
  • HubSpot Financial Services Industry Specialist
  • HubSpot Manufacturing Industry Specialist
  • HubSpot Quote to Cash Capability

Partnerships

  • Google Partner
  • Anthropic Solutions Partner

Have a system in the same shape?

Send the systems, the direction and the record volume. We reply with a scope and an effort band within one business day.

Press Enter or comma to add another system.

Add more detail (optional)

Optional. A sentence or two about the workflow is enough.

We use these answers to scope the work and for nothing else. Privacy policy.

Prefer to talk it through first? Book a technical scoping call

Talk to the people who build these

  • Nijat
  • Sean
  • Prash

Book a scoping call