← All insights

RevOps · 7 min read

Lead qualification that routes itself: a scoring model you can read and test

By BizDevHub ·

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

  1. Explicit rules first. Budget, timeline, role, company size, email domain. Each adds or removes points you can see.
  2. Show the reasons. Every score returns a list of why. If sales disagrees, they can point at the rule.
  3. Disqualify early and visibly. Job seekers, students and "free template" requests shouldn't consume a human's time.
  4. 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.
  5. 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.