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
-
Export
The retiring sales layer and the ERP
- Source: Four exported files from sales-i Next: Cleaned in code into one record per line, in Clean.
- Source: Pronto snapshot of accounts and 2026 invoices Next: Cleaned in code into one record per line, in Clean.
-
Clean
Python scripts, in code
- Cleaned in code into one record per line Next: Key properties and deal pipeline created first.
-
In HubSpot: Key properties and deal pipeline created first Next: Source system and batch stamped on every record.
- Source system and batch stamped on every record Next: Companies upsert on the account number, in Load.
-
Load
Keyed batch upsert, parents first
-
In HubSpot: Companies upsert on the account number Next: Contacts upsert on the source contact id.
-
In HubSpot: Contacts upsert on the source contact id Next: Email already held by an earlier contact?.
- Decision: Email already held by an earlier contact? Yes: Email moves to a text property. No: Deals set by internal pipeline and stage ids.
- Email moves to a text property Next: Deals set by internal pipeline and stage ids.
-
In HubSpot: Deals set by internal pipeline and stage ids Next: Calls created, deduplicated on source note id.
-
In HubSpot: Calls created, deduplicated on source note id Next: Five association types from load responses, in Associate.
-
-
Associate
A separate pass after the load
-
In HubSpot: Five association types from load responses Next: A second full run updates in place, in Reconcile.
-
-
Reconcile
Sandbox first, gated on counts
- A second full run updates in place Next: Transform output compared with the portal.
- Transform output compared with the portal Next: Zero failed records in the sandbox.
- Outcome, done: Zero failed records in the sandbox
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 file | HubSpot object | Direction | Match key |
|---|---|---|---|
| Customers | Company | To HubSpot | Account number, a unique custom property |
| Contacts | Contact | To HubSpot | Source contact id, never the email address |
| Opportunities | Deal | To HubSpot | Source opportunity id |
| Notes, which are a call log | Call engagement | To HubSpot | Source 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 column | HubSpot property | Transform | Note |
|---|---|---|---|
| Account Number | salesi_account_number | clean | Unique, the company upsert key |
| Customer Name | name | clean | |
| Telephone 1 | phone | phone | Vanity numbers are left for review |
| Country | country | country | 18 spellings normalized |
| Last Order Date | salesi_last_order_date | date to ms | 17,565 rows carry a 1900 sentinel |
| Customer Type | lifecyclestage | clean | Prospect becomes lead, customer becomes customer |
| sales-i Contact Id | salesi_contact_id | clean | Unique, the contact upsert key |
| email or salesi_shared_email | clean and validate | The first holder keeps the email | |
| Value Of Quote | amount | num | |
| Stage | dealstage and pipeline | stage map | Internal ids, never labels |
| Note Call Date | hs_timestamp | dt to iso | Required, sets the timeline position |
| Call Duration | hs_call_duration | min to ms | A 60 minute call stores 3,600,000 |
- 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 accreditations
Industry specializations
Partnerships
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.
Thanks, we will reply within one business day
Prash or Sean reads every one of these and comes back with a scope, or with the one question we need answered first.