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)

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

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.