n8n
lead-routing
intermediate

Conditional Lead Routing Logic with n8n

Build sophisticated lead routing workflows in n8n with multi-criteria logic, capacity balancing, timezone awareness, and intelligent failover handling.

55 minutes to implement Updated 11/4/2025

Conditional Lead Routing Logic with n8n

The head of sales cornered me in the break room: “Your routing automation is broken. It assigned three enterprise leads to our newest SDR while our top rep sat idle.” I pulled up the logs and discovered the issue—our “round-robin” logic was too simple, treating all reps equally regardless of seniority, capacity, or performance.

That conversation led to rebuilding our routing from scratch in n8n with proper conditional logic: territory rules, capacity limits, availability checks, skill matching, and intelligent failover. Lead distribution improved, rep satisfaction went up, and we stopped losing deals to routing mistakes.

Why Simple Routing Fails

The Equal Distribution Myth Round-robin assumes all reps are interchangeable. In reality, some handle enterprise better, some excel with SMB, some are ramping, and some are at capacity.

The Static Rules Problem Hardcoded territory assignments break when reps change roles, go on vacation, or get promoted. Manual updates to routing logic create inconsistencies and errors.

The Context Blindness Most routing ignores critical context: deal size potential, product fit, timezone alignment, industry expertise, current rep workload, and response time patterns.

The Conditional Routing Framework

Layer 1: Disqualification Remove leads that shouldn’t enter routing at all (spam, competitors, out-of-market).

Layer 2: Territory Assignment Determine which team/region owns the lead based on geography, company size, or industry.

Layer 3: Rep Selection Within territory, select the optimal rep based on availability, capacity, and specialization.

Layer 4: Fallback Logic Handle edge cases when no rep is available or meets criteria.

Building Conditional Routing in n8n

Module 1: Lead Intake and Validation

{
  "name": "Webhook Trigger",
  "type": "n8n-nodes-base.webhook",
  "parameters": {
    "path": "lead-routing",
    "responseMode": "responseNode",
    "httpMethod": "POST"
  }
}

Data Validation Node (Function):

// n8n Code Node
const lead = $input.first().json;

// Required fields check
const required = ['email', 'company', 'country'];
const missing = required.filter(field => !lead[field]);

if (missing.length > 0) {
  return {
    json: {
      status: 'rejected',
      reason: `Missing required fields: ${missing.join(', ')}`,
      lead: lead
    }
  };
}

// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(lead.email)) {
  return {
    json: {
      status: 'rejected',
      reason: 'Invalid email format',
      lead: lead
    }
  };
}

// Personal email detection
const personalDomains = ['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com'];
const domain = lead.email.split('@')[1];
if (personalDomains.includes(domain)) {
  return {
    json: {
      status: 'rejected',
      reason: 'Personal email domain',
      lead: lead
    }
  };
}

return {
  json: {
    status: 'validated',
    lead: lead
  }
};

Module 2: Lead Scoring

Calculate routing priority:

// n8n Function Node: Lead Scoring
const lead = $input.first().json.lead;

let score = 0;

// Company size scoring
const employees = lead.employee_count || 0;
if (employees > 1000) score += 30;
else if (employees > 500) score += 25;
else if (employees > 100) score += 20;
else if (employees > 50) score += 15;
else if (employees > 10) score += 10;

// Source quality
const sourceScores = {
  'demo_request': 30,
  'pricing_inquiry': 25,
  'contact_sales': 25,
  'webinar': 15,
  'content_download': 10,
  'newsletter': 5
};
score += sourceScores[lead.source] || 0;

// Intent signals
if (lead.visited_pricing_page) score += 15;
if (lead.page_views > 5) score += 10;
if (lead.time_on_site > 300) score += 10;

// Industry fit
const targetIndustries = ['Software', 'Technology', 'SaaS', 'E-commerce'];
if (targetIndustries.includes(lead.industry)) score += 10;

// Priority classification
let priority = 'low';
if (score >= 70) priority = 'high';
else if (score >= 40) priority = 'medium';

return {
  json: {
    ...lead,
    lead_score: score,
    priority: priority,
    routing_timestamp: new Date().toISOString()
  }
};

Module 3: Territory Assignment

n8n Switch Node - Geographic Routing:

// Function Node: Determine Territory
const lead = $input.first().json;

// Multi-criteria territory logic
let territory = null;

// Primary: Geographic territory
if (['US', 'CA', 'MX'].includes(lead.country)) {
  if (['CA', 'OR', 'WA', 'NV', 'AZ'].includes(lead.state)) {
    territory = 'west_coast';
  } else if (['NY', 'NJ', 'PA', 'MA', 'CT'].includes(lead.state)) {
    territory = 'east_coast';
  } else {
    territory = 'central';
  }
} else if (['GB', 'DE', 'FR', 'NL', 'ES', 'IT'].includes(lead.country)) {
  territory = 'emea';
} else if (['AU', 'NZ', 'SG', 'JP', 'IN'].includes(lead.country)) {
  territory = 'apac';
}

// Override: Enterprise size goes to enterprise team
if (lead.employee_count > 500) {
  territory = 'enterprise';
}

// Override: Strategic accounts
const strategicAccounts = await $getWorkflowStaticData('strategicAccounts');
if (strategicAccounts.includes(lead.company)) {
  territory = 'strategic';
}

return {
  json: {
    ...lead,
    territory: territory
  }
};

Module 4: Rep Availability Check

Query rep database for current status:

// n8n PostgreSQL Node: Get Available Reps
const territory = $input.first().json.territory;

const query = `
  SELECT
    rep_id,
    rep_name,
    rep_email,
    max_capacity,
    current_leads,
    specializations,
    availability_status,
    last_assignment,
    timezone
  FROM sales_reps
  WHERE territory = $1
    AND availability_status IN ('available', 'online')
    AND current_leads < max_capacity
  ORDER BY current_leads ASC, last_assignment ASC
  LIMIT 5
`;

return await $input.database.query(query, [territory]);

Check Calendar Availability:

// n8n Google Calendar Node
// For each available rep, check if they're in a meeting

const reps = $input.all();
const now = new Date();
const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000);

const availableReps = [];

for (const rep of reps) {
  const events = await $calendar.getEvents({
    calendarId: rep.json.calendar_id,
    timeMin: now.toISOString(),
    timeMax: oneHourLater.toISOString()
  });

  if (events.length === 0) {
    // No meetings in next hour = available
    availableReps.push({
      ...rep.json,
      calendar_status: 'free'
    });
  } else {
    // In meeting = unavailable
    availableReps.push({
      ...rep.json,
      calendar_status: 'busy',
      next_available: events[events.length - 1].end.dateTime
    });
  }
}

return availableReps.filter(r => r.calendar_status === 'free');

Module 5: Skill Matching

Match lead characteristics with rep specializations:

// n8n Function Node: Calculate Rep Match Score
const lead = $input.first().json;
const reps = $input.all();

const scoredReps = reps.map(rep => {
  let matchScore = 0;

  // Industry expertise
  if (rep.json.specializations.industries?.includes(lead.industry)) {
    matchScore += 20;
  }

  // Deal size experience
  const dealSize = lead.employee_count > 500 ? 'enterprise' :
                  lead.employee_count > 100 ? 'midmarket' : 'smb';

  if (rep.json.specializations.deal_size?.includes(dealSize)) {
    matchScore += 15;
  }

  // Product expertise
  if (lead.product_interest && rep.json.specializations.products?.includes(lead.product_interest)) {
    matchScore += 15;
  }

  // Language match
  if (lead.language && rep.json.languages?.includes(lead.language)) {
    matchScore += 10;
  }

  // Timezone alignment
  const leadTimezone = lead.timezone || getTimezoneFromCountry(lead.country);
  const timezoneOffset = Math.abs(
    getTimezoneOffset(leadTimezone) - getTimezoneOffset(rep.json.timezone)
  );

  if (timezoneOffset <= 3) matchScore += 10; // Within 3 hours
  else if (timezoneOffset <= 6) matchScore += 5;

  // Performance bonus
  if (rep.json.conversion_rate > 0.20) matchScore += 10; // >20% close rate
  else if (rep.json.conversion_rate > 0.15) matchScore += 5;

  return {
    ...rep.json,
    match_score: matchScore
  };
});

// Sort by match score descending
scoredReps.sort((a, b) => b.match_score - a.match_score);

return scoredReps;

Module 6: Capacity Balancing

Prevent overloading reps:

// n8n Function Node: Capacity-Based Selection
const reps = $input.all();
const lead = $input.first().json;

// Calculate capacity utilization
const repsWithCapacity = reps.map(rep => {
  const utilization = rep.json.current_leads / rep.json.max_capacity;

  return {
    ...rep.json,
    capacity_utilization: utilization,
    capacity_score: 100 - (utilization * 100) // Higher score = more available
  };
});

// Weight match_score and capacity_score
const finalScores = repsWithCapacity.map(rep => {
  const matchWeight = lead.priority === 'high' ? 0.7 : 0.5; // Prioritize match for high-value leads
  const capacityWeight = 1 - matchWeight;

  const finalScore = (rep.match_score * matchWeight) + (rep.capacity_score * capacityWeight);

  return {
    ...rep,
    final_score: finalScore
  };
});

// Select top rep
finalScores.sort((a, b) => b.final_score - a.final_score);

return finalScores[0];

Module 7: Assignment Execution

Update Rep Capacity:

-- PostgreSQL Node
UPDATE sales_reps
SET current_leads = current_leads + 1,
    last_assignment = NOW()
WHERE rep_id = $1;

Create CRM Record:

// n8n HubSpot/Salesforce Node
{
  "module": "HubSpot - Create or Update Contact",
  "properties": {
    "email": "{{lead.email}}",
    "firstname": "{{lead.first_name}}",
    "lastname": "{{lead.last_name}}",
    "company": "{{lead.company}}",
    "hubspot_owner_id": "{{selectedRep.hubspot_id}}",
    "lead_status": "Assigned",
    "lead_score": "{{lead.lead_score}}",
    "lead_priority": "{{lead.priority}}",
    "routing_reason": "Territory: {{lead.territory}}, Match Score: {{selectedRep.match_score}}",
    "assigned_date": "{{NOW}}"
  }
}

Create Follow-up Task:

{
  "module": "Create Task",
  "task": {
    "title": `Follow up: ${lead.company} - ${lead.priority} priority`,
    "owner_id": selectedRep.crm_id,
    "due_date": lead.priority === 'high' ? '+4 hours' : '+24 hours',
    "priority": lead.priority,
    "notes": `
Lead Score: ${lead.lead_score}
Source: ${lead.source}
Match Reason: ${selectedRep.match_score > 30 ? 'Strong fit' : 'Available'}
Context: ${lead.initial_message || 'N/A'}

Routing Details:
- Territory: ${lead.territory}
- Priority: ${lead.priority}
- Assigned: ${new Date().toISOString()}
    `
  }
}

Notify Rep:

// Slack Notification
{
  "module": "Slack - Send Direct Message",
  "user": selectedRep.slack_id,
  "text": "New lead assigned to you!",
  "blocks": [
    {
      "type": "header",
      "text": "🎯 New Lead Assignment"
    },
    {
      "type": "section",
      "fields": [
        {
          "type": "mrkdwn",
          "text": `*Company:* ${lead.company}`
        },
        {
          "type": "mrkdwn",
          "text": `*Priority:* ${lead.priority.toUpperCase()}`
        },
        {
          "type": "mrkdwn",
          "text": `*Score:* ${lead.lead_score}/100`
        },
        {
          "type": "mrkdwn",
          "text": `*Source:* ${lead.source}`
        }
      ]
    },
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": `*Why you?* Match score: ${selectedRep.match_score}/100\n${getMatchReasoning(selectedRep)}`
      }
    },
    {
      "type": "actions",
      "elements": [
        {
          "type": "button",
          "text": "View in CRM",
          "url": `${CRM_BASE_URL}/contact/${lead.crm_id}`,
          "style": "primary"
        },
        {
          "type": "button",
          "text": "Research Company",
          "url": `https://www.linkedin.com/company/${lead.company}`
        }
      ]
    }
  ]
}

Module 8: Fallback Logic

Handle cases where no rep is available:

// n8n IF Node: Check if rep was found
if (!selectedRep || selectedRep.length === 0) {
  // No available reps - implement fallback

  // Option 1: Queue for later assignment
  await $database.insert('lead_queue', {
    lead_id: lead.id,
    lead_data: lead,
    territory: lead.territory,
    priority: lead.priority,
    queue_time: new Date(),
    retry_count: 0,
    status: 'pending'
  });

  // Option 2: Assign to team lead regardless of capacity
  const teamLead = await $database.query(`
    SELECT * FROM sales_reps
    WHERE territory = $1 AND role = 'team_lead'
    LIMIT 1
  `, [lead.territory]);

  if (teamLead) {
    selectedRep = teamLead[0];
    selectedRep.override_reason = 'No available reps - assigned to team lead';
  }

  // Option 3: Expand to neighboring territory
  const neighboringTerritory = getNeighboringTerritory(lead.territory);
  const neighborReps = await $database.query(`
    SELECT * FROM sales_reps
    WHERE territory = $1
      AND availability_status = 'available'
      AND current_leads < max_capacity
    LIMIT 1
  `, [neighboringTerritory]);

  if (neighborReps.length > 0) {
    selectedRep = neighborReps[0];
    selectedRep.override_reason = 'Territory overflow - assigned to neighboring region';
  }

  // Option 4: Alert management
  await $slack.post({
    channel: '#sales-management',
    text: `⚠️ Lead routing fallback triggered for ${lead.company}. No available reps in ${lead.territory}.`
  });
}

Advanced Patterns

Time-Based Routing

Route based on business hours:

// Function Node: Business Hours Check
const lead = $input.first().json;
const repTimezone = getRepTimezoneForTerritory(lead.territory);

const now = new Date();
const localTime = new Date(now.toLocaleString('en-US', { timeZone: repTimezone }));
const hour = localTime.getHours();
const day = localTime.getDay();

const isBusinessHours = hour >= 9 && hour < 18 && day >= 1 && day <= 5;

if (isBusinessHours) {
  return { json: { ...lead, routing_mode: 'immediate' } };
} else {
  // Outside business hours
  if (lead.priority === 'high') {
    // High priority = assign to on-call rep
    return { json: { ...lead, routing_mode: 'on_call' } };
  } else {
    // Normal priority = queue for next business day
    return { json: { ...lead, routing_mode: 'queued' } };
  }
}

Round-Robin with Memory

Implement true round-robin using state:

// n8n Function Node: Round Robin Selection
const reps = $input.all();
const workflowState = $workflow.getStaticData('node');

// Get last assigned rep index
let lastIndex = workflowState.lastAssignedIndex || -1;

// Find next rep in rotation
let nextIndex = (lastIndex + 1) % reps.length;

// Handle capacity - skip full reps
let attempts = 0;
while (reps[nextIndex].json.current_leads >= reps[nextIndex].json.max_capacity) {
  nextIndex = (nextIndex + 1) % reps.length;
  attempts++;

  // Prevent infinite loop
  if (attempts >= reps.length) {
    // All reps at capacity - select least loaded
    reps.sort((a, b) => a.json.current_leads - b.json.current_leads);
    nextIndex = 0;
    break;
  }
}

// Save state
workflowState.lastAssignedIndex = nextIndex;

return reps[nextIndex];

Lead Recycling

Reassign stale leads:

// Scheduled n8n Workflow: Daily at 9 AM
const staleDays = 14;

const staleLeads = await $database.query(`
  SELECT l.*, r.rep_id, r.rep_name
  FROM leads l
  JOIN sales_reps r ON l.assigned_rep = r.rep_id
  WHERE l.status = 'assigned'
    AND l.last_activity < NOW() - INTERVAL '${staleDays} days'
    AND l.contact_attempts >= 5
`);

for (const lead of staleLeads) {
  // Decrement original rep's capacity
  await $database.query(`
    UPDATE sales_reps
    SET current_leads = current_leads - 1
    WHERE rep_id = $1
  `, [lead.rep_id]);

  // Re-route lead
  // (Trigger main routing workflow with this lead)
  await $http.post('/webhook/lead-routing', lead);

  // Log recycling
  await $database.insert('lead_lifecycle_log', {
    lead_id: lead.id,
    event: 'recycled',
    previous_rep: lead.rep_id,
    reason: `No activity for ${staleDays} days after ${lead.contact_attempts} attempts`,
    timestamp: new Date()
  });
}

FAQ

Q: How do I handle leads that come in during off-hours? A: Implement a queue system. Store leads in a database table with “pending” status. Run a scheduled workflow every 30 minutes during business hours that processes queued leads. For high-priority leads, assign to an on-call rep even outside business hours.

Q: What’s the best way to balance load across reps? A: Track active lead count per rep in a database. When routing, select the rep with the lowest current count among those matching territory and skill requirements. Update the count immediately after assignment to prevent race conditions.

Q: How do I prevent routing loops if assignment fails? A: Add a “retry_count” field to queued leads. Increment on each failed attempt. After 3 failures, escalate to management queue and send alert. Never retry infinitely—always have a manual fallback.

Q: Should I route based on rep performance metrics? A: Yes, but carefully. Give high-performers first pick of high-value leads, but don’t starve new reps of opportunities. Consider using a weighted system: 60% capacity-based, 30% match-based, 10% performance-based.

Q: How do I handle VIP or strategic account leads? A: Check against a “strategic accounts” list before normal routing. Route these directly to AEs or account managers, bypassing SDRs entirely. Set up dedicated Slack channels for strategic account alerts.

Q: What’s the right capacity limit per rep? A: Varies by role. SDRs: 40-60 active leads. AEs: 20-30 open opportunities. Adjust based on your sales cycle length and conversion rates. Monitor “leads per rep” vs. “conversion rate”—if conversion drops as load increases, reduce capacity limits.

Q: How do I test routing logic without affecting production? A: Create a test webhook endpoint that runs the routing logic but doesn’t actually assign leads or update databases. Use a “dry_run” flag to simulate assignments and output routing decisions for review.

Conditional lead routing in n8n transforms chaotic lead distribution into intelligent, optimized assignment. Start with basic territory rules, add capacity balancing, then layer in skill matching and performance weighting. Your sales team’s efficiency will dramatically improve when every lead goes to the right person at the right time.

Need Implementation Help?

Our team can build this integration for you in 48 hours. From strategy to deployment.

Get Started