Skip to content
Creative Codes
Creative Codes
← All insights
ScrapingJune 22, 2026· Updated: September 22, 20269 min read

B2B Lead Enrichment Pipelines: From Raw Email to Qualified Contact Data

A raw email address tells you almost nothing about a lead. A properly enriched record tells you company size, industry, job seniority, technology stack, and whether they match your ICP. Here's how to build the pipeline that gets you from one to the other.

Muhammad Hassan

Founder, Creative Codes. 8 years on backends; last 3 deep on AI agents, RAG pipelines, and production scraping. Python, LangGraph, Playwright, n8n, FastAPI.

A raw email address tells you almost nothing about a lead. A properly enriched record tells you company size, industry, job title, seniority, technology stack, funding stage, and whether this person matches your ideal customer profile. The gap between raw and enriched is where B2B sales efficiency lives, and building the pipeline that reliably closes that gap is the difference between a sales team spending time on good-fit prospects and one drowning in unqualified outreach.

This post covers the enrichment pipeline architecture: data sources, waterfall logic, normalization, and routing.

What enrichment actually provides

From a business email address, enrichment APIs can return:

Person-level data: first name, last name, job title, seniority level (individual contributor / manager / director / VP / C-suite), LinkedIn URL, professional history

Company-level data: company name, website, company size (employee count range), industry (GICS/SIC classification), annual revenue range, headquarters location, founding year

Technology stack: what software the company uses (CRM, marketing automation, analytics tools, cloud providers), extracted from job postings, website scripts, and tech stack detection

Funding data: last funding round, total funding raised, investors (from Crunchbase, PitchBook via third-party enrichment APIs)

Not every field is available for every contact. Enrichment coverage varies significantly by industry, company size, and how well-indexed the contact is. A VP at a 500-person SaaS company in the US will have high coverage. A manager at a regional services firm in Southeast Asia may return very little.

Data sources and their tradeoffs

HubSpot enrichment: useful when your CRM already runs on HubSpot. Eligibility, credits, and available fields depend on the current plan. Check HubSpot's enrichment documentation and test your own sample before estimating coverage.

Apollo.io: a contact and company data source with API access on eligible plans. Test coverage in the industries and regions you target; a provider's headline database size does not predict match quality for your list.

Hunter.io: email verification and domain-level company data. Doesn't return person-level detail but is useful for validating email deliverability before enrichment.

ZoomInfo: an enterprise data option. Compare contract terms, permitted uses, field coverage, and update frequency before committing.

Public company sources: websites and other sources you are permitted to use can help verify company-level facts. Review site terms, privacy law, retention, and outreach rules for each source. Do not assume that a public profile can be collected or reused without restrictions.

Waterfall enrichment

No single provider covers 100% of contacts. A waterfall pattern tries multiple sources in sequence, stopping when sufficient data is found:

python
from dataclasses import dataclass, field
from typing import Optional
import httpx

@dataclass
class EnrichedContact:
    email: str
    first_name: Optional[str] = None
    last_name: Optional[str] = None
    job_title: Optional[str] = None
    seniority: Optional[str] = None
    company_name: Optional[str] = None
    company_size: Optional[str] = None
    industry: Optional[str] = None
    linkedin_url: Optional[str] = None
    enrichment_sources: list[str] = field(default_factory=list)

    @property
    def is_sufficiently_enriched(self) -> bool:
        """True if we have the minimum fields needed for ICP scoring."""
        return bool(
            self.company_name
            and self.company_size
            and self.job_title
        )

def enrich_from_crm(email: str) -> Optional[dict]:
    """Use the documented enrichment interface for your CRM plan.

    Return normalized fields or None. Keep credentials and provider-specific
    request details in the adapter, not in the routing logic.
    """
    raise NotImplementedError

def enrich_from_apollo(email: str) -> Optional[dict]:
    try:
        response = httpx.post(
            "https://api.apollo.io/v1/people/match",
            json={"email": email, "reveal_personal_emails": False},
            headers={"X-Api-Key": APOLLO_KEY},
            timeout=5.0,
        )
        if response.status_code == 200:
            person = response.json().get("person") or {}
            org = person.get("organization") or {}
            return {
                "first_name": person.get("first_name"),
                "last_name": person.get("last_name"),
                "job_title": person.get("title"),
                "seniority": person.get("seniority"),
                "company_name": org.get("name"),
                "company_size": org.get("estimated_num_employees"),
                "industry": org.get("industry"),
                "linkedin_url": person.get("linkedin_url"),
                "source": "apollo",
            }
    except Exception:
        pass
    return None

def waterfall_enrich(email: str) -> EnrichedContact:
    contact = EnrichedContact(email=email)

    for enrich_fn in [enrich_from_crm, enrich_from_apollo]:
        result = enrich_fn(email)
        if result:
            # Fill in any missing fields from this source
            for key, value in result.items():
                if key == "source":
                    contact.enrichment_sources.append(value)
                    continue
                if value and not getattr(contact, key, None):
                    setattr(contact, key, value)

        if contact.is_sufficiently_enriched:
            break

    return contact

The waterfall tries the CRM's existing enrichment first, then a second provider if required fields remain empty. Which provider goes first should come from a sample test of match rate, field accuracy, permitted use, and cost for your actual list.

Technology stack detection

For selling technical products or services, knowing what tools a company already uses is high-signal enrichment. A company using HubSpot CRM and running on AWS with a React frontend has a different buyer profile than one using Salesforce and running on a custom Java stack.

Technology stack data comes from two places:

  • Job postings: companies list their tech stack in engineering job descriptions. Scraping a company's open roles and extracting technology mentions gives a reasonably current picture of their stack.
  • Website fingerprinting: tools like Wappalyzer, BuiltWith, and Datanyze detect technologies from website headers, script tags, and meta patterns.
python
import httpx
from bs4 import BeautifulSoup

TECH_PATTERNS = {
    "HubSpot CRM": ["hubspot.com/hs-scripts", "hsforms.com"],
    "Salesforce": ["salesforce.com", "force.com"],
    "Intercom": ["intercom.io", "intercomcdn.com"],
    "Stripe": ["js.stripe.com"],
    "Segment": ["cdn.segment.com", "segment.io"],
    "Google Analytics 4": ["gtag/js", "G-"],
}

def detect_website_tech(domain: str) -> list[str]:
    try:
        response = httpx.get(f"https://{domain}", timeout=8.0, follow_redirects=True)
        html = response.text
        detected = []
        for tech, patterns in TECH_PATTERNS.items():
            if any(p in html for p in patterns):
                detected.append(tech)
        return detected
    except Exception:
        return []

For ICP scoring, technology stack signals can be decisive: if you're selling a data pipeline product, a company already using Segment + Snowflake is a much warmer prospect than one with no data infrastructure.

ICP scoring

Once enriched, score the contact against your ideal customer profile:

python
def calculate_icp_score(contact: EnrichedContact) -> tuple[int, str]:
    """Returns (score 0-100, tier A/B/C/D)."""
    score = 0

    # Company size (example ICP: 50-500 employees)
    size = contact.company_size or ""
    if any(s in size for s in ["51-200", "201-500", "51-100", "101-250"]):
        score += 35
    elif any(s in size for s in ["11-50", "501-1000"]):
        score += 20
    elif any(s in size for s in ["1-10"]):
        score += 5

    # Industry fit
    target_industries = {"technology", "saas", "e-commerce", "financial services", "healthcare"}
    if any(ind in (contact.industry or "").lower() for ind in target_industries):
        score += 30

    # Seniority (prefer decision makers)
    if contact.seniority in ("director", "vp", "c_suite", "owner", "founder"):
        score += 35
    elif contact.seniority in ("manager", "senior"):
        score += 20

    tier = "A" if score >= 80 else "B" if score >= 55 else "C" if score >= 30 else "D"
    return score, tier

Tier A and B contacts route to direct sales outreach. Tier C goes to a nurture email sequence. Tier D is deprioritized or excluded entirely, it's not worth a rep's time.

Enrichment at scale: batching and caching

For large lists (import of 10,000 contacts, weekly re-enrichment of the entire CRM), enrichment must be batched to control rate limits and costs.

Key operational patterns:

  • Cache enrichment results per email for 90 days. Re-enriching the same email weekly is wasteful. Job titles and company sizes don't change that fast.
  • Prioritize, enrich new leads immediately, batch-enrich historical records overnight
  • Track coverage, log which fields came back null per provider; if a provider's coverage drops below 40%, investigate whether your query patterns have changed

Estimate spend using your contracted plan and a representative sample. Track requests, successful matches, useful fields, and cost per qualified record. A cheap match that returns the wrong company or an unusable title is not a saving.

What to do when enrichment fails

About 20-30% of contacts in a typical B2B list won't enrich successfully, personal email addresses, very small companies, or contacts in regions with low database coverage. For these:

  • Flag as "unenriched" in the CRM
  • Route to a lower-priority sequence with more generic messaging
  • Periodically re-try enrichment (people change jobs, new LinkedIn profiles appear)
  • Use domain-level company data (from Hunter or a web scrape) when person-level enrichment fails

One pattern worth implementing: a manual enrichment queue. For Tier A accounts (large deal size, close to ICP but missing key fields), route to a human reviewer to fill gaps manually rather than letting the automation downgrade them. The economics work when a single Tier A deal is worth more than the 15 minutes it takes a sales rep to manually look up the missing fields. For Tier C and D, the automation's best guess is good enough, human review is not worth the time.

Finally, treat enrichment data as decaying. Job titles change. Companies get acquired. A contact enriched 18 months ago may now be at a different company entirely. Re-enriching the active CRM database quarterly, or whenever a contact re-engages, keeps the data fresh and the ICP scoring accurate.


If you're building a lead enrichment pipeline and need it to handle multiple sources, scoring, and CRM sync, tell us about the project.

Related: Automating the Sales Pipeline: Lead Capture, CRM Sync, and Follow-up Sequences | How We Scrape at Scale Without Getting Blocked

Lead Enrichment services →

Have a project in mind?

Start with a free conversation about your requirements, fit and next steps.

Discuss your project

Related capability

Need large-scale scraping built to run without getting blocked?

Web Scraping & Data Extraction →

Business outcome

Turn public market data into monitored, usable intelligence.

Explore Market Intelligence →

We publish new posts every few weeks. See more on the insights page.