• facebook
  • twitter
  • linkedin

Bulk Merging Duplicate Contacts in HubSpot Using Custom Workflows

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

02- Merging Contacts Process

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.

Comments (0)
Other

Insights You Might Like

Automatically Tag...

HubSpot stores heaps of data about your contacts, but it doesn’t natively track
Read More

Rank the Top Candidates...

Matching applicants to open roles is only half the battle; you still need to...
Read More

Auto-Populate Deals from...

When a subscription is created in HubSpot—often via Stripe, Chargebee or a...
Read More