If you finished the Fundamentals level you have probably already built a signup gate that blocks an address when isValid is false or isDisposable is true. That gate is fine for a free tier. It is not enough for a sales form that routes leads to humans, a marketplace that pays sellers, or a newsletter whose sender reputation you care about. Those flows need to know why an address looks bad, because the right response differs: a typo deserves a hint, a role mailbox deserves a human, a domain that refuses mail deserves a hard stop.
This lesson takes the Email Validator response apart field by field, shows you real responses where the fields disagree with intuition, and ends with a decision function you can drop into any backend.
01What one call actually returns
Start with an address that exercises most of the response: a Gmail address with dots, mixed case and a +tag.
curl "https://api.apiverve.com/v1/emailvalidator?email=Jane.Doe%2Bnews%40gmail.com" \
-H "x-api-key: YOUR_API_KEY"Look at isSubaddressed and normalizedEmail: the address you will store and the address you dedupe on are different strings.
The response comes back lowercased (jane.doe+news@gmail.com) and carries roughly twenty fields. They fall into four groups, and it helps to think of them that way rather than as a flat list.
| Group | Fields | Question it answers |
|---|---|---|
| Shape | isRegexValid, hasTypo, username, domain | Is this string an email address at all? |
| Deliverability | isMxValid, mxRecord, isNullMx, canReceiveMail | Can mail for this domain be delivered anywhere? |
| Domain posture | hasSpf, hasDmarc, isFreeEmail, isCompanyEmail, isDisposable | What kind of domain is it, and is it run properly? |
| Mailbox | isRoleAccount, isSubaddressed, normalizedEmail | What kind of mailbox is this, and have we seen it before? |
isValid is the roll-up. In every response used for this lesson it matched isRegexValid && isMxValid: the shape is acceptable and the domain has MX records. It does not consider disposability, role accounts or anything in the mailbox group. Treat it as a deliverability summary, not a verdict.
Three fields are premium: suggestedCorrection, riskScore and riskLevel. They are useful when your plan includes them, and your code must keep working when they come back null. Every other field in the table is on every plan. The call costs 20 credits, which matters later when you decide what to call and in what order.
02Deliverability: MX, null MX and the A-record fallback
Mail for a domain is routed by its MX records. No MX records usually means mail cannot be delivered, but "usually" hides two special cases that the response separates for you.
Null MX. RFC 7505 lets a domain publish a single MX record pointing at . to declare "this domain accepts no mail, do not try." example.com does exactly that:
curl "https://api.apiverve.com/v1/emailvalidator?email=user%40example.com" \
-H "x-api-key: YOUR_API_KEY"isNullMx is true and canReceiveMail is false. This is the one deliverability answer that is a deliberate no, not a missing record.
A null MX is the strongest reject signal in the response. The domain owner has told the world nobody reads mail there. A person typing such an address is either testing your form or hiding.
The A-record fallback. RFC 5321 says that when a domain has no MX record at all, a sender may deliver to the domain's A record instead. isMxValid ignores that fallback; canReceiveMail follows it. The difference shows up on parked and misspelled domains. Validate jane@gmial.com and you get isMxValid: false, mxRecord: null, and canReceiveMail: true, because the typo domain has a web server but no mail exchanger. Technically a mail server could try delivery there. In practice that address is a typo, and the person who owns gmial.com is not your customer.
The rule that falls out: isMxValid: false is a reject or review signal even when canReceiveMail is true. Use canReceiveMail only to separate "can never work" (null MX, nothing resolvable) from "probably misconfigured".
Looking at the records directly
When you need the actual records, for example to show a support agent which provider hosts a customer's mail, MX Lookup returns them for a bare domain at 10 credits a call.
curl "https://api.apiverve.com/v1/mxlookup?domain=gmail.com" \
-H "x-api-key: YOUR_API_KEY"The records arrive in no particular order. The lowest priority number is the preferred server.
Two details catch people. First, the mx array is not sorted: for gmail.com the priority-5 record is last. Sort it yourself before you call the first entry "primary". Second, a domain with no usable MX records does not return an empty array. mxlookup on example.com returns HTTP 404 with the error no mx records found for domain. That is the "no result for your input" kind of 404, not an unknown API, so your client must treat it as an answer rather than a failure to retry.
const BASE = 'https://api.apiverve.com/v1';
export async function mxRecords(domain) {
const res = await fetch(`${BASE}/mxlookup?${new URLSearchParams({ domain })}`, {
headers: { 'x-api-key': process.env.APIVERVE_API_KEY },
signal: AbortSignal.timeout(3000),
});
const body = await res.json().catch(() => null);
if (res.status === 404) return []; // no MX records: an answer, not an outage
if (!res.ok || body?.status !== 'ok') throw new Error(body?.error || `HTTP ${res.status}`);
return [...body.data.mx].sort((a, b) => a.priority - b.priority);
}You rarely need both mxlookup and emailvalidator for the same address, because the validator already reports the preferred exchanger as mxRecord. Reach for mxlookup when you are auditing domains rather than addresses, such as checking a list of B2B customer domains after a migration.
An address returns isMxValid false, mxRecord null, isNullMx false and canReceiveMail true. What is the most accurate reading?
03Shape: what isRegexValid and hasTypo really mean
The format check is more permissive than you might expect. jane..doe@gmail.com has two consecutive dots in the local part, which the RFCs do not allow in an unquoted address, yet it comes back isRegexValid: true and isValid: true. Gmail happens to ignore dots, so this one would even be delivered. The point is that isRegexValid answers "does this look like an email" rather than "is this exactly RFC 5322". If you need strictness, add your own check; do not assume the API rejects every oddity.
At the other end, a string that is not an address at all, such as jane.doe@gmail with no top-level domain, never reaches the validator: the request fails with status: "error" and the message email: must be a valid email address. Your client must handle that as a definitive reject, not as an outage. Distinguish it from network and 5xx errors, which are retryable.
hasTypo is easy to misread from its name. It means the address is malformed. It does not mean "looks like a misspelled popular domain." jane@gmial.com returns hasTypo: false, because the string is perfectly well formed. The misspelling is caught elsewhere: on plans with the premium field, suggestedCorrection comes back as jane@gmail.com. That is the field to show as a "did you mean" hint.
The same call also returns isDisposable: true for gmial.com. Typo domains of the big providers are commonly registered to harvest mistyped mail, and disposable lists include many of them. If your gate shows a hint only when it is not blocking, you will block someone who simply fat-fingered Gmail. Order your checks so a present suggestedCorrection wins: show the hint first, and re-validate whatever they submit next.
04Domain posture: SPF, DMARC, free and disposable
hasSpf and hasDmarc say whether the domain publishes sender policy records. They describe a domain that sends mail properly, which is a decent proxy for "a real organisation runs this domain." Every major free provider has both. A brand-new domain registered for a burst of fake signups often has neither.
Use them as weak evidence only. Plenty of small businesses have a working MX record and no DMARC policy. Rejecting on missing DMARC would turn away the plumber who set up email in 2009 and never touched DNS again. A sensible use: when a B2B lead claims a company domain and that domain has MX but no SPF and no DMARC, route it to review rather than straight to a salesperson's calendar. Both fields can also be null when the lookup could not complete, so test for === false, not falsiness.
isFreeEmail and isCompanyEmail are for routing, not gating. A B2B product may score company addresses higher for sales follow-up. Rejecting free addresses outright cuts off freelancers, students and founders who have not set up a domain yet.
For disposability, Disposable Email Checker answers the one question for 6 credits instead of 20:
curl "https://api.apiverve.com/v1/emaildisposablechecker?email=test%40mailinator.com" \
-H "x-api-key: YOUR_API_KEY"Three fields: isDisposable, email, domain. Disposability is a property of the domain, so cache it by domain.
Because the answer depends only on the domain, cache it by domain for an hour or so. Calling the cheap checker first and the full validator only when the answer is false is the pattern from the Fundamentals signup project; it still holds here.
05The mailbox: role accounts, subaddressing and duplicates
isRoleAccount flags shared mailboxes such as support@, info@ and sales@. info@apiverve.com comes back true. Role accounts are real and often belong to small businesses, which is why they belong in review rather than reject. The check reads the local part as typed, though: sales+leads@outlook.com returned isRoleAccount: false. If role detection matters to your flow, strip the tag yourself before applying your own role list, or check the normalised address.
isSubaddressed and normalizedEmail exist for duplicates. One person can create unlimited "different" accounts with jane+1@, jane+2@, and, at Gmail, j.ane@ and ja.ne@. The normalised form removes the +tag everywhere and, for Gmail, the dots as well, folding googlemail.com into gmail.com. Jane.Doe+news@gmail.com normalises to janedoe@gmail.com; sales+leads@outlook.com normalises to sales@outlook.com with its dot rules untouched, because dot-folding is Gmail-specific.
The common bug is to overwrite the user's address with the normalised one. Do not. normalizedEmail is a dedupe key, not a deliverability claim, and some people use tags deliberately to filter your mail. Store both:
psql "$DATABASE_URL" -c "ALTER TABLE users ADD COLUMN email_normalized text;" -c "CREATE UNIQUE INDEX users_email_normalized_uq ON users (email_normalized);"Send mail to email; enforce uniqueness and look up trial abuse on email_normalized. That one index stops the most common free-tier abuse pattern without blocking anyone's legitimate address.
Two signups arrive: J.Smith+trial@gmail.com and jsmith@googlemail.com. What does a uniqueness check on normalizedEmail do, and what should you send the welcome email to?
06Turning signals into accept, review or reject
Now combine everything. The function below takes the data object and returns a decision plus reasons, because a decision without reasons is undebuggable the first time a customer complains. It treats premium fields as optional and null lookups as unknown.
export function decideEmail(d) {
const reasons = [];
if (d.suggestedCorrection) return { decision: 'review', reasons: ['typo'], hint: d.suggestedCorrection };
if (d.isNullMx) reasons.push('null_mx');
if (!d.isRegexValid) reasons.push('bad_format');
if (d.isDisposable) reasons.push('disposable');
if (!d.isMxValid) reasons.push('no_mx');
if (reasons.length) return { decision: 'reject', reasons };
if (d.isRoleAccount) reasons.push('role_account');
if (d.isCompanyEmail && d.hasSpf === false && d.hasDmarc === false) reasons.push('unconfigured_domain');
if (d.riskLevel === 'high') reasons.push('risk_high'); // premium, null on some plans
return { decision: reasons.length ? 'review' : 'accept', reasons, normalized: d.normalizedEmail };
}def decide_email(d: dict) -> dict:
if d.get("suggestedCorrection"):
return {"decision": "review", "reasons": ["typo"], "hint": d["suggestedCorrection"]}
reasons = []
if d.get("isNullMx"):
reasons.append("null_mx")
if not d.get("isRegexValid"):
reasons.append("bad_format")
if d.get("isDisposable"):
reasons.append("disposable")
if not d.get("isMxValid"):
reasons.append("no_mx")
if reasons:
return {"decision": "reject", "reasons": reasons}
if d.get("isRoleAccount"):
reasons.append("role_account")
if d.get("isCompanyEmail") and d.get("hasSpf") is False and d.get("hasDmarc") is False:
reasons.append("unconfigured_domain")
if d.get("riskLevel") == "high": # premium, None on some plans
reasons.append("risk_high")
return {"decision": "review" if reasons else "accept", "reasons": reasons,
"normalized": d.get("normalizedEmail")}Run the responses from this lesson through it and you get: Jane.Doe+news@gmail.com accept, normalised to janedoe@gmail.com; user@example.com reject for null_mx and no_mx; support@myspace.com review for role_account; jane@gmial.com review with a hint on premium plans, and reject for disposable and no_mx on plans without suggestedCorrection.
That last case is a real tradeoff. Without the premium hint, the typo is indistinguishable from a junk domain, and the person is rejected. If typo signups matter to you, either add your own short list of common misspellings of the top providers, or make "reject" show an inline message ("we can't deliver to gmial.com, check the spelling") rather than a dead end. A rejection message that names the domain fixes most typos for free.
Your plan does not include premium fields. A visitor types jane@gmial.com. With the decide function above, what happens and what is the cheapest improvement?
07Next
Email is one channel. The next lesson does the same for phone numbers, where the stakes are higher because every SMS you send costs money and some numbers exist only to receive verification codes. Continue to Validate phone numbers before you send an SMS. If you need to re-screen an existing user table, the same endpoint accepts up to 200 addresses per call through batch requests.