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.
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