Batch enrich HubSpot contacts missing job title or company size using n8n
Prerequisites
- n8n instance — n8n cloud or self-hosted
- HubSpot private app token with
crm.objects.contacts.readandcrm.objects.contacts.writescopes - 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
}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 company → NOT_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 }));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 keyContent-Type:application/json
- Body:
{{ $json }}
The bulk endpoint returns a matches[] array with one result per input, in the same order.
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 }} }
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:
- Retry on Fail on both HTTP Request nodes (2 retries, 5-second wait)
- An Error Workflow that sends a Slack notification when the batch job fails
Step 7: Test and activate
- Click Execute Workflow with a small batch (set the search limit to 10)
- Check the Apollo bulk_match response — verify it returns an array of matches
- Check the Code node — verify only empty fields are included in updates
- Open a few contacts in HubSpot to confirm only missing fields were filled
- 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_dateproperty andenrichment_sourceto 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.