Batch enrich HubSpot contacts missing job title or company size using n8n

medium complexityCost: $0-24/mo

Prerequisites

Prerequisites
  • n8n instance — n8n cloud or self-hosted
  • HubSpot private app token with crm.objects.contacts.read and crm.objects.contacts.write scopes
  • Apollo API key with enrichment credits (Settings → Integrations → API)
  • n8n credential configured for HubSpot

Step 1: Schedule a weekly trigger

Add a Schedule Trigger node:

  • Trigger interval: Weeks
  • Day of week: Sunday
  • Hour: 22
  • Minute: 0
  • Timezone: Your team's timezone

Running on Sunday evening ensures contacts from the previous week are enriched before Monday's workflows fire.

Step 2: Search HubSpot for contacts missing fields

Add an HTTP Request node to find contacts without a job title:

  • Method: POST
  • URL: https://api.hubapi.com/crm/v3/objects/contacts/search
  • Authentication: HubSpot credential
  • Body:
{
  "filterGroups": [
    {
      "filters": [
        {
          "propertyName": "jobtitle",
          "operator": "NOT_HAS_PROPERTY"
        }
      ]
    }
  ],
  "properties": [
    "email", "firstname", "lastname", "jobtitle", "company",
    "phone", "linkedin_url", "industry", "numemployees"
  ],
  "limit": 100
}
Multiple missing field conditions

To find contacts missing any of several fields, use multiple filterGroups (OR logic). Each filterGroup represents an OR condition. For contacts missing title OR company, add a second filterGroup with companyNOT_HAS_PROPERTY.

Step 3: Handle pagination

Add an IF node to check for more pages:

  • Condition: {{ $json.paging?.next?.after }} is not empty

On the true branch, loop back to the HTTP Request node with the after parameter. Use a Merge node to combine all pages into a single list.

Alternatively, use a Code node to handle pagination in a single step:

const HUBSPOT_TOKEN = $credentials.hubspotApi?.accessToken;
const allContacts = [];
let after = 0;
 
while (true) {
  const resp = await fetch("https://api.hubapi.com/crm/v3/objects/contacts/search", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${HUBSPOT_TOKEN}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      filterGroups: [{ filters: [{ propertyName: "jobtitle", operator: "NOT_HAS_PROPERTY" }] }],
      properties: ["email", "firstname", "lastname", "jobtitle", "company", "phone", "linkedin_url"],
      limit: 100,
      after
    })
  });
  const data = await resp.json();
  allContacts.push(...data.results);
 
  if (data.paging?.next?.after) {
    after = data.paging.next.after;
  } else {
    break;
  }
}
 
return allContacts.map(c => ({ json: c }));
Search API 10,000-result cap

The HubSpot Search API returns a maximum of 10,000 results total. If you have more than 10,000 unenriched contacts, add additional filters to narrow the set — for example, created in the last 30 days, or in a specific lifecycle stage.

Step 4: Batch enrich via Apollo

Apollo offers a bulk match endpoint that processes up to 10 people per request. Add a Split In Batches node (batch size: 10) then a Code node to format the batch:

const contacts = $input.all().map(item => ({
  email: item.json.properties.email,
  first_name: item.json.properties.firstname,
  last_name: item.json.properties.lastname,
}));
 
return [{ json: { details: contacts } }];

Add an HTTP Request node for the bulk endpoint:

  • Method: POST
  • URL: https://api.apollo.io/api/v1/people/bulk_match
  • Headers:
    • x-api-key: Your Apollo API key
    • Content-Type: application/json
  • Body: {{ $json }}

The bulk endpoint returns a matches[] array with one result per input, in the same order.

Apollo bulk_match

The bulk endpoint accepts up to 10 records per request and costs 1 credit per person (same as individual calls). The advantage is fewer HTTP requests and lower latency — 1 request for 10 people instead of 10 individual requests.

Step 5: Map enriched data and update HubSpot

Add a Code node to pair Apollo results with HubSpot contact IDs and build update payloads. Only include fields that are currently empty on the contact:

const batchContacts = $('Split In Batches').all();
const apolloMatches = $input.first().json.matches || [];
const updates = [];
 
for (let i = 0; i < batchContacts.length; i++) {
  const contact = batchContacts[i].json;
  const match = apolloMatches[i];
 
  if (!match) continue;
 
  const properties = {};
  const props = contact.properties;
 
  // Only fill empty fields — never overwrite existing data
  if (!props.jobtitle && match.title) properties.jobtitle = match.title;
  if (!props.company && match.organization?.name) properties.company = match.organization.name;
  if (!props.phone && match.phone_numbers?.[0]?.sanitized_number) {
    properties.phone = match.phone_numbers[0].sanitized_number;
  }
  if (!props.linkedin_url && match.linkedin_url) properties.linkedin_url = match.linkedin_url;
  if (!props.industry && match.organization?.industry) properties.industry = match.organization.industry;
 
  if (Object.keys(properties).length > 0) {
    updates.push({
      contactId: contact.id,
      properties,
    });
  }
}
 
return updates.map(u => ({ json: u }));

Add another Split In Batches node (batch size: 1) followed by an HTTP Request node to update each contact:

  • Method: PATCH
  • URL: https://api.hubapi.com/crm/v3/objects/contacts/{{ $json.contactId }}
  • Authentication: HubSpot credential
  • Body: { "properties": {{ $json.properties }} }
Never overwrite existing data

The Code node checks each field with if (!props.jobtitle && ...). This is critical — if a rep manually entered a job title, you don't want the automation to overwrite it with Apollo's data. Always respect manually entered data.

Step 6: Add a Wait node and error handling

Add a Wait node (500ms) between the Apollo HTTP Request and the next batch to stay within rate limits.

Add error handling:

  1. Retry on Fail on both HTTP Request nodes (2 retries, 5-second wait)
  2. An Error Workflow that sends a Slack notification when the batch job fails

Step 7: Test and activate

  1. Click Execute Workflow with a small batch (set the search limit to 10)
  2. Check the Apollo bulk_match response — verify it returns an array of matches
  3. Check the Code node — verify only empty fields are included in updates
  4. Open a few contacts in HubSpot to confirm only missing fields were filled
  5. Toggle the workflow to Active

Cost

  • n8n cloud: Starts at $24/mo. A batch of 100 contacts uses ~30-40 executions (10 batches of 10 through Apollo, plus individual HubSpot updates).
  • Apollo: 1 credit per person in the bulk request. 100 contacts = 100 credits. Basic plan ($49/mo) = 900 credits.
  • HubSpot: Free within API rate limits.
  • Weekly budget: If you enrich 50-100 contacts/week, that's 200-400 Apollo credits/month — well within the Basic plan.

Next steps

  • Add multiple field checks — expand the search to find contacts missing company, phone, or LinkedIn (not just job title)
  • Add a summary notification — post a Slack message at the end: "Weekly batch: enriched 47/62 contacts, 15 not found"
  • Track enrichment over time — set an enrichment_date property and enrichment_source to monitor data freshness

Need help implementing this?

We build and optimize automation systems for mid-market businesses. Let's discuss the right approach for your team.