RevOps · 7 min read
Lead qualification that routes itself: a scoring model you can read and test
If two reps qualify the same lead differently, your forecast is a guess. A scoring model fixes that, but only if people can read it, argue with it and change it. Black-box scores get ignored.
Principles
- Explicit rules first. Budget, timeline, role, company size, email domain. Each adds or removes points you can see.
- Show the reasons. Every score returns a list of why. If sales disagrees, they can point at the rule.
- Disqualify early and visibly. Job seekers, students and "free template" requests shouldn't consume a human's time.
- Route by tier, not by hunch. Hot leads alert the owner and offer booking; warm leads enter a nurture sequence; cold leads get an automatic FAQ reply.
- Use an LLM to extract, not to decide. Let a model pull "budget" and "timeline" out of a free-text message, then score those fields with the plain rules. That keeps the decision auditable.
Reference implementation
export const DEFAULT_RULES = {
budgetMin: 2000, // stated monthly or project budget in USD that counts as "has budget"
targetSizes: [10, 200], // employee range that fits our service
timelineDays: 30,
freeEmail: /@(gmail|yahoo|hotmail|outlook|aol|icloud|proton(mail)?)\.[a-z.]+$/i,
decisionRoles: /\b(owner|founder|ceo|coo|cto|president|partner|director|head of|vp)\b/i,
disqualifiers: /\b(job|jobs|hiring|internship|salary|student|homework|free template)\b/i,
};
export function scoreLead(lead, rules = DEFAULT_RULES) {
let score = 0;
const reasons = [];
const add = (pts, why) => { score += pts; reasons.push(`${pts >= 0 ? '+' : ''}${pts} ${why}`); };
if (rules.disqualifiers.test(`${lead.message || ''} ${lead.role || ''}`)) {
return { score: 0, tier: 'cold', route: 'auto-reply-faq', reasons: ['disqualifier keyword in message or role'] };
}
if (Number(lead.budget) >= rules.budgetMin) add(30, `budget >= $${rules.budgetMin}`);
if (Number.isFinite(lead.timelineDays) && lead.timelineDays <= rules.timelineDays) add(25, `timeline <= ${rules.timelineDays} days`);
if (rules.decisionRoles.test(lead.role || '')) add(20, 'decision-maker role');
const n = Number(lead.employees);
if (n >= rules.targetSizes[0] && n <= rules.targetSizes[1]) add(15, 'company size in target range');
if (lead.email && !rules.freeEmail.test(lead.email)) add(10, 'business email domain');
else if (lead.email) add(-10, 'free email domain');
score = Math.max(0, Math.min(100, score));
const tier = score >= 60 ? 'hot' : score >= 30 ? 'warm' : 'cold';
const route = { hot: 'alert-owner-and-offer-booking', warm: 'nurture-sequence', cold: 'auto-reply-faq' }[tier];
return { score, tier, route, reasons };
}
We tested it
The function ships with seven tests (run with Node), all passing: a hot lead scoring 100, a warm lead scoring 40, a student with a free email scoring 0, a disqualifier overriding an otherwise perfect lead, a free-email penalty, missing fields handled safely, and score bounds and reasons present on every result.
Calibrate with your own data
The weights above are a starting point, not a law. After 30 to 50 closed leads, compare score tiers with outcomes: if "warm" leads close as often as "hot" ones, change the weights. Store the score and its reasons with each lead so you can run that comparison later.
SELECT tier, COUNT(*) AS leads,
SUM(CASE WHEN outcome = 'won' THEN 1 ELSE 0 END) AS won
FROM leads
GROUP BY tier;
Where this fits
This is the qualification step in our RevOps and lead generation systems, behind the instant first response.