Route HubSpot leads by territory and company size using n8n
Prerequisites
- n8n instance (cloud or self-hosted)
- HubSpot private app token with
crm.objects.contacts.read,crm.objects.contacts.write, andcrm.objects.companies.readscopes - Slack app with Bot Token (
chat:writescope) - n8n credentials configured for both HubSpot and Slack
- Enriched company data in HubSpot (state, country, employee count)
Step 1: Add a HubSpot Trigger node
Create a new workflow and add a HubSpot Trigger node:
- Authentication: Select your HubSpot credential
- Event: Contact Created
Step 2: Fetch contact and associated company
Add an HTTP Request node to get the contact with company association:
- Method: GET
- URL:
https://api.hubapi.com/crm/v3/objects/contacts/{{ $json.objectId }} - Query params:
properties=firstname,lastname,email,jobtitle,company,state,country,hubspot_owner_id&associations=companies
Then add another HTTP Request node to get the company details (if associated):
- Method: GET
- URL:
https://api.hubapi.com/crm/v3/objects/companies/{{ $json.associations.companies.results[0].id }} - Query params:
properties=name,numberofemployees,state,country,hubspot_owner_id
Territory routing typically uses company-level data (state, employee count). If the company record exists, prefer its data over the contact's self-reported values.
Step 3: Check for existing account owner
Add an IF node to check if the company already has an owner:
- Condition:
{{ $json.properties.hubspot_owner_id }}is not empty
If the company has an owner, route the contact to that same owner -- this keeps accounts together. Connect the "true" branch directly to the HubSpot update step (Step 5).
Step 4: Apply territory and size rules with a Code node
For contacts without an existing account owner, add a Code node with the routing logic:
const contact = $('Fetch Contact').first().json;
const company = $('Fetch Company').first().json;
const state = (company?.properties?.state || contact.properties.state || '').toUpperCase();
const country = (company?.properties?.country || contact.properties.country || '').toUpperCase();
const employees = parseInt(company?.properties?.numberofemployees || '0');
// --- Territory config ---
// Map regions to reps. Each rep has a HubSpot owner ID and Slack user ID.
const TERRITORY_MAP = {
// Northeast
'NY': { ownerId: '111111', slackId: 'U01AAAA', rep: 'Alice' },
'MA': { ownerId: '111111', slackId: 'U01AAAA', rep: 'Alice' },
'CT': { ownerId: '111111', slackId: 'U01AAAA', rep: 'Alice' },
'NJ': { ownerId: '111111', slackId: 'U01AAAA', rep: 'Alice' },
// West
'CA': { ownerId: '222222', slackId: 'U02BBBB', rep: 'Bob' },
'WA': { ownerId: '222222', slackId: 'U02BBBB', rep: 'Bob' },
'OR': { ownerId: '222222', slackId: 'U02BBBB', rep: 'Bob' },
// Southeast
'FL': { ownerId: '333333', slackId: 'U03CCCC', rep: 'Carol' },
'GA': { ownerId: '333333', slackId: 'U03CCCC', rep: 'Carol' },
'TX': { ownerId: '333333', slackId: 'U03CCCC', rep: 'Carol' },
};
// Enterprise override: large companies go to senior AE regardless of territory
const ENTERPRISE_REP = { ownerId: '444444', slackId: 'U04DDDD', rep: 'Dave (Enterprise)' };
const ENTERPRISE_THRESHOLD = 1000;
// Default fallback
const DEFAULT_REP = { ownerId: '555555', slackId: 'U05EEEE', rep: 'Eve (Catch-all)' };
// --- Routing logic ---
let assignedRep;
let reason;
if (employees >= ENTERPRISE_THRESHOLD) {
assignedRep = ENTERPRISE_REP;
reason = `Enterprise (${employees} employees)`;
} else if (TERRITORY_MAP[state]) {
assignedRep = TERRITORY_MAP[state];
reason = `Territory match: ${state}`;
} else {
assignedRep = DEFAULT_REP;
reason = `No territory match (state: ${state || 'unknown'})`;
}
return [{
json: {
contactId: contact.id,
contactName: `${contact.properties.firstname || ''} ${contact.properties.lastname || ''}`.trim(),
email: contact.properties.email,
company: company?.properties?.name || contact.properties.company,
employees,
state,
assignedRep,
reason,
}
}];The territory mapping is the part that changes most. Consider storing it in a Google Sheet and fetching it at the start of each execution, so sales ops can update territories without touching the workflow.
Step 5: Update contact owner in HubSpot
Add an HTTP Request node:
- Method: PATCH
- URL:
https://api.hubapi.com/crm/v3/objects/contacts/{{ $json.contactId }} - Body:
{
"properties": {
"hubspot_owner_id": "{{ $json.assignedRep.ownerId }}"
}
}Step 6: Notify the assigned rep in Slack
Add a Slack node:
- Channel:
{{ $json.assignedRep.slackId }}(DM by user ID) - Message Type: Block Kit
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "🆕 *New Lead Routed to You*\n*{{ $json.contactName }}* at {{ $json.company }} ({{ $json.employees }} employees)\n📍 {{ $json.state || 'Unknown location' }}\nRouting reason: {{ $json.reason }}"
}
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": "View in HubSpot" },
"url": "https://app.hubspot.com/contacts/YOUR_PORTAL_ID/contact/{{ $json.contactId }}"
}
]
}
]
}Step 7: Activate
- Click Execute Workflow to test with a real contact
- Verify the contact owner was set correctly based on territory/size
- Toggle the workflow to Active
Cost
- n8n Cloud Starter: $24/mo for 2,500 executions. Each new lead = 1 execution.
- Self-hosted: Free. Unlimited executions.
Need help implementing this?
We build and optimize automation systems for mid-market businesses. Let's discuss the right approach for your team.