Route HubSpot leads by territory and company size using n8n

medium complexityCost: $0-24/moRecommended

Prerequisites

Prerequisites
  • n8n instance (cloud or self-hosted)
  • HubSpot private app token with crm.objects.contacts.read, crm.objects.contacts.write, and crm.objects.companies.read scopes
  • Slack app with Bot Token (chat:write scope)
  • n8n credentials configured for both HubSpot and Slack
  • Enriched company data in HubSpot (state, country, employee count)

Why n8n?

n8n gives you a visual workflow editor with a Code node for custom logic — ideal for territory routing, which requires company lookups, multi-tier decision logic, and state normalization. The HubSpot Trigger node fires on each new contact creation, so routing happens in near real-time without polling delays. Self-hosted n8n is completely free with unlimited executions.

The Code node is what sets n8n apart for this use case. You can implement the full three-tier routing hierarchy — existing account owner, enterprise override, territory match — in a single JavaScript block within the visual editor. No separate script, no deployment pipeline. The trade-off is that you need basic JavaScript comfort for the Code node, and territory config changes require editing the workflow.

How it works

  • HubSpot Trigger fires when a new contact is created
  • HTTP Request nodes fetch the contact's details and associated company data
  • IF node checks if the company already has an owner (keeps accounts together)
  • Code node applies enterprise override and territory matching logic
  • HTTP Request node updates the contact's hubspot_owner_id in HubSpot
  • Slack node DMs the assigned rep with the lead details

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
Company vs. contact properties

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,
  }
}];
Keep the config object editable

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

  1. Click Execute Workflow to test with a real contact
  2. Verify the contact owner was set correctly based on territory/size
  3. Toggle the workflow to Active

Troubleshooting

Common questions

Does each routed lead count as a separate n8n execution?

Yes. Each new contact triggers one workflow execution that includes all steps (trigger, company lookup, routing, HubSpot update, Slack notification). On n8n Cloud Starter ($24/mo), you get 2,500 executions — enough for ~2,500 new leads/month. Self-hosted has no execution limits.

How do I update territory assignments without editing the workflow?

Store the territory mapping in a Google Sheet and add an HTTP Request node at the start of the workflow to fetch it. This way sales ops can update territories by editing a spreadsheet. Alternatively, use n8n's static workflow data ($getWorkflowStaticData('global')) to store the mapping.

How fast does the HubSpot Trigger fire?

n8n's HubSpot Trigger node uses polling, not webhooks. On n8n Cloud, the default polling interval is 5 minutes. Self-hosted n8n can poll as frequently as every minute by adjusting the EXECUTIONS_PROCESS setting. For sub-minute routing, use the Code approach with a real HubSpot webhook instead.

What if a contact has no state or company data?

The Code node routes contacts with missing state data to the default/fallback rep. If most of your contacts arrive without state data (common with form submissions), consider enriching contacts first — either via a separate enrichment workflow or by adding a Clearbit/Apollo lookup step before the routing logic.

Cost

  • n8n Cloud Starter: $24/mo for 2,500 executions. Each new lead = 1 execution.
  • Self-hosted: Free. Unlimited executions.

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.