Personalizing onboarding from the signup email domain
· 7 min read · Business email
You can personalize onboarding from the signup email domain by classifying the domain at signup and using the result to pick defaults: a company domain gets team setup and an industry template, a free webmail address gets a solo flow, and an academic domain gets the education plan offer. The domain only chooses where the user starts. Every branch needs a visible way to switch, because the address type is a hint about the person, not a fact.
This post covers the branching rules, a server-side implementation, UX copy for each branch, and the privacy lines we think you should not cross.
Map each category to an onboarding default
Start with a table you can show your product team. Each row is a default, not a gate.
| Domain category | Example | Onboarding default | Escape hatch |
|---|---|---|---|
company |
jane@acme-logistics.com |
Create a workspace named after the company, invite teammates, offer an industry template | "Just me for now" |
free |
jane@gmail.com |
Solo workspace, personal templates, skip the invite step | "I'm setting this up for a team" |
educational |
jane@cs.example.edu |
Offer the education plan, classroom or research templates | "Not for school work" |
government |
jane@agency.gov |
Team setup plus a link to your security and procurement docs | Same as company |
isp |
jane@comcast.net |
Same as free |
Same as free |
disposable |
jane@mailinator.com |
Should not reach onboarding; handle at signup | n/a |
The free and isp rows behave the same because both are personal mailboxes. A Comcast address is a consumer account, not a sign that the person works at Comcast. Disposable addresses belong in your signup screening, which how to tell a business email from a personal one covers in more detail.
Two cases trip people up. A founder on Gmail evaluating for a 40-person company lands in the solo flow, so the "team" link has to be easy to find. And a freelancer on janedoe.design lands in the company flow with a team of one, which is why the company branch should read the employee estimate before pushing invites.
Classify fast, enrich in the background
The category is cheap: pingutil returns it in milliseconds with skip_enrichment: true because no LLM is involved. Company details like industry and employee_size_estimate come from an LLM and can take a few seconds the first time a domain is seen. So split the work: decide the branch synchronously at signup, then fetch the company profile in the background and have it ready by the time the user reaches the template step.
// onboarding.ts (Node 18+, server side only: the API key must not reach the browser)
type Branch = 'team' | 'solo' | 'education' | 'blocked';
const API = 'https://pingutil.com/v1/lookup';
async function lookup(input: string, opts: Record<string, unknown> = {}) {
const res = await fetch(API, {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.PINGUTIL_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ input, ...opts }),
signal: AbortSignal.timeout(opts.skip_enrichment ? 2000 : 15000),
});
if (!res.ok) throw new Error(`lookup failed: ${res.status}`);
return res.json();
}
export async function chooseBranch(email: string): Promise<{ branch: Branch; domain: string | null }> {
try {
const data = await lookup(email, { skip_enrichment: true });
if (data.verdict.is_spam) return { branch: 'blocked', domain: data.domain };
switch (data.category) {
case 'company':
case 'government':
return { branch: 'team', domain: data.domain };
case 'educational':
return { branch: 'education', domain: data.domain };
default:
return { branch: 'solo', domain: data.domain };
}
} catch {
// The lookup failing must never block signup. Solo is the safest default.
return { branch: 'solo', domain: null };
}
}
// Call this without awaiting it in the signup request, or push it to a job queue.
export async function prefetchCompany(userId: string, domain: string) {
const data = await lookup(domain); // bare domain: company profile, no email_signals
const c = data.company;
if (!c) return; // enrichment failed or not a company; meta.enrichment_error explains
await saveInferred(userId, {
companyName: c.name,
industry: c.industry,
sizeEstimate: c.employee_size_estimate,
confidence: c.confidence ?? 0,
source: 'email_domain',
});
}
saveInferred is yours to write. Keep the inferred values in their own table or JSON column, separate from anything the user typed. You'll want that separation later, both for the correction flow and for answering "where did you get this?"
Looking up the bare domain in prefetchCompany is deliberate. The company profile is keyed on the registrable domain anyway (mail.acme.com and acme.com share one cached row), and you don't need to send the full address to get it.
Pick templates from industry, with a confidence floor
industry is free text produced by a model, so map it to your template list with a few keyword matches and a fallback, rather than expecting an exact enum.
const TEMPLATE_RULES: Array<[RegExp, string]> = [
[/software|saas|internet|developer/i, 'product-team'],
[/financ|bank|insur|fintech/i, 'finance-ops'],
[/health|medical|pharma|biotech/i, 'clinical-ops'],
[/retail|e-?commerce|consumer goods/i, 'store-ops'],
[/agency|marketing|advertis|media/i, 'client-projects'],
];
export function suggestTemplate(inferred: { industry: string | null; confidence: number }) {
if (!inferred.industry || inferred.confidence < 0.7) return null; // ask instead of guessing
const hit = TEMPLATE_RULES.find(([re]) => re.test(inferred.industry!));
return hit ? hit[1] : null;
}
export function shouldPushInvites(sizeEstimate: string | null) {
// employee_size_estimate is a range string such as "5001-10000"
const low = sizeEstimate ? parseInt(sizeEstimate, 10) : NaN;
return Number.isNaN(low) || low > 1; // unknown size: still offer invites
}
The 0.7 floor is a starting point, not a measured threshold. confidence is the model's own estimate, and the profile for a well-known company is usually solid while a ten-person agency may come back thin or wrong. We go into why in how accurate LLM-based company enrichment is. When the suggestion is null, show the template picker with no preselection. A blank choice costs the user one click. A wrong preselected template costs them a confusing first session.
UX copy for each branch
The rule we follow: only say things back to the user that they could reasonably expect you to know from what they just typed. Their email domain, and the company name that obviously goes with it, pass that test. Headcount, funding and HQ city do not.
Company branch, workspace step
Set up a workspace for Acme Logistics? Your teammates at acme-logistics.com can join once you invite them. [Create team workspace] [Just me for now]
Company branch, template step (confidence above your floor)
Start with the Finance ops template? You can switch templates or start blank at any time. [Use this template] [See all templates]
Note what's missing: no "we noticed Acme is a 500-person Series C fintech." The template suggestion is enough; the reasoning stays internal.
Solo branch
Your personal workspace is ready. Setting this up for a team? [Create a team workspace]
Education branch
Using this for teaching or research? You may qualify for the education plan. [Check eligibility] [Continue with the standard plan]
"May qualify" matters. An academic domain covers students, faculty, staff and, at some universities, alumni. If the plan is a large discount, verify properly; verifying student and academic email addresses walks through what the domain can and can't prove. The list of TLD patterns we treat as academic is on the educational domains page.
Let users correct what you inferred
Every inferred value needs a place where the user can see it and change it. In practice that's two things.
- Editable fields, prefilled. The workspace name field is prefilled with
companyNameand is a normal text input. If the user edits it, store their value as confirmed and stop overwriting it from enrichment. - A settings line that names the source. Something like "Workspace details were suggested from your email domain. [Edit]". Short and honest.
When a user switches branch ("Just me for now", "I'm setting this up for a team"), record it. Those switches are your best signal about where the defaults are wrong. If a meaningful share of Gmail signups click the team link, your solo default may be too strong for your audience.
Privacy lines we wouldn't cross
- Don't change price or plan visibility silently based on inferred company size. If the enterprise tier is shown to someone because enrichment says they're large, they'll notice when a colleague sees different pricing.
- Don't greet users with firmographics. "Welcome, Series C fintech" reads as surveillance even when the data is public.
- Keep inferred and confirmed data apart, and drop inferred values if the user asks for their data to be deleted, the same as everything else.
- Mention it in your privacy policy. A sentence saying you look up public information about the company associated with a signup domain is enough for most products. Get your own legal review; this is not legal advice.
- Don't send the full email when the domain is enough. For company profiles, the bare domain is all the lookup needs.
Ship it in this order
- Add
chooseBranchto signup with the solo fallback and a short timeout. - Add the escape-hatch links to each branch and log every switch.
- Add the background
prefetchCompanycall and store results as inferred. - Turn on template suggestions above a confidence floor, with the picker as fallback.
- After a few weeks, look at the switch logs and adjust the defaults.
If you're running a team plan, the next step is noticing when a second person from the same domain signs up; spotting multiple signups from the same company covers that. Field names and flags used above are documented in the API reference. The timeout uses AbortSignal.timeout(), available in Node 18 and current browsers.
Questions people ask
- Is it creepy to personalize onboarding from someone's email address?
- It can be. Using the company name from the domain the user just typed is expected. Showing inferred headcount, funding or revenue back to them is not. Use enrichment to pick defaults, show only what the user could have guessed you know, and always let them change it.
- What if someone signs up with Gmail but is evaluating for a company?
- Give the solo flow a visible way out, such as an 'I'm setting this up for a team' link. A free webmail domain tells you the address type, not the buyer, so treat it as a default rather than a decision.
- Can I use the email domain to verify that someone is a student?
- No. An educational domain tells you the address belongs to an academic institution, which also covers staff, faculty and sometimes alumni. Use it to offer an education plan, and require proper verification if the discount is large.