Enriching inbound leads from nothing but an email domain
· 6 min read · Company enrichment
From a single work email you can reliably get the company's domain and, through enrichment, a firmographic profile: name, industry, employee range, headquarters and social links. You can't get anything about the person beyond what the local part hints at (a role address like sales@, for example). The work is less in the lookup itself and more in the plumbing around it: which domains to skip, when to run the lookup, how to store results so you know where every value came from, and when not to overwrite.
This post is about that plumbing. It assumes you've already got the basics of finding the company behind an email address; here we're designing the enrichment step of a lead pipeline so it stays trustworthy after six months of real traffic.
What an email domain can and can't tell you
| You can get | You can't get |
|---|---|
The registrable domain (eu.acme.com becomes acme.com) |
The person's title or seniority |
| Whether it's a company, free webmail, ISP, university, government or disposable | Their phone number or LinkedIn profile |
| Company name, industry, size band, HQ, funding stage (via enrichment) | Which team or office they sit in |
| Whether the address is a role or system address | Whether the mailbox exists (that needs SMTP, which is unreliable) |
If your sales team expects title and phone from enrichment, that's a person-data product and a different budget conversation. Set that expectation before you build anything.
Step 1: gate by domain type before you spend anything
Most inbound forms get a meaningful share of personal addresses. Every one you send to an enrichment provider is either a wasted credit or, worse, a confident profile of Google attached to a freelancer.
Classify first:
- company: enrich.
- free (webmail providers) and isp (consumer ISP domains like comcast.net): skip company enrichment. Keep the lead, mark the source as personal.
- educational and government: don't enrich as a company. Route to whoever handles those segments, if anyone.
- disposable: this isn't a lead. Drop or quarantine it.
Role addresses (info@, sales@) on a company domain are still worth enriching at the company level, since the company is real even if the person isn't identifiable. Flag them so a rep knows not to expect a named contact.
Step 2: decide sync or async
Run enrichment synchronously only if something in the request depends on it: a form that hides the company-size field when you already know it, or a router that must assign an owner before the thank-you page renders.
Everything else should be async. A job queue, a webhook from your form tool, or a CRM workflow that fires on contact creation. The reasons are practical. First-seen domains can take seconds to enrich. Providers have outages. A lead that waits on a third-party API is a lead that can be lost to a timeout.
If you do run it inline, set a hard timeout and fall back to classification alone. A lead with category: company and no profile is still routable; a lead lost to a 504 is not.
Step 3: store provenance next to every enriched value
This is the part teams skip and regret. Six months in, someone asks "why does this account say 51-200 employees when they have 2,000?", and nobody can tell whether a rep typed it, a form captured it, or an enrichment provider guessed it.
Store, per enriched record:
enrichment_source: which provider and model produced the data.enrichment_confidence: the provider's confidence, if it gives one.enriched_at: when.enrichment_domain: the registrable domain that was looked up (useful when the email changes later).
In most CRMs these are four custom properties on the company object. They cost nothing and make every later argument about data quality settle in seconds.
Step 4: never overwrite a human
The rule: enrichment fills empty fields and its own previous values. It never overwrites a value a person entered, or one that came from a higher-trust source like a signed order form.
HubSpot's own enrichment follows a similar default. Its documentation says existing data is kept unless you explicitly choose to overwrite. Copy that default, whatever tool you use.
You can only implement this rule if you know where each value came from, which is why step 3 comes first.
The enrichment function
Here's the whole step as one function. It takes an email and the current CRM record, and returns a patch: only the fields that should change. It uses pingutil for classification and enrichment, but the gating, provenance and overwrite logic is the point, and it ports to any provider.
type CrmCompany = {
name?: string | null;
industry?: string | null;
employee_band?: string | null;
hq_country?: string | null;
linkedin_url?: string | null;
// provenance
enrichment_source?: string | null;
enrichment_confidence?: number | null;
enriched_at?: string | null;
enrichment_domain?: string | null;
// which fields enrichment owns (anything not listed here was set by a person or another system)
enriched_fields?: string[];
};
type EnrichmentOutcome =
| { action: 'skip'; reason: string; category: string }
| { action: 'patch'; patch: Partial<CrmCompany> };
const MIN_CONFIDENCE = 0.6;
export async function enrichLead(email: string, current: CrmCompany): Promise<EnrichmentOutcome> {
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: email }),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`lookup failed: ${res.status}`); // let the queue retry
const data = await res.json();
if (data.verdict.is_spam) return { action: 'skip', reason: 'spam', category: data.category };
if (data.category !== 'company') return { action: 'skip', reason: 'not_a_company_domain', category: data.category };
const c = data.company;
if (!c) {
// Enrichment failed upstream (see meta.enrichment_error). Retry later; don't write nulls.
return { action: 'skip', reason: data.meta.enrichment_error ?? 'no_profile', category: data.category };
}
if ((c.confidence ?? 0) < MIN_CONFIDENCE) {
return { action: 'skip', reason: 'low_confidence', category: data.category };
}
const candidate: Partial<CrmCompany> = {
name: c.name,
industry: c.industry,
employee_band: c.employee_size_estimate,
hq_country: c.headquarters?.country ?? null,
linkedin_url: c.linkedin_url,
};
const owned = new Set(current.enriched_fields ?? []);
const patch: Partial<CrmCompany> = {};
const nowOwned: string[] = [];
for (const [field, value] of Object.entries(candidate) as [keyof CrmCompany, any][]) {
if (value == null) continue; // never write a null over anything
const existing = current[field];
const isEmpty = existing == null || existing === '';
if (isEmpty || owned.has(field)) { // fill blanks, or update our own past values
(patch as any)[field] = value;
nowOwned.push(field);
}
}
if (nowOwned.length === 0) return { action: 'skip', reason: 'nothing_to_change', category: data.category };
return {
action: 'patch',
patch: {
...patch,
enrichment_source: data.meta.source,
enrichment_confidence: c.confidence,
enriched_at: new Date().toISOString(),
enrichment_domain: data.domain,
enriched_fields: [...new Set([...owned, ...nowOwned])],
},
};
}
A few decisions in there worth calling out:
- Confidence threshold of 0.6. pingutil's company profiles are LLM-generated, and the
confidenceis the model's own estimate. Below 0.5 the API also setsmeta.low_confidence: true. Pick your threshold by testing against companies you know, not by intuition. - Failures don't write. If the provider is down, the function returns
skipand the record stays as it was. A later retry fills it in. - The
enriched_fieldslist is what makes "never overwrite a human" enforceable. When a rep editsindustryby hand, your CRM workflow should removeindustryfrom that list.
If you're on HubSpot, enriching contacts with a workflow webhook shows how to call a function like this from a workflow and write the patch back.
Step 5: key on the domain, not the lead
Ten people from the same company fill in your form over a quarter. That's one company to enrich, not ten. Cache results by registrable domain, and enrich the company object rather than each contact. You save lookups, and every contact from acme.com gets the same answer, which matters when two reps compare notes. Caching domain lookups has TTL guidance.
Step 6: refresh on a schedule, not on every touch
Company facts change on a scale of months. Re-enrich when enriched_at is older than 60 to 90 days and the lead re-engages, or on a nightly job that picks up stale records in small batches. Refreshing on every form fill makes fields flap between slightly different values and burns through rate limits.
Pipeline checklist
- Classify the domain before enriching. Skip free, ISP, edu, gov and disposable.
- Enrich asynchronously unless a live decision depends on it.
- Store source, confidence, timestamp and looked-up domain with every enriched record.
- Fill blanks and update your own past values; never overwrite a person.
- Set a confidence threshold from your own test data and skip below it.
- Treat provider failures as "try later", never as "write nulls".
- Enrich per company, cache per domain, refresh every couple of months.
Once enrichment is reliable, the next step is acting on it: see routing inbound leads by company size and industry.
Questions people ask
- Can you enrich a lead from a Gmail address?
- Not at the company level. A free webmail domain tells you nothing about the employer, so domain-based enrichment should skip it. Ask for the company name on the form, or wait for a work email later in the lifecycle.
- Should lead enrichment run before or after the form submits?
- After, in most cases. Enrich asynchronously so a slow or failed lookup never blocks the signup, and only enrich synchronously if a form field or an instant routing decision depends on it.
- How often should enriched company data be refreshed?
- Company facts change slowly. Refreshing every 60 to 90 days, or when a lead re-engages after a long gap, is usually enough. Refreshing on every touch wastes lookups and makes fields flap.