Validating a B2B signup form: VAT number, work email and phone
A B2B signup form usually asks for three things a consumer form does not: a VAT number, a work email, and a phone number. Each one can be checked. The hard part is not the checking — it is deciding what a failed check should do.
The mistake is treating every check as a gate. Each field fails in ways that say nothing about
whether the person is a legitimate customer: a registry that is down, a company whose only mailbox
is contact@, a business that runs on VoIP. Block on those and you lose real signups to your own
validation.
This page gives one rule per field, then the code.
The rule across all three: reject what is impossible, flag what is unusual, and accept what you merely could not confirm. An impossible value — a VAT number with the wrong structure, a domain that receives no mail — will never become valid. Everything else is information for you to use, not a verdict to show the user.
The decision table
| Field | Reject | Ask the user to confirm | Accept, and record |
|---|---|---|---|
| VAT number | Wrong structure for its country · Registry says not registered | — | Registry unreachable (registered: null) · Answer served from cache |
| Work email | valid: false — malformed, no mail records, blocked domain · Disposable, on a trial | Likely typo (gmail.con) | contact@ / info@ · Consumer mailbox, if you allow freelancers |
| Phone | Not a number in its country’s plan · Premium-rate | Missing country code | VoIP · Landline · Country different from the VAT number |
The columns are deliberately uneven. Most signals belong on the right.
VAT number
A VAT number answers two separate questions, and only one of them is always answerable.
Is it shaped like a real VAT number? Decided locally, from each member state’s rules. If not, the number will never be valid — reject it, with a message that says so.
Is it registered? Only VIES, the European Commission’s registry, can say — and VIES is a federation of 27 national systems with no SLA. We measured it: some member states were unreachable for more than a day in total over nine days (the data).
When VIES cannot be reached, VerifNow returns registered: null, not false. Accept the signup.
A number whose registration you could not check is not an unregistered number, and rejecting it turns
a registry outage into a lost customer.
const vat = vatResult.vatDetails;
if (!vat.formatValid) return fieldError('vat', 'This VAT number is not correctly formed.');
if (vat.registered === false) return fieldError('vat', 'This VAT number is not registered in VIES.');
// registered === null: VIES was unreachable. Accept, and check again later.
account.vatVerified = vat.registered === true;
account.vatSource = vat.source; // LIVE, CACHE, STALE or UNVERIFIEDIf the customer will be invoiced under reverse charge, keep the evidence of the check: source,
checkedAt, and — when your own account has a VAT number configured — consultationNumber, the
receipt VIES issues to an identified requester. See Validate VAT.
Work email
Reject what valid: false rejects
valid: false rests on hard facts only: empty or malformed input, a domain with neither MX nor A
records, or a blocked domain. That is worth rejecting, with the message the API returns — and the
verdict is the same on every plan.
Everything else is a signal. deliverability: UNDELIVERABLE in particular does not mean
valid: false: a likely typo at a real domain can receive mail, just not the mail your user meant
to receive. Decide on those yourself, as below. See Validate Email.
Ask about typos — even when valid is true
typo_detected is set when the domain looks like a misspelling of a common one. A typo domain can
still have mail records, so do not wait for valid: false: show the suggestion.
const { typoDetected, suggestedDomain } = emailResult.emailDetails.signals;
if (typoDetected) {
const [local] = email.split('@');
return fieldHint('email', `Did you mean ${local}@${suggestedDomain}?`);
}Accept contact@ and info@
Role addresses are routine in B2B. For a small company, contact@ is often the only mailbox it has.
VerifNow reports them in role_based and they raise the risk score, but on their own they do not
make an address invalid. Blocking them should be a deliberate decision about your market, not a
default.
Decide on consumer mailboxes — don’t let a library decide
Whether to accept @gmail.com on a B2B form is a business question. Freelancers and very small
companies use consumer mailboxes; enterprise-only products may not want them.
free_provider answers it, but it is only returned on the GROWTH and PRO plans. On FREE and
STARTER the field is absent, not false — so check it exists before branching on it:
const { freeProvider } = emailResult.emailDetails.signals;
if (freeProvider === undefined) {
// Not computed at this plan's depth. Don't treat this as "not a free provider".
} else if (freeProvider) {
account.flags.push('consumer-mailbox');
}Disposable addresses are the one signal worth considering a hard block on a B2B trial, since a
throwaway inbox is rarely a buyer. disposable is returned on every plan.
Phone
VerifNow validates a number against its country’s numbering plan: the right length, and a prefix
that is actually allocated. It needs the country code — +33 6 12 34 56 78, or 0033… — because
without one the same digits are valid in one country and nonsense in another.
valid: false: reject, and showmessage. It says what is wrong (“too short for its country”, “must include its country code”).line_type: "PREMIUM_RATE": reject. A business contact number is not a premium line.VOIPandFIXED_LINE: accept. Plenty of companies run on VoIP.
A phone country that differs from the VAT country is normal — subsidiaries, remote teams, founders abroad. Record it; do not reject it.
Valid means the number exists in the plan, not that it rings. If you need to reach the person, send a code. See Validate Phone.
Putting it together
One API key covers the three fields. There is no batch endpoint: send three requests in parallel.
import { VerifNow, VerifNowRateLimitError } from '@verifnow/sdk';
const client = new VerifNow({ apiKey: process.env.VERIFNOW_API_KEY! });
export async function checkSignup(form: { vat: string; email: string; phone: string }) {
const [vat, email, phone] = await Promise.allSettled([
client.validateVat(form.vat),
client.validateEmail(form.email),
client.validatePhone(form.phone),
]);
const errors: Record<string, string> = {};
const flags: string[] = [];
// VAT
if (vat.status === 'fulfilled') {
const d = vat.value.vatDetails!;
if (!d.formatValid) errors.vat = 'This VAT number is not correctly formed.';
else if (d.registered === false) errors.vat = 'This VAT number is not registered in VIES.';
else if (d.registered === null) flags.push('vat-unverified');
} else {
flags.push('vat-unchecked');
}
// Email
if (email.status === 'fulfilled') {
const { valid, message, emailDetails } = email.value;
const s = emailDetails?.signals;
if (s?.typoDetected) errors.email = `Did you mean ${form.email.split('@')[0]}@${s.suggestedDomain}?`;
else if (!valid) errors.email = message ?? 'This email address cannot receive mail.';
else if (s?.disposable) errors.email = 'Please use your work email address.';
if (s?.freeProvider) flags.push('consumer-mailbox');
} else {
flags.push('email-unchecked');
}
// Phone
if (phone.status === 'fulfilled') {
const { valid, message, phoneDetails } = phone.value;
if (!valid) errors.phone = message ?? 'This phone number is not valid.';
else if (phoneDetails?.lineType === 'PREMIUM_RATE') errors.phone = 'Please enter a standard-rate number.';
} else {
flags.push('phone-unchecked');
}
// A 429 that survived the SDK's retries — usually a spent quota — is not a reason to accept
// silently. Surface it to whoever operates the form.
for (const r of [vat, email, phone]) {
if (r.status === 'rejected' && r.reason instanceof VerifNowRateLimitError) throw r.reason;
}
return { ok: Object.keys(errors).length === 0, errors, flags };
}Three choices in there are worth making consciously:
Promise.allSettled, notPromise.all. One failed call should not discard the other two answers.- A network failure accepts the field and flags it (
vat-unchecked). The SDK throws instead of returningvalid: trueon failure precisely so that this is your decision. The opposite choice — blocking signups while the validation service is unreachable — is defensible too; make it on purpose. - A spent quota is re-thrown. Accepting every signup unverified until the end of the month is the kind of failure nobody notices.
The code needs @verifnow/sdk 1.2.0 or later, which models phoneDetails and consultationNumber. The SDK
maps the API’s snake_case fields to camelCase.
What it costs, and what it needs
Each field is one validation, so one signup is three validations:
| Plan | Validations per month | Signups per month | Requests in flight | Signups checked simultaneously |
|---|---|---|---|---|
| FREE | 250 | ~83 | 3 | 1 |
| STARTER | 10,000 | ~3,300 | 5 | 1 |
| GROWTH | 50,000 | ~16,700 | 10 | 3 |
| PRO | 150,000 | 50,000 | 25 | 8 |
The concurrency limit counts requests in flight, not signups per second. The three calls of a signup
usually finish well under a second; a VAT check waiting on a slow VIES can take up to four. A request
over the limit gets a 429, which the SDK retries with a short backoff. See
Rate Limits.
Signals by plan: FREE and STARTER run the same checks — they differ in volume only. GROWTH adds
free_provider, risk_level and domain age for email. VAT and phone checks are identical on every
plan.
What not to validate
- Don’t require a mobile number unless you actually send SMS. Landlines and VoIP are how many businesses answer.
- Don’t cross-check countries as a gate. A Belgian company with a French phone number and an Irish VAT registration is unusual, not fraudulent.
- Don’t validate on every keystroke. Each call counts against your quota. Validate when the field loses focus, and reuse that answer on submit unless the value changed.