Skip to content

Routing inbound leads by company size and industry

· 8 min read · Company enrichment

To route inbound leads by company size and industry, enrich the lead's email domain into an employee range, an industry and a headquarters country, then run an ordered list of rules where the first match picks the queue. The parts that go wrong are almost never the rules themselves. They are unnormalized inputs (free-text industries, three spellings of the same country), unknown or low-confidence sizes silently falling into the SMB queue, and nobody being able to say why a lead went where it did. Design for those and the rules are the easy part.

This post assumes you already have an enrichment step producing company data; if not, start with enriching inbound leads from an email domain. Code is TypeScript and uses the field names from pingutil's response, but the structure works with any enrichment source.

Route in this order

Rules fire in a fixed order. Earlier rules are about who the lead is; later ones are about which segment they fall into.

  1. Not a lead. Disposable addresses, spam verdicts, and vendors pitching you (an address flagged as sales outreach). These go nowhere or to a separate inbox.
  2. Existing account. If the domain matches an account with an owner, the owner gets it. This beats every segment rule. Two people from the same company landing with two different reps is the fastest way to annoy both the customer and the reps. See spotting multiple signups from the same company.
  3. Non-company domains. Free webmail, ISP, education, government. Route by your policy for each; most B2B teams send personal-email leads to a nurture track and edu/gov to a specialist if they have one.
  4. Segment rules. Size first, then industry or territory overrides.
  5. Fallback. Anything that didn't match goes to triage. Never to "SMB by default".

Normalize the inputs first

Rules are only as good as what they compare against. Three fields need work.

Size: fixed buckets, mapped to your segments

pingutil returns employee_size_estimate as one of eight fixed strings: 1-10, 11-50, 51-200, 201-500, 501-1000, 1001-5000, 5001-10000, 10000+. Fixed strings are good news: map them once to your segments and never parse numbers out of them.

Where you draw the segment edges is a business decision, not a data one. Pull your closed-won deals, group by employee band, and look for where deal size or sales cycle length jumps. That's your edge.

Industry: free text, so map it to a short list

The industry field is free text written by an LLM. You'll see "Financial Services", "Fintech", "Payments" and "Banking" for companies your sales team considers one vertical. Don't route on the raw string. Map it to the handful of verticals you actually have specialists for, using keyword rules on both industry and sub_industry, and let everything else fall to "general".

Country: free text again, so map to ISO codes

headquarters.country comes back as a name like "United States", not an ISO code. Other providers return "US" or "USA". Map everything to ISO 3166-1 alpha-2 codes before any territory rule sees it.

An explicit lookup table beats anything clever. You only care about the countries your territories name; everything else can fall through to triage, and you add entries as you see them in the logs. Deriving a name-to-code map automatically is tempting and fragile: inverting Intl.DisplayNames over every two-letter code, for example, maps "Germany" to DD, the retired code for East Germany, because it sorts before DE.

The router

Everything below is one file with no dependencies. route() is a pure function of the lookup response plus an optional existing-account match, which makes it trivial to test.

// router.ts
type Segment = 'smb' | 'mid_market' | 'enterprise';
type Territory = 'NA' | 'EMEA' | 'APAC' | 'LATAM';
type Vertical = 'fintech' | 'healthcare' | 'software' | 'general';

export type RouteResult = { queue: string; rule: string };

const MIN_CONFIDENCE = 0.6;

const SIZE_TO_SEGMENT: Record<string, Segment> = {
  '1-10': 'smb', '11-50': 'smb', '51-200': 'smb',
  '201-500': 'mid_market', '501-1000': 'mid_market',
  '1001-5000': 'enterprise', '5001-10000': 'enterprise', '10000+': 'enterprise',
};

const COUNTRY_ALIASES: Record<string, string> = {
  'united states': 'US', 'united states of america': 'US', usa: 'US', us: 'US',
  canada: 'CA', ca: 'CA',
  'united kingdom': 'GB', uk: 'GB', gb: 'GB', 'great britain': 'GB', england: 'GB',
  germany: 'DE', deutschland: 'DE', de: 'DE',
  france: 'FR', fr: 'FR',
  netherlands: 'NL', 'the netherlands': 'NL', nl: 'NL',
  india: 'IN', in: 'IN',
  australia: 'AU', au: 'AU',
  singapore: 'SG', sg: 'SG',
  japan: 'JP', jp: 'JP',
  brazil: 'BR', br: 'BR',
  mexico: 'MX', mx: 'MX',
};

const ISO_TO_TERRITORY: Record<string, Territory> = {
  US: 'NA', CA: 'NA',
  GB: 'EMEA', DE: 'EMEA', FR: 'EMEA', NL: 'EMEA',
  IN: 'APAC', AU: 'APAC', SG: 'APAC', JP: 'APAC',
  BR: 'LATAM', MX: 'LATAM',
};

const VERTICAL_PATTERNS: [Vertical, RegExp][] = [
  ['fintech', /financ|fintech|payment|bank|lending|insur/i],
  ['healthcare', /health|medic|pharma|biotech|clinic|hospital/i],
  ['software', /software|saas|developer|internet|information technology|cloud/i],
];

export function toIso(country: string | null | undefined): string | null {
  if (!country) return null;
  return COUNTRY_ALIASES[country.trim().toLowerCase()] ?? null;
}

export function toVertical(industry: string | null, subIndustry: string | null): Vertical {
  const text = `${industry ?? ''} ${subIndustry ?? ''}`;
  for (const [vertical, re] of VERTICAL_PATTERNS) if (re.test(text)) return vertical;
  return 'general';
}

export function route(lookup: any, existingOwner?: string | null): RouteResult {
  // 1. Not a lead
  if (lookup.verdict.is_spam) return { queue: 'discard', rule: `spam:${lookup.verdict.reason}` };
  if (lookup.verdict.reason === 'sales_outreach') return { queue: 'vendor_inbox', rule: 'sales_outreach' };

  // 2. Existing account wins
  if (existingOwner) return { queue: `owner:${existingOwner}`, rule: 'existing_account' };

  // 3. Non-company domains
  if (lookup.category === 'free' || lookup.category === 'isp') return { queue: 'nurture', rule: `personal:${lookup.category}` };
  if (lookup.category === 'educational') return { queue: 'education', rule: 'educational' };
  if (lookup.category === 'government') return { queue: 'public_sector', rule: 'government' };
  if (lookup.category !== 'company') return { queue: 'triage', rule: `category:${lookup.category}` };

  // 4. Segment rules, only on data we trust
  const c = lookup.company;
  if (!c) return { queue: 'triage', rule: 'no_profile' };
  if ((c.confidence ?? 0) < MIN_CONFIDENCE) return { queue: 'triage', rule: 'low_confidence' };

  const segment = c.employee_size_estimate ? SIZE_TO_SEGMENT[c.employee_size_estimate] : undefined;
  if (!segment) return { queue: 'triage', rule: 'unknown_size' };

  const iso = toIso(c.headquarters?.country);
  const territory = iso ? ISO_TO_TERRITORY[iso] : undefined;
  const vertical = toVertical(c.industry, c.sub_industry);

  // Overrides: enterprise fintech and healthcare go to specialists regardless of territory
  if (segment === 'enterprise' && (vertical === 'fintech' || vertical === 'healthcare')) {
    return { queue: `enterprise_${vertical}`, rule: `enterprise+${vertical}` };
  }
  if (segment === 'enterprise' || segment === 'mid_market') {
    if (!territory) return { queue: 'triage', rule: `unknown_territory:${c.headquarters?.country ?? 'null'}` };
    return { queue: `${segment}_${territory}`, rule: `${segment}+${territory}` };
  }
  // 5. No implicit fallback: every path returns a queue and the rule that chose it.
  return { queue: 'smb_pooled', rule: 'smb' }; // SMB is pooled globally in this example
}

Getting the lookup is one call. Use the full lookup here (not skip_enrichment), because routing needs the company profile:

const 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: lead.email }),
});
const lookup = await res.json();
const { queue, rule } = route(lookup, await findAccountOwner(lookup.domain));
await saveRouting(lead.id, { queue, rule, routed_at: new Date().toISOString() });

findAccountOwner and saveRouting are yours: a CRM query keyed on the registrable domain (lookup.domain, already normalized so eu.acme.com and acme.com match), and wherever you record the decision.

Design choices worth defending

Every result carries the rule that fired. Store rule on the lead. When a rep asks why a 5,000-person bank landed in triage, the answer is in the record (low_confidence), not in someone's memory of how the router works.

Low confidence goes to triage, not to the segment. pingutil's profiles are LLM-generated and the confidence is the model's own estimate. A small company with a famous-sounding name can come back with a big company's headcount. A wrong segment is worse than no segment, because nobody looks twice at a lead that seems correctly routed. How accurate LLM-based enrichment is goes into where it's weakest.

Unknown size is not SMB. This is the most common silent failure in routing setups. Enrichment coverage is weakest for small and non-US companies, so "unknown" skews small, but it also contains every large company your provider missed. A triage queue reviewed daily by one person costs less than the enterprise deals that sat in a self-serve nurture sequence.

Overrides are explicit and few. The example has one override (enterprise fintech and healthcare). Every override you add is a rule someone has to remember. If you find yourself writing ten, your segments are wrong.

Test it like code

Because route() is pure, a table of fixtures covers it. Node's built-in test runner is enough:

// router.test.ts  (run with: node --test --experimental-strip-types router.test.ts)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { route } from './router.ts';

const company = (over: object) => ({
  category: 'company',
  verdict: { is_spam: false, low_trust: false, reason: null },
  company: {
    confidence: 0.9, employee_size_estimate: '201-500', industry: 'Software',
    sub_industry: null, headquarters: { city: null, region: null, country: 'Germany' },
    ...over,
  },
});

const cases: [string, any, string | null, string][] = [
  ['disposable', { category: 'disposable', verdict: { is_spam: true, reason: 'disposable_domain' } }, null, 'discard'],
  ['existing owner wins', company({}), 'maria', 'owner:maria'],
  ['gmail to nurture', { category: 'free', verdict: { is_spam: false, reason: null }, company: null }, null, 'nurture'],
  ['mid-market DE', company({}), null, 'mid_market_EMEA'],
  ['low confidence', company({ confidence: 0.3 }), null, 'triage'],
  ['unknown size', company({ employee_size_estimate: null }), null, 'triage'],
  ['enterprise bank', company({ employee_size_estimate: '10000+', industry: 'Banking' }), null, 'enterprise_fintech'],
  ['unmapped country', company({ headquarters: { country: 'Estonia' } }), null, 'triage'],
];

for (const [name, lookup, owner, expected] of cases) {
  test(name, () => assert.equal(route(lookup, owner).queue, expected));
}

Add a case every time someone reports a misroute. After a few months, the fixture table doubles as documentation of your routing policy.

Checklist before you turn it on

  • Pick segment edges from closed-won data, not from a blog post (including this one).
  • Map size, industry and country to fixed values before any rule compares them.
  • Check existing account ownership before segment rules.
  • Gate segment rules on enrichment confidence; send the rest to triage.
  • Store the rule that fired on every lead.
  • Review the triage queue daily for the first month, and turn recurring patterns into new mappings, not new overrides.
  • Keep the router a pure function with a fixture test for every past misroute.

The full response schema, including every company field, is in the API reference.

Questions people ask

What employee ranges should define SMB, mid-market and enterprise?
There's no standard. A common split is under 200 employees for SMB, 200 to 1,000 for mid-market and over 1,000 for enterprise, but the right edges are wherever your deal sizes and sales motions actually change. Look at closed-won data before picking.
What should happen to a lead when company size is unknown?
Send it to a small triage queue that a person reviews, not to the SMB queue by default. Defaulting unknowns to the lowest tier quietly underserves the large companies your enrichment happened to miss.
Should lead routing check for existing accounts first?
Yes. If the lead's domain matches an account that already has an owner, route to that owner before any segment rule runs. Segment rules are for net-new companies.