HubSpot's native contact deduplication tools are helpful for merging duplicate records, but they fall short in one key area: only one contact can be merged at a time. For businesses dealing with large-scale migrations, ill-fated list imports, or incomplete contact records (such as those without email addresses), this limitation can become a significant bottleneck.
To overcome this constraint, we developed a set of workflows and custom-coded actions that allow you to bulk merge duplicate contacts in a controlled, flexible, and scalable manner.
Why This Approach?
This solution allows users to:
- Manually or automatically flag duplicates to be merged into a single primary contact
- Use HubSpot's merge API to combine contacts in bulk
- Re-enroll and repeat merges as needed until all duplicates are processed
- Reset merge flags once the process is complete
Note: This workflow is intentionally designed to allow for manual review and selection of merge candidates. However, the initial list of duplicates can be populated automatically using a separate workflow, HubSpot's built-in duplicate management tools, or custom logic based on property comparisons.
Example Use Cases
- List Import Cleanup: A bulk import introduces redundant contacts that need to be consolidated.
- CRM Migration: Data from another platform has inconsistent duplication logic and needs cleanup post-import.
- Low-Data Records: Contacts without unique identifiers like emails need to be reviewed and merged manually.
How It Works
The system consists of three custom-coded actions used in conjunction with three workflows. Each part plays a role in managing the merge process through a combination of property updates, list membership, and HubSpot API calls.
Custom Properties You Need
contact_to_be_merged
(Single checkbox)contact_merging_id
(Number or text)merging_initial_contact
(Single checkbox)merging_re_enrollment_trigger
(Text)duplicated_contact
(Single checkbox)
Step-by-Step Instructions
Step 1: Prepare Primary Contact and Tag Duplicates
Create a workflow or manually trigger the following custom action to prepare the main contact and tag related duplicates.
const hubspot = require('@hubspot/api-client');
exports.main = async (event) => {
const hubspotLists = new hubspot.Client({
accessToken: process.env.MyLists
});
/* 01. GET MAIN CONTACT ID FOR MERGING */
let getContactId;
try {
const ApiResponse1 = await hubspotLists.crm.contacts.basicApi.getById(event.object.objectId, ["hs_object_id"]);
getContactId = ApiResponse1.body.properties.hs_object_id;
} catch (err) {
console.error(err);
throw err;
}
console.log(getContactId);
/* 02. SET THAT CONTACT AS INITIAL ONE AND PREPARE RE-ENROLLMENT TRIGGER */
let getCurrentDateTime = Date.now();
let createReenrollment = 'reenroll-' + getCurrentDateTime;
try {
const ApiResponse2 = await hubspotLists
.apiRequest({
method: 'PATCH',
path: '/crm/v3/objects/contacts/' + getContactId,
body: {
"properties":{
"merging_initial_contact": "Yes",
"merging_re_enrollment_trigger": createReenrollment
}
}
});
} catch (err) {
console.error(err);
throw err;
}
/* 03. GET ALL CONTACT IDS FROM LIST AND REMOVE INITIAL ONE */
let getAllContactsIds = [];
try {
const ApiResponse3 = await hubspotLists
.apiRequest({
method: 'GET',
path: '/crm/v3/lists/2223/memberships?limit=250',
});
getAllContactsIds = ApiResponse3.body.results.filter(contact => Number(contact.recordId) !== Number(getContactId)).map(contact => contact.recordId)
} catch (err) {
console.error(err);
throw err;
}
console.log(getAllContactsIds);
/* 04. UPDATE CONTACTS TO BE MERGED WITH INITIAL CONTACT */
if(getAllContactsIds.length > 0){
for(let i = 0; i < getAllContactsIds.length; i++){
try {
const ApiResponse4 = await hubspotLists
.apiRequest({
method: 'PATCH',
path: '/crm/v3/objects/contacts/' + getAllContactsIds[i],
body: {
"properties":{
"contact_to_be_merged": "Yes",
"contact_merging_id": getContactId
}
}
});
} catch (err) {
console.error(err);
throw err;
}
}
} else {
console.log('No Contacts for Merging');
}
}
Step 2: Merge Contacts
This action will locate all flagged duplicates and merge them into the primary contact.
const hubspot = require('@hubspot/api-client');
exports.main = async (event) => {
const hubspotLists = new hubspot.Client({
accessToken: process.env.MyLists
});
/* 01. GET INITIAL CONTACT ID */
let getContactId;
try {
const ApiResponse1 = await hubspotLists.crm.contacts.basicApi.getById(event.object.objectId, ["hs_object_id"]);
getContactId = ApiResponse1.body.properties.hs_object_id;
} catch (err) {
console.error(err);
throw err;
}
let getDuplicatedIds = [];
try {
const ApiResponse2 = await hubspotLists
.apiRequest({
method: 'POST',
path: '/crm/v3/objects/contacts/search',
body: {
"filterGroups":[
{
"filters":[
{
"propertyName":"hs_object_id",
"operator":"NEQ",
"value": getContactId
},
{
"propertyName":"contact_to_be_merged",
"operator":"EQ",
"value": "Yes"
},
{
"propertyName":"contact_merging_id",
"operator":"EQ",
"value": getContactId
}
]
}
],
"limit": 1
}
});
getDuplicatedIds = ApiResponse2.body.results.filter(contact => Number(contact.id) !== Number(getContactId)).map(contact => contact.id);
} catch (err) {
console.error(err);
throw err;
}
console.log(getDuplicatedIds);
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
if (getDuplicatedIds.length == 0){
console.log('There are NO Contacts to Merge');
} else if (getDuplicatedIds.length > 0){
let getCurrentDateTime = Date.now();
let createReenrollment = 'reenroll-' + getCurrentDateTime;
let primaryContactId = event.object.objectId;
for (let i = 0; i < getDuplicatedIds.length; i++){
try {
const ApiResponse3 = await hubspotLists
.apiRequest({
method: 'POST',
path: '/contacts/v1/contact/merge-vids/' + primaryContactId,
body: {
"vidToMerge": getDuplicatedIds[i]
}
});
await sleep(5000);
} catch (err) {
console.error(err);
throw err;
}
try {
const ApiResponse5 = await hubspotLists
.apiRequest({
method: 'PATCH',
path: '/crm/v3/objects/contacts/' + primaryContactId,
body: {
"properties":{
"merging_initial_contact": "Yes",
"merging_re_enrollment_trigger": createReenrollment
}
}
});
} catch (err) {
console.error(err);
throw err;
}
}
}
}
Step 3: Check If Merge Is Complete
This action checks if any duplicates remain. If not, it clears all merge-related properties.
const hubspot = require('@hubspot/api-client');
exports.main = async (event) => {
const hubspotLists = new hubspot.Client({
accessToken: process.env.MyLists
});
/* 01. GET INITIAL CONTACT ID */
let getContactId;
try {
const ApiResponse1 = await hubspotLists.crm.contacts.basicApi.getById(event.object.objectId, ["hs_object_id"]);
getContactId = ApiResponse1.body.properties.hs_object_id;
} catch (err) {
console.error(err);
throw err;
}
let getDuplicatedIds = [];
try {
const ApiResponse2 = await hubspotLists
.apiRequest({
method: 'POST',
path: '/crm/v3/objects/contacts/search',
body: {
"filterGroups":[
{
"filters":[
{
"propertyName":"hs_object_id",
"operator":"NEQ",
"value": getContactId
},
{
"propertyName":"contact_to_be_merged",
"operator":"EQ",
"value": "Yes"
},
{
"propertyName":"contact_merging_id",
"operator":"EQ",
"value": getContactId
}
]
}
],
"limit": 100
}
});
getDuplicatedIds = ApiResponse2.body.results.filter(contact => Number(contact.id) !== Number(getContactId)).map(contact => contact.id);
} catch (err) {
console.error(err);
throw err;
}
console.log(getDuplicatedIds);
if (getDuplicatedIds.length == 0){
try {
const ApiResponse3 = await hubspotLists
.apiRequest({
method: 'PATCH',
path: '/crm/v3/objects/contacts/' + getContactId,
body: {
"properties": {
"merging_initial_contact": "",
"merging_re_enrollment_trigger": "",
"duplicated_contact": "No"
}
}
});
} catch (err) {
console.error(err);
throw err;
}
} else if (getDuplicatedIds.length > 0){
console.log('There are still Duplicates');
}
}
Optional Step: Set Auto Enrollment Only
If you need to re-trigger a contact’s enrollment manually or outside the main merge preparation flow, you can use this simplified version of the code. It only updates the merging_re_enrollment_trigger
property to a unique value, allowing re-enrollment in the downstream workflow.
const hubspot = require('@hubspot/api-client');
exports.main = async (event) => {
const hubspotLists = new hubspot.Client({
accessToken: process.env.MyLists
});
/* 01. GET BASIC INFO TO CHECK (Email is the main one) */
let getContactId;
try {
const ApiResponse1 = await hubspotLists.crm.contacts.basicApi.getById(event.object.objectId, ["hs_object_id"]);
getContactId = ApiResponse1.body.properties.hs_object_id;
} catch (err) {
console.error(err);
throw err;
}
let getCurrentDateTime = Date.now();
let createReenrollment = 'reenroll-' + getCurrentDateTime;
console.log(createReenrollment);
try {
await hubspotLists.apiRequest({
method: 'PATCH',
path: '/crm/v3/objects/contacts/' + getContactId,
body: {
properties: {
merging_re_enrollment_trigger: createReenrollment
}
}
});
} catch (err) {
console.error(err);
throw err;
}
};
Conclusion
This setup bypasses a major limitation in HubSpot's native deduplication feature and enables bulk merging logic in a repeatable and manageable way. Whether you're cleaning up after a bad import, transitioning from another CRM, or auditing partial contact records, this approach provides both the structure and the flexibility to fit real-world use cases.
If you'd like help implementing this or adapting it to your specific merge logic or data structure, feel free to reach out.