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

Why n8n?

n8n's Code node handles the bulk_match batching logic cleanly, and Split In Batches manages the iteration. Self-hosted n8n means unlimited executions at zero platform cost — the only variable expense is Apollo credits. The visual canvas makes it easy to see which contacts matched, which were skipped, and where errors occurred.

The trade-off is node count. This workflow requires 7-8 nodes with HTTP requests, Code nodes for payload formatting, and IF/Filter nodes for the "only fill empty fields" logic. If you want a single script you can read top-to-bottom, the Code approach is simpler.

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_ACCESS_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_ACCESS_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

Troubleshooting

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.

Common questions

How many n8n executions does a batch of 100 contacts use?

About 30-40 executions. The Split In Batches node processes 10 contacts per iteration (10 iterations), plus individual HubSpot PATCH calls for matched contacts. On n8n cloud, this is well within even the Starter plan's limits.

Can I use n8n's built-in HubSpot node for the search?

The built-in HubSpot node supports basic contact operations but not the Search API with NOT_HAS_PROPERTY filters. Use an HTTP Request node for the search step. You can use the built-in node for simple updates if you prefer.

What happens if Apollo's bulk_match endpoint is down during a run?

With Retry on Fail enabled (2 retries, 5-second wait), n8n will retry the failed batch automatically. If it still fails, the Error Workflow sends a Slack notification so you know to investigate. Successfully processed batches before the failure are already written to HubSpot.

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

Looking to scale your AI operations?

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