You can execute a Salesforce to HubSpot migration without losing critical information or disrupting...
A 32-object export, a two-person-per-record contact model, and an Excel engine that ran the entire business. Here's how we rebuilt all of it in HubSpot without losing a single booking.
Placeholders to fill before publishing: client name or descriptor, region, and the results figures in the Outcomes section.
Quick answer: Migrating a decade of Act! CRM data into HubSpot without losing bookings meant solving four problems before importing a single row: splitting shared two-person contact records into individual people, preserving a four-level object hierarchy (contact, trip, itinerary, operator), rebuilding the client's real operational engine (a linked Excel system) as middleware instead of just moving the CRM, and working around a vendor-gated export process with no direct database access. The fix combined a custom identity-resolution model, a 100-record pilot before the full migration, and AWS middleware that keeps the spreadsheet workflow alive while writing straight into HubSpot.
Client snapshot
| Industry | Bespoke tours and travel, multi-country itineraries |
| Legacy system | Act! CRM, in continuous use for 10+ years |
| Team | Sales consultants, operations staff, finance |
| Records migrated | Contacts, trips, itinerary lines, operators, booking records |
| Target | HubSpot Sales Hub + Operations Hub, custom objects, custom-coded middleware |
| Duration | Weeks, discovery to hypercare exit |
This client sells long-form, high-value guided travel. A single booking is a 10 to 15 day itinerary across multiple countries, handled on the ground by a chain of local operator companies, sold to couples and small groups who often travel with them six or seven times over a decade.
Ten years of that lives in Act!. And Act! had been bent into a shape no off-the-shelf migration tool could read.
Why this migration was different
Most CRM migrations are a mapping exercise. Contacts to contacts, deals to deals, done. This one had four structural problems that had to be solved before a single row could be imported.
1. Two humans, one record
In Act!, a travelling couple was stored as a single contact record with a primary and a secondary contact inside it. Husband and wife, or two friends travelling as a pair, shared one Act! ID.
That model breaks immediately in HubSpot. HubSpot's contact object is one record per person. Marketing consent, communication history, passport data, and dietary requirements all belong to individuals, not to a pair.
Worse, the source data was inconsistent:
- Some pairs had two distinct email addresses
- Many had one email shared between both people
- Some had an email on the primary and nothing on the secondary
- The same pair appeared across six or seven historical trips, sometimes with the email on a different half of the pair each time
HubSpot deduplicates contacts on email address by default. Importing this data naively would have merged spouses into one another and collapsed a decade of travel history into the wrong person.
2. A four-level object hierarchy
Act! held a custom object structure that looked like this:
- Contact (primary + secondary)
- Trip (the booking: value, dates, group, operators)
- Itinerary (day-by-day destination breakdown, 10 to 15 rows per trip)
- Operator (one to many per trip, handling different legs)
- Booking form data (passports, signed docs, dietary plans)
- Trip (the booking: value, dates, group, operators)
Every layer had to survive with its relationships intact. An itinerary line detached from its trip is meaningless. An operator not linked to the correct leg of the correct trip is an operational failure with a customer standing in an airport.
3. The business ran on Excel, not on the CRM
The real operational engine was a spreadsheet system the client called JPS. When a trip was confirmed, a client-specific itinerary sheet was generated automatically from a master brain sheet holding collections, amounts, operator rates, and supplier data. Operations worked that sheet for the life of the booking. When the trip closed out, the finished numbers were synced back into Act!.
Migrating the CRM without migrating this loop would have delivered a beautiful HubSpot portal that nobody used.
4. Nobody could export the data
This was the constraint that shaped the entire project plan.
Neither the client nor our team had access to the underlying Act! SQL database. No table-level export, no direct query access, no ODBC connection. The only route to the data was through the Act! vendor's own export team.
That meant every export was a formal request with a turnaround time. We could not iterate quickly, we could not re-pull a table because we misread a column, and we could not afford to discover a schema surprise late.
Phase 1: Discovery and data model design
Before requesting any data, we mapped the target architecture. Getting this wrong would mean re-importing tens of thousands of records.
| Act! CRM | HubSpot | Notes |
|---|---|---|
| Contact (primary) | Contact | Standard object, one record per person |
| Contact (secondary) | Contact | Separate record, labelled association back to primary |
| Company / agency | Company | Standard object |
| Operator | Custom object: Operator | Kept separate from Company so partner records and ground-handler records never mix in reporting |
| Trip | Deal | Needs amount, pipeline, stages, forecasting |
| Itinerary | Custom object: Itinerary | Child of Deal, one record per travel day or leg |
| Booking form / pax data | Custom object: Booking (Pax) | Passport, dietary, signature docs, one per traveller per trip |
| Brain sheet output | Deal properties + Operator associations | Written by middleware, not manually |
| Act! user | HubSpot owner | Includes deactivated historical users |
Two decisions here did most of the heavy lifting later.
Trips became Deals, not a custom object. Trips have a value, a lifecycle, a close date, and a forecast. Deals give the client pipelines, revenue reporting, and forecasting for free. A custom object would have meant rebuilding all of that.
Operators became a custom object rather than Companies. The client works with both travel agency partners and in-country ground handlers. Both are "companies" in the loosest sense, but they behave nothing alike. Splitting them keeps operator performance reporting clean and prevents a decade of partner records from being polluted by supplier data.
Solving the identity problem
This was the technical centrepiece of the migration.
Custom unique identifiers instead of email
We created a custom property on the Contact object, act_contact_id, and set it as a unique identifier property in HubSpot. We added a second property, act_contact_role, with values Primary or Secondary.
Because the same Act! ID belonged to both halves of a pair, the actual dedupe key became a composite written into a third property: act_identity_key = {ACT_ID}-{ROLE}.
Imports were then run against act_identity_key rather than email. This meant:
- Two people sharing one email address stayed as two records
- Re-running an import updated the correct record instead of creating duplicates
- Every HubSpot record remained traceable back to its Act! origin for audit
Handling shared and missing emails
We built a pre-import resolution pass over the raw export that classified every pair into one of four cases:
- Two valid, distinct emails: imported as-is
- One shared email across both: email assigned to the primary, secondary imported with email blank plus a flag property shared_email_with_primary
- Email on secondary only: retained on the secondary, primary flagged for enrichment
- No email on either: imported as non-marketing records, flagged for the sales team to complete
Every record in categories 2, 3, and 4 was surfaced in a review workbook and signed off by the client before import. No automated guessing on identity. Pairs requiring manual adjudication were flagged and resolved individually.
Secondary contacts with no independent email were set as non-marketing contacts, which kept consent handling clean and avoided inflating the client's marketing contact tier.
Association labels
We built custom labelled associations so the model reads correctly in the UI and in reporting:
- Contact ↔ Contact: Primary Traveller / Secondary Traveller
- Deal → Contact: Primary Traveller, Secondary Traveller, Booker, Payer
- Deal → Operator: Ground Handler, with the itinerary leg referenced on the association
- Deal → Itinerary: parent-child
- Deal → Booking (Pax): one Booking record per traveller per trip
A consultant opening a Deal now sees both travellers, correctly labelled, plus every operator on the trip and the full day-by-day itinerary, in one view.
Phase 2: The 32-object export and the pilot import
With the model designed, we went to the Act! vendor's export team.
What came back was 32 separate objects and sheets. Not all of them were live data. Some were legacy fields abandoned years earlier, some were duplicated views of the same underlying table, and some were system artefacts.
We worked through all 32 with the client's operations lead and narrowed it to 16 sheets that genuinely needed to reach HubSpot. Everything else was documented and archived rather than migrated. Cutting 16 objects out of scope was one of the highest-value decisions in the project.
The 100-record pilot
Rather than requesting the full export, we asked the Act! team for a sample of roughly 100 records across every planned object, with referential integrity preserved so the sample included the parents and children of the same trips.
We then ran the entire migration against that sample:
- Full property mapping and picklist normalisation
- Identity resolution pass
- All object imports in dependency order
- All associations, including labelled ones
- Owner mapping
- A reconciliation report comparing HubSpot output against the source rows
The client reviewed the result in a sandbox portal and formally approved it. Only then did we request the full master export.
This is the step most migrations skip, and it is exactly the step that a vendor-gated, slow-turnaround export makes non-negotiable. The pilot caught mapping defects that would have been catastrophic at full volume, including date-format inversion on two fields and a picklist that used different values in two different sheets for the same concept.
Phase 3: The full migration
The Act! team delivered the complete master export. We loaded it in strict dependency order, because associations cannot be created before both sides of the relationship exist.
| # | Load | Why this order |
|---|---|---|
| 1 | Users and owner mapping table | Every subsequent record needs a valid owner |
| 2 | Companies | Parents for contacts |
| 3 | Operators (custom object) | Referenced by trips and itineraries |
| 4 | Contacts (primary) | Anchor records |
| 5 | Contacts (secondary) | Requires primaries to exist for association |
| 6 | Contact ↔ Contact labelled associations | Pair structure |
| 7 | Deals (trips) | Requires contacts, companies, owners |
| 8 | Deal → Contact / Company / Operator associations | Requires all of the above |
| 9 | Itineraries | Requires parent Deals |
| 10 | Bookings / pax data | Requires Deals and Contacts |
| 11 | Notes, activities, attachments | Timeline history |
Trips were imported as Deals with full financials: gross value, deposits and collections, currency, booking date, departure date, return date, group size, and pipeline stage derived from the Act! trip status. Historical trips landed in a dedicated Closed Won (Historical) stage so they contributed to lifetime-value reporting without distorting active pipeline forecasts.
Itinerary records carried day index, destination, arrival and departure dates, accommodation, meal plan, and the operator responsible for that leg. A 14-day trip produced 14 linked itinerary records rather than a wall of text in a description field, which is what made day-level operational reporting possible afterwards.
User and ownership mapping
Ten years of Act! meant a long list of users, many of them long gone. We built an explicit mapping table covering:
- Active Act! users to active HubSpot users
- Departed Act! users to a [Client] Archive owner or to the current territory owner, per the client's rules
- Blank or system owners to a documented default
Ownership was mapped consistently across contacts, deals, operators, itineraries, and bookings. Without this, historical performance reporting and territory routing would have been unusable from day one.
Phase 4: Rebuilding the booking form pipeline
The client's website booking form already captured passport details, signed documents, and dietary requirements, and pushed them into Act! via API.
We re-pointed that pipeline at HubSpot:
- Form submissions now create or update a Booking (Pax) custom object record
- The Booking record is associated to the correct Deal and to the correct traveller Contact
- Signed documents are stored in HubSpot's file manager and attached to the Booking record
- Passport and identity fields were configured as sensitive data properties with restricted visibility, so passport numbers are not exposed to every user in the portal
The result is that from go-live, new bookings populate HubSpot natively. There is no residual dependency on Act! for new business.
Phase 5: Rebuilding JPS, the brain sheet engine
This is the part that made the CRM migration actually stick.
The client's operational loop was: trip confirmed → client itinerary sheet generated from the master brain sheet → operations works the sheet → final figures pushed back into the CRM.
We rebuilt that loop with middleware on AWS, triggered from Google Drive:
Architecture
- Trigger: a Drive change notification fires when a client sheet is created or updated in the monitored folder structure
- Ingest: an API gateway endpoint receives the notification and queues the job
- Parse and normalise: a Lambda function reads the sheet, resolves the brain sheet references (collections, amounts, operator rates), and validates against expected schema
- Resolve identity: the sheet is matched to a HubSpot Deal using the Act!-derived trip reference, not by name matching
- Write: normalised values are pushed to HubSpot via the batch upsert APIs: financials onto the Deal, leg data onto Itinerary records, operator links as labelled associations
- Audit: every job writes an immutable record of what was read, what was written, and what was rejected
Reliability details that mattered
- Idempotency keys on every write, so a duplicate Drive notification cannot double-count a collection
- Exponential backoff against HubSpot API rate limits
- A dead-letter queue for failed jobs, with an alert to a shared operations inbox rather than a silent failure
- Schema validation before write, so a broken sheet is rejected with a readable error instead of corrupting a Deal
- Full reconciliation report run nightly, comparing sheet totals against Deal amounts
The client kept the spreadsheet workflow their operations team already knew. HubSpot became the accurate system of record, updated automatically, instead of a place people were told to update manually and didn't.
Quality assurance and reconciliation
Nothing went live on assertion. Every object was reconciled on four dimensions:
- Row counts: source rows in, HubSpot records out, per object, with variance explained line by line
- Financial totals: sum of Deal amounts by year and by currency against the Act! source totals
- Association integrity: orphan detection across every relationship. Zero itineraries without a parent Deal. Zero secondary contacts without a labelled primary. Zero Deals without at least one traveller
- Manual spot-check: Records per object opened side by side against Act! by the client's own team, including the most complex repeat-traveller cases we could find
The identity resolution work was validated specifically by pulling the client's known repeat travellers and confirming that all six or seven of their historical trips appeared on the correct pair of contact records, with the correct labels.
Cutover
- Freeze window agreed with the client, with new enquiries handled on a documented manual process
- Delta export requested from the Act! team covering activity since the master export, then loaded
- Final reconciliation signed off by the client before user access was opened
- Go-live with the booking form pipeline and JPS middleware live simultaneously, so no data was created in a system that was about to be retired
- Hypercare period: daily reconciliation checks, a triage channel, and same-day fixes on data queries
Act! was kept in read-only reference mode for a defined period and then decommissioned.
Outcomes
- Contacts, deals, itinerary records, operators, and booking records migrated with full relational integrity
- 16 of 32 source objects migrated, the remainder documented and retired, reducing long-term data maintenance
- Repeat-traveller history correctly attributed to individual people rather than combined pair records, unlocking per-person lifetime value reporting for the first time
- Zero manual CRM data entry in the post-trip financial loop, previously a recurring weekly task
- Day-level itinerary and operator performance reporting now possible, which the Act! model could not support
- New bookings flow into HubSpot natively from the website with no legacy dependency
What we'd tell anyone migrating off Act!
Design the target model before you request the export. Especially when export requests are gated behind a vendor team with a turnaround time. Every schema surprise you catch on paper is a week you don't lose.
Never let email be your dedupe key on legacy travel or family data. Shared household emails are the norm, not the exception. Custom unique identifier properties carried from the source system are the only safe anchor.
Cut scope aggressively at the object level. Half the objects in a decade-old CRM are abandoned. Migrating them costs money twice: once to move them and forever to maintain them.
Pilot with a full vertical slice, not a wide sample. 100 records that include complete parent-child-grandchild chains will teach you more than 10,000 flat contact rows.
Migrate the workflow, not just the data. If a spreadsheet is the real system of record, the migration is not finished until that spreadsheet writes to HubSpot automatically. Otherwise you have built a very expensive archive.
Map departed users deliberately. Ten years of staff turnover will silently destroy your historical reporting if unowned records land on a default nobody agreed to.
Frequently Asked Questions
Can Act! CRM data be migrated to HubSpot?
Yes. Act! data can be migrated into HubSpot's standard and custom objects, but a decade-old Act! database usually needs a custom identity and object model designed first, especially where Act! stored shared or paired contact records that HubSpot's one-record-per-person model can't read directly.
How do you migrate CRM data when there's no direct database access?
When a legacy CRM vendor controls the only export route, the fix is to request a small referentially-intact pilot sample first, validate the full mapping and import logic against it, get client sign-off, then request the full master export. This avoids re-requesting exports and catching schema errors only after a full-volume import.
How does HubSpot handle a shared record for two people, like a travelling couple?
HubSpot's Contact object is built for one person per record, so a shared legacy record has to be split into two individual Contacts with a labelled association between them (for example, Primary Traveller and Secondary Traveller). Deduplication should run on a custom unique identifier from the source system, not on email, since paired records frequently share one email address.
What happens to a legacy spreadsheet system when you migrate to a new CRM?
If a spreadsheet is genuinely running the business, the migration isn't complete until that spreadsheet writes into the new CRM automatically. That usually means custom middleware that reads the sheet, resolves it against the correct CRM record, and pushes the data in, rather than asking the operations team to switch to manual entry.
How long does a complex Act! to HubSpot migration take?
It depends on record volume, the number of custom objects, and how many operational workflows (not just data) need to be rebuilt. A migration involving multiple custom objects, identity resolution, and custom middleware typically runs longer than a standard contacts-and-deals migration, and should include a hypercare period after go-live.
Is the old CRM decommissioned right away after migration?
Not usually. Keeping the legacy CRM in read-only reference mode for a defined period after go-live gives the team a safety net for historical lookups while HubSpot is confirmed as the accurate system of record, before the old system is fully retired.
Working with a complex legacy CRM?
If you're on Act!, or any legacy CRM with custom objects, a spreadsheet-driven operations layer, and no direct database access, the migration is an architecture project rather than a data-transfer job. We design the target model, negotiate the export, prove it on a pilot, and rebuild the operational loop so the new system holds.