In 2024, automated traffic passed human traffic on the web for the first time, hitting 51 percent of all traffic, according to Imperva. That bleeds straight into your signup form. Stripe, looking at its own network, found that 7.4 percent of signups at AI companies show multi-account abuse, and that self-serve products with direct API access see roughly ten times more attempted abuse than enterprise ones. People reuse throwaway addresses to farm free trials: one in five admit to it, rising to 29 percent of Gen Z.
For a B2B product, most of this is answerable at the moment of registration with one question. Is this a real person at a real company? A consumer fraud stack answers a different question with device fingerprints and IP reputation. The B2B version is an identity-legitimacy check: does this email map to an actual professional, and does that professional's company match what they typed in the form. A Person API answers it directly.
This post builds that check. A synchronous verifier you call during signup that returns accept, review, or reject, with the reasoning attached. It is not KYC and does not replace it. It is the lightweight gate that keeps fake and abusive signups out of your funnel before they cost you compute, pollute your activation metrics, and fill your sales queue with ghosts. Code is in Python and JavaScript.
Why the email decides most of it
The single most informative thing on a B2B signup form is the email domain. A work email on a custom domain resolves to a person and a company far more reliably than a personal address on a free provider, as the vendors who do this enrichment note. And a meaningful share of what gets typed is simply not real: email-validation providers report that only around 62 percent of addresses submitted through web forms are valid and deliverable.
So the verifier works the email hardest. It normalizes it, classifies it, then asks whether it belongs to a real person at the company the signup claims.
Normalize and classify the email
Start with the free utility endpoints, which are rate-limited but cost nothing. Cleaning catches the plus-addressing and casing tricks people use to spin up trial accounts from one inbox.
import os
import requests
API = "https://api.datalegion.ai"
HEADERS = {"API-Key": os.environ["DATALEGION_API_KEY"]}
FREE_PROVIDERS = {"gmail.com", "outlook.com", "yahoo.com", "icloud.com", "proton.me"}
def normalize_email(email: str) -> str:
resp = requests.post(
f"{API}/utility/clean",
headers=HEADERS,
json={"fields": {"email": email}},
timeout=5,
)
if resp.ok:
return resp.json()["results"]["email"]["cleaned"]
return email.strip().lower()
def email_domain(email: str) -> str:
return email.rsplit("@", 1)[-1].lower()
def is_work_email(email: str) -> bool:
return email_domain(email) not in FREE_PROVIDERSconst API = "https://api.datalegion.ai";
const HEADERS = {
"API-Key": process.env.DATALEGION_API_KEY,
"Content-Type": "application/json",
};
const FREE_PROVIDERS = new Set([
"gmail.com", "outlook.com", "yahoo.com", "icloud.com", "proton.me",
]);
async function normalizeEmail(email) {
const resp = await fetch(`${API}/utility/clean`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({ fields: { email } }),
signal: AbortSignal.timeout(5000),
});
if (resp.ok) {
const data = await resp.json();
return data.results.email.cleaned;
}
return email.trim().toLowerCase();
}
function emailDomain(email) {
return email.split("@").pop().toLowerCase();
}
function isWorkEmail(email) {
return !FREE_PROVIDERS.has(emailDomain(email));
}A free-provider address is not a rejection. Plenty of legitimate small-team signups use Gmail. It just means the email alone can't carry the verification, so the score will lean harder on the other signals.
Resolve the person behind it
Send the email, plus the name and company the user typed, to person enrich. Passing all three lets the match metadata tell you which inputs actually agreed, which is more useful than a yes or no.
def resolve_person(email: str, name: str, company: str) -> dict | None:
resp = requests.post(
f"{API}/person/enrich",
headers=HEADERS,
json={"email": email, "full_name": name, "company": company},
timeout=5,
)
if not resp.ok:
return None
matches = resp.json().get("matches", [])
return matches[0] if matches else Noneasync function resolvePerson(email, name, company) {
const resp = await fetch(`${API}/person/enrich`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({ email, full_name: name, company }),
signal: AbortSignal.timeout(5000),
});
if (!resp.ok) return null;
const { matches = [] } = await resp.json();
return matches.length ? matches[0] : null;
}A match comes back with the resolved person and a match_metadata block: match_confidence, match_type, and matched_on, the list of fields that contributed to the match. That last field is the cross-check. If the email matched but the name did not, matched_on says so.
{
"person": {
"full_name": "Marcus Delaney",
"job_title": "VP of Engineering",
"company_name": "Larkspur Software",
"company_domain": "larkspur.example",
"emails": [
{
"address": "marcus@larkspur.example",
"type": "professional",
"current": true,
"confidence": "high",
"validation_status": "valid"
}
]
},
"match_metadata": {
"matched_on": ["email", "company"],
"match_type": "exact",
"match_confidence": "high"
}
}Cross-check the claims
A match is necessary but not sufficient. The signup also has to be internally consistent. Two checks catch most of the obvious fakes. Does the email domain match the resolved company's domain, and does the deliverability flag come back clean. Someone claiming to be a VP at a large company with a Gmail address that resolves to nobody is the textbook pattern, and it falls out of these comparisons.
def cross_checks(record: dict, claimed_email: str) -> dict:
person = record["person"]
checks = {}
# Email domain should line up with the resolved company domain.
company_domain = (person.get("company_domain") or "").lower()
checks["domain_matches_company"] = (
company_domain != "" and email_domain(claimed_email) == company_domain
)
# Deliverability, straight from the matched email object.
current = next(
(e for e in person.get("emails", [])
if e.get("current") and e.get("type") == "professional"),
None,
)
checks["email_deliverable"] = bool(current) and current.get("validation_status") == "valid"
return checksfunction crossChecks(record, claimedEmail) {
const person = record.person;
const checks = {};
// Email domain should line up with the resolved company domain.
const companyDomain = (person.company_domain || "").toLowerCase();
checks.domainMatchesCompany =
companyDomain !== "" && emailDomain(claimedEmail) === companyDomain;
// Deliverability, straight from the matched email object.
const current = (person.emails || []).find(
(e) => e.current && e.type === "professional",
);
checks.emailDeliverable = Boolean(current) && current.validation_status === "valid";
return checks;
}Score it and route the result
Now turn the signals into a decision. The goal is three buckets, not a binary. Accept the clean ones automatically, send the ambiguous ones to manual review or a step-up challenge, and reject or hard-gate the ones with nothing behind them. Stripe's own guidance on abuse is that no single signal proves anything; you accumulate risk indicators and add friction only when they cross a threshold.
def verify_signup(email: str, name: str, company: str) -> dict:
email = normalize_email(email)
record = resolve_person(email, name, company)
if record is None:
return {"decision": "reject", "score": 0, "reasons": ["no person match"]}
confidence = record["match_metadata"]["match_confidence"]
matched_on = record["match_metadata"].get("matched_on", [])
checks = cross_checks(record, email)
score = 0
reasons = []
score += {"high": 50, "moderate": 30, "low": 10}.get(confidence, 0)
reasons.append(f"match_confidence={confidence}")
if "email" in matched_on:
score += 20
reasons.append("email matched")
if checks["domain_matches_company"]:
score += 20
reasons.append("email domain matches company")
if checks["email_deliverable"]:
score += 10
reasons.append("email deliverable")
if not is_work_email(email):
score -= 15
reasons.append("free-provider email")
if score >= 70:
decision = "accept"
elif score >= 40:
decision = "review"
else:
decision = "reject"
return {"decision": decision, "score": score, "reasons": reasons}async function verifySignup(emailInput, name, company) {
const email = await normalizeEmail(emailInput);
const record = await resolvePerson(email, name, company);
if (record === null) {
return { decision: "reject", score: 0, reasons: ["no person match"] };
}
const confidence = record.match_metadata.match_confidence;
const matchedOn = record.match_metadata.matched_on || [];
const checks = crossChecks(record, email);
let score = 0;
const reasons = [];
score += { high: 50, moderate: 30, low: 10 }[confidence] || 0;
reasons.push(`match_confidence=${confidence}`);
if (matchedOn.includes("email")) {
score += 20;
reasons.push("email matched");
}
if (checks.domainMatchesCompany) {
score += 20;
reasons.push("email domain matches company");
}
if (checks.emailDeliverable) {
score += 10;
reasons.push("email deliverable");
}
if (!isWorkEmail(email)) {
score -= 15;
reasons.push("free-provider email");
}
let decision;
if (score >= 70) decision = "accept";
else if (score >= 40) decision = "review";
else decision = "reject";
return { decision, score, reasons };
}The thresholds are yours to calibrate. A product with cheap downside (a content tool) can set them low and let more through. A product giving every signup expensive compute or a real free-tier allowance, which is exactly where the trial-abuse pressure concentrates, should set them high and route more to review.
Wiring it into the signup flow
This belongs inline, synchronous, before the account is created. The budget is tight, sub-second, because it runs while someone waits on a form. That is well within reach: the utility calls are fast and the enrich call returns quickly, so the whole verifier fits inside a normal request.
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Signup(BaseModel):
email: str
name: str
company: str
@app.post("/signup")
def signup(body: Signup):
result = verify_signup(body.email, body.name, body.company)
if result["decision"] == "accept":
return create_account(body, verified=True)
if result["decision"] == "review":
return create_account(body, verified=False, flagged=True)
return {"status": "needs_verification", "detail": result["reasons"]}import express from "express";
const app = express();
app.use(express.json());
app.post("/signup", async (req, res) => {
const { email, name, company } = req.body;
const result = await verifySignup(email, name, company);
if (result.decision === "accept") {
return res.json(createAccount(req.body, { verified: true }));
}
if (result.decision === "review") {
return res.json(createAccount(req.body, { verified: false, flagged: true }));
}
return res.json({ status: "needs_verification", detail: result.reasons });
});The reject branch should rarely be a hard wall. A genuine user behind a false negative is worse than a fake user behind a step-up. Send rejects to email verification or a manual path instead of turning them away.
One compliance note
This is a B2B legitimacy check on business identity, but the data is still personal information. California's temporary B2B exemption under the CCPA expired on January 1, 2023, so a work email, title, and employer of a California resident are covered. Use it the way this verifier does: to make a yes-or-no decision at signup, not to silently assemble a profile you keep. Rely on commercially licensed and publicly available data, keep the footprint minimal, and you stay on the right side of it. This is a design note, not legal advice.
The verifier sits next to, not instead of, device and IP fraud tooling. Those answer "is this a bot or a stolen card." This answers "is this a real professional at the company they named," which is the question that matters most for B2B. For the lookup mechanics underneath it, see Reverse Email Lookup, and for enriching the accounts that pass, Building a Real-Time Enrichment Pipeline. The utility and enrich field references are at www.datalegion.ai/docs/person-data/schema. Put the check in front of account creation and let the fakes fail before they cost you anything.
Build it with a coding agent
To scaffold the verifier in one pass, paste this into Claude Code, Codex, or Cursor:
Build a signup identity verifier that returns accept / review / reject from a
work email.
Data Legion endpoints (send API-Key: $DATALEGION_API_KEY on each):
- POST https://api.datalegion.ai/utility/clean with
{"fields": {"email": "<address>"}} to normalize and classify the email.
It is free and does not spend credits; read results.email.cleaned.
- POST https://api.datalegion.ai/person/enrich with {"email": "<address>"}
to resolve the person; read matches[0].person.
References for context (read these first):
- The walkthrough this is based on:
https://www.datalegion.ai/blog/post/building-a-signup-identity-verifier
- Utility Clean API: https://www.datalegion.ai/docs/api-reference/utility-clean
- Person Enrich API: https://www.datalegion.ai/docs/api-reference/person-enrichment
- Person field schema: https://www.datalegion.ai/docs/person-data/schema
Requirements:
- Normalize the email, then resolve the person behind it.
- Cross-check the signup form claims (name, company, title) against the
resolved profile; treat an email whose validation_status is "valid" as a
positive signal.
- Score the result and route it: accept, send to manual review, or reject.
- Expose it as one endpoint (FastAPI or Express) that takes the signup payload
and returns the decision plus the reasons.
Before you start, ask me which language and framework to use. Sketch a short
plan, then build it with tests and a short README. When you finish, check the
result against each requirement above and tell me what is covered.