A B2B lead scoring model built from email and company data
· 8 min read · Company enrichment
A useful B2B lead scoring model can be built from five things you can derive from an email address: the domain category (company, free webmail, education and so on), company size, industry, whether the address is a role or system inbox, and whether it looks machine-generated. Spam and disposable signals disqualify a lead outright; everything else adds or subtracts points that sum into an A, B or C band. Start with hand-set weights, store every line of the score on the lead, and recalibrate the points against your own closed-won data once you have it.
The code is dependency-free TypeScript using pingutil's field names; any enrichment source with the same shape works.
Why points, not a model
A trained classifier can beat a points table, given enough data. Most B2B teams don't have enough, and they need sales to trust the score. A points table is readable ("+20 company domain, +15 target industry, minus 10 for a role inbox" is something a rep can argue with), and when a good lead scores badly you can see which line did it.
This is fit scoring: who the lead is. Behaviour (pages viewed, trial activity) belongs in a second score; mixing the two makes both harder to tune.
The inputs
| Input | Source field | Notes |
|---|---|---|
| Category | category |
company, free, isp, educational, government, disposable, unknown |
| Company size | company.employee_size_estimate |
One of eight fixed ranges, 1-10 to 10000+ |
| Industry | company.industry, company.sub_industry |
Free text from an LLM, so map it to a short list |
| Role / system / vendor | verdict.reason |
role_based, system_generated, sales_outreach |
| Randomized address | verdict.is_spam, verdict.reason |
randomized_local_part |
| Profile trust | company.confidence |
The model's own 0 to 1 estimate |
Size and industry exist only for company domains (business email vs personal email covers the categories). How accurate LLM-based enrichment is explains the confidence gate.
Hard rules before points
Some facts end the conversation. They run first and return no score at all:
verdict.is_spamis true: a disposable domain (from the community disposable-email-domains list), a reputation-flagged domain or a randomized local part.verdict.reasonissystem_generated(noreply@,mailer-daemon@) orsales_outreach(a vendor pitching you).categoryisunknown, meaning the input wasn't a valid email.
Giving these leads -100 instead isn't equivalent. Someone will eventually add a big bonus for some segment, and a disposable signup will float into the A band.
Starting weights
These are guesses chosen to be easy to reason about, not benchmarks. They assume a product that sells best to companies of a few hundred people in software, finance and commerce. Change them to match your ideal customer before you ship.
| Feature | Points | Reasoning |
|---|---|---|
Category company |
+20 | Business domain, enrichable |
Category government |
+5 | Real organization, slow buyer |
Category educational |
0 | Often students; depends on your market |
Category free or isp |
-10 | Personal inbox; could still be a founder |
Size 1-10 |
0 | |
Size 11-50 |
+5 | |
Size 51-200 |
+15 | |
Size 201-500, 501-1000 |
+20 | Assumed sweet spot |
Size 1001-5000 |
+15 | |
Size 5001-10000, 10000+ |
+10 | Longer cycles |
| Industry matches target list | +15 | |
| Industry on excluded list | -20 | |
Role inbox (info@, support@) |
-10 | Real company, unknown person |
| Profile confidence below 0.6 | size and industry skipped | Wrong data is worse than none |
| Band A | 40 or more | |
| Band B | 20 to 39 | |
| Band C | below 20 |
Free webmail gets -10, not a disqualification: plenty of founders sign up with Gmail.
The scoring function
// score.ts
export type Lookup = {
category: 'company' | 'free' | 'disposable' | 'educational' | 'government' | 'isp' | 'unknown';
company: {
industry: string | null;
sub_industry: string | null;
employee_size_estimate: string | null;
confidence: number | null;
} | null;
verdict: { is_spam: boolean; low_trust: boolean; reason: string | null };
};
export type Line = { feature: string; points: number };
export type Score =
| { qualified: false; reason: string; lines: Line[] }
| { qualified: true; total: number; band: 'A' | 'B' | 'C'; lines: Line[] };
// Every number below is a starting guess. Calibrate them against your own data.
export const WEIGHTS = {
category: { company: 20, government: 5, educational: 0, free: -10, isp: -10 } as Record<string, number>,
size: {
'1-10': 0, '11-50': 5, '51-200': 15, '201-500': 20,
'501-1000': 20, '1001-5000': 15, '5001-10000': 10, '10000+': 10,
} as Record<string, number>,
industry: { target: 15, excluded: -20, other: 0 },
role: { role_based: -10 } as Record<string, number>,
minConfidence: 0.6,
bands: { A: 40, B: 20 },
};
const TARGET_INDUSTRY = /software|saas|fintech|financ|e-?commerce|retail/i;
const EXCLUDED_INDUSTRY = /education|non-?profit|government/i;
export function scoreLead(l: Lookup): Score {
const lines: Line[] = [];
// Hard rules first. These are disqualifications, not negative points.
if (l.verdict.is_spam) return { qualified: false, reason: `spam:${l.verdict.reason}`, lines };
if (l.verdict.reason === 'system_generated' || l.verdict.reason === 'sales_outreach') {
return { qualified: false, reason: l.verdict.reason, lines };
}
if (l.category === 'unknown' || l.category === 'disposable') {
return { qualified: false, reason: `category:${l.category}`, lines };
}
lines.push({ feature: `category:${l.category}`, points: WEIGHTS.category[l.category] ?? 0 });
if (l.verdict.reason === 'role_based') {
lines.push({ feature: 'role_based', points: WEIGHTS.role.role_based });
}
const c = l.company;
if (l.category === 'company' && c) {
if ((c.confidence ?? 0) < WEIGHTS.minConfidence) {
lines.push({ feature: 'low_confidence_profile', points: 0 });
} else {
const size = c.employee_size_estimate;
if (size && size in WEIGHTS.size) lines.push({ feature: `size:${size}`, points: WEIGHTS.size[size] });
const text = `${c.industry ?? ''} ${c.sub_industry ?? ''}`;
const fit = EXCLUDED_INDUSTRY.test(text) ? 'excluded' : TARGET_INDUSTRY.test(text) ? 'target' : 'other';
lines.push({ feature: `industry:${fit}`, points: WEIGHTS.industry[fit] });
}
}
const total = lines.reduce((sum, x) => sum + x.points, 0);
const band = total >= WEIGHTS.bands.A ? 'A' : total >= WEIGHTS.bands.B ? 'B' : 'C';
return { qualified: true, total, band, lines };
}
scoreLead is a pure function, so fixture lookups test it completely. Every result carries lines, the breakdown behind the total. Store it on the lead record, so the answer to "why is this a C?" is right there.
The lookup itself is a normal server-side call without skip_enrichment, since scoring needs the company profile:
// lookup.ts
import { scoreLead, type Lookup, type Score } from './score.ts';
export async function scoreEmail(email: string): Promise<Score | { qualified: null; reason: string }> {
let res: Response;
try {
res = await fetch('https://pingutil.com/v1/lookup', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PINGUTIL_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ input: email }), // full lookup: scoring needs the company profile
signal: AbortSignal.timeout(15_000),
});
} catch {
return { qualified: null, reason: 'lookup_unavailable' };
}
// 429 carries retry_after_seconds. In a queue worker, re-enqueue with that delay instead.
if (res.status === 429) return { qualified: null, reason: 'rate_limited' };
if (!res.ok) return { qualified: null, reason: `lookup_http_${res.status}` };
return scoreLead((await res.json()) as Lookup);
}
The API key stays in a server environment variable. When the lookup can't run, the lead is "unscored", not "C": send it to the default queue and score it later. Treating an outage as a low score hides good leads.
Calibrating the weights against closed-won data
After a quarter or two, check the weights against what actually closed. Two rules make this work.
Log features at scoring time. Store lines when the lead arrives. Re-enriching old leads gives you the current company size and industry, not what you knew when the lead arrived.
Compare each feature's win rate to your overall win rate. For every feature with enough leads behind it, compute the share that became closed-won, divide by the base rate, and turn that lift into points on a log scale. A feature that doubles the win rate gets about +10; one that halves it gets about -10.
// calibrate.ts
// rows: one per lead, with the features logged at scoring time and the final outcome.
export type Row = { features: string[]; won: boolean };
export type Suggestion = { feature: string; leads: number; wins: number; winRate: number; lift: number; points: number };
export function calibrate(rows: Row[], { minLeads = 30, prior = 20, scale = 10, cap = 30 } = {}): Suggestion[] {
const baseRate = rows.filter((r) => r.won).length / rows.length;
if (!(baseRate > 0)) throw new Error('need at least one won lead to calibrate');
const stats = new Map<string, { leads: number; wins: number }>();
for (const r of rows) {
for (const f of new Set(r.features)) {
const s = stats.get(f) ?? { leads: 0, wins: 0 };
s.leads += 1;
if (r.won) s.wins += 1;
stats.set(f, s);
}
}
const out: Suggestion[] = [];
for (const [feature, { leads, wins }] of stats) {
if (leads < minLeads) continue; // too few leads to say anything
// Shrink toward the base rate so small buckets can't produce extreme weights.
const winRate = (wins + prior * baseRate) / (leads + prior);
const lift = winRate / baseRate;
const points = Math.max(-cap, Math.min(cap, Math.round(scale * Math.log2(lift))));
out.push({ feature, leads, wins, winRate, lift, points });
}
return out.sort((a, b) => b.points - a.points);
}
Feed it one row per lead that reached a final outcome, with the features from lines and won from your CRM. Leave open opportunities out; they haven't been decided yet.
How to read the output:
minLeadsdrops features with too few leads to say anything. Raise it if your suggestions jump around between runs.priorshrinks every bucket toward the base rate, as if it had that many extra leads at the average win rate. A bucket of 30 leads with 6 wins is pulled in more than a bucket of 300 with 60.- Don't copy suggestions blindly. Features overlap. Every
size:*lead is alsocategory:company, so summing the suggested points for both counts part of the same effect twice. Calibrate size and industry using only company-domain leads as the base, or move partway toward the suggestion and re-run next quarter. - Look for surprises. The useful output is the feature whose suggested points have the opposite sign to what you assumed.
Once you have a solid volume of closed deals, a logistic regression on the same features is the natural next step. The points table remains a good way to explain its output to sales.
Using the score
A score is only useful if it changes what happens to a lead: for example, A to a rep, B to nurture with a weekly review, C to automation only. Pair it with the router in routing inbound leads by company size and industry so size decides who and the score decides how fast.
Before turning it on:
- Replace the industry patterns with your own.
- Store
lineswith every score. - Treat lookup failures as unscored, never as C.
- Schedule the first calibration after your next full quarter of outcomes.
All company fields available for extra features, such as funding_stage or headquarters, are in the API reference.
Questions people ask
- What is a points-based lead scoring model?
- A set of rules that add or subtract points for attributes of a lead, such as company size or industry, and sum them into a score. Score bands then decide what happens next. Its main advantage over a black-box model is that anyone can read why a lead got its score.
- How many closed deals do I need to calibrate lead scoring weights?
- There is no fixed number. Each bucket you calibrate needs enough leads that its win rate isn't noise, which is why the method here ignores small buckets and shrinks the rest toward your overall win rate. With very few wins, keep hand-set weights and revisit later.
- Should disposable or spam emails get negative points or be excluded?
- Excluded. A lead from a disposable address should never be rescued by a large company size or a good industry, so treat it as a hard disqualification that runs before any points are added.