Validating EU B2B billing details: VAT number and IBAN
Invoicing a business in the EU comes down to two fields a consumer checkout never asks for.
A VAT number decides whether the invoice is legal: with a valid one you apply the reverse charge and bill without VAT, and if it turns out to be wrong, the tax authority bills you for the VAT you did not collect. An IBAN decides whether you get paid: a wrong one means a failed direct debit, a returned-payment fee, and a customer who believes they paid.
Both are usually validated with a regex and a checksum. Both deserve better, for opposite reasons: one of them cannot be answered locally at all, and the other is answered locally but not by the check most people run.
The two failure modes
| VAT number | IBAN | |
|---|---|---|
| Decided locally | Structure, per member state | Structure and check digits |
| Needs a third party | Registration — only VIES knows | Nothing |
| Can be unknown | Yes — VIES goes down per country | No |
| What a correct answer still doesn’t prove | That the business is the one in front of you | That the account exists, is open, or belongs to your customer |
The asymmetry is the whole point. A VAT check can come back unknown and you must handle it. An IBAN check never comes back unknown — so if your validator says yes, you have to be sure it asked the right question.
The IBAN question most validators skip
Nearly every IBAN validator runs the ISO 7064 MOD-97 check digits. They are good at what they do: they catch a mistyped digit, two transposed characters, a missing character — the mistakes people actually make.
They do not catch an IBAN that could never exist.
{ "value": "FR23111111111111111111111" }That string has correct check digits and is 25 characters long. Every French IBAN is 27. A checksum-only validator accepts it; the bank rejects the mandate weeks later, when the customer is already onboarded and the first payment is due.
VerifNow answers both questions separately:
{
"valid": false,
"message": "A FR IBAN is 27 characters long",
"originalValue": "FR23111111111111111111111",
"ibanDetails": {
"country_code": "FR",
"structure_valid": false,
"checksum_valid": true,
"length": 25,
"expected_length": 27
}
}checksum_valid: true with structure_valid: false is exactly the case a single boolean hides. The
structure comes from the SWIFT registry — 89 countries, each with its own length and character
layout, because a German BBAN is 18 digits while a Maltese one is 4 letters, 5 digits and 18
alphanumerics. A country outside the registry is rejected on sight: ZZ77… returns “ZZ is not an
IBAN country”.
No IBAN check tells you the account exists. Not ours, and not anyone’s from the number alone — that needs a bank network: SEPA name-checking, or a penny-drop transfer. What structural validation buys you is the typo caught in the form instead of in the payment run.
The VAT question that has no local answer
A VAT number’s structure is decided locally, always: IE6388047V, NL123456789B01, ESX1234567X —
several member states allow letters inside the number, and a validator that assumes digits produces
false negatives on real registrations.
Registration is different. Only VIES can confirm it, and VIES is a federation of 27 national registries with no published SLA. We measured it over nine days: some member states were unreachable for more than a day in total (the data).
So registered is nullable, and null means unknown:
{
"valid": true,
"vatDetails": {
"format_valid": true,
"registered": null,
"country_code": "FR",
"source": "UNVERIFIED",
"vies_available": false
}
}Never collapse null into false. “The registry did not answer” and “this business is not
registered” are different facts, and treating them the same rejects legitimate customers during
someone else’s outage — while the customer is on your signup page with a card in hand.
For the reverse charge, what you generally need is evidence that you checked at the time, not a
guarantee the registry was up. Store source and checked_at with the invoice — and
consultation_number when your account has its own VAT number configured, which is the receipt VIES
issues to an identified requester.
Both fields, one key
Two calls, one API key, one quota. There is no batch endpoint: send them together.
import { VerifNow, VerifNowRateLimitError } from '@verifnow/sdk';
const client = new VerifNow({ apiKey: process.env.VERIFNOW_API_KEY! });
type BillingInput = { vat: string; iban: string };
export async function checkBillingDetails({ vat, iban }: BillingInput) {
const [vatResult, ibanResult] = await Promise.allSettled([
client.validateVat(vat),
client.validateIban(iban),
]);
const errors: Record<string, string> = {};
const record: Record<string, unknown> = {};
if (vatResult.status === 'fulfilled') {
const d = vatResult.value.vatDetails!;
if (!d.formatValid) {
errors.vat = 'That VAT number is not correctly formed.';
} else if (d.registered === false) {
errors.vat = 'That VAT number is not registered in VIES.';
} else {
// true or null. null is unknown: accept, record what you know, re-check later.
record.vatVerified = d.registered === true;
record.vatSource = d.source;
record.vatCheckedAt = d.checkedAt;
record.vatReceipt = d.consultationNumber;
if (d.registered === null) await queueVatRecheck(vat);
}
} else {
record.vatVerified = false; // the call failed; do not claim you checked
}
if (ibanResult.status === 'fulfilled') {
const { valid, message, normalizedValue, ibanDetails } = ibanResult.value;
if (!valid) {
errors.iban = message!; // "A FR IBAN is 27 characters long", not "invalid IBAN"
} else {
record.iban = normalizedValue;
record.ibanCountry = ibanDetails!.countryCode;
}
} else {
errors.iban = 'We could not check that IBAN just now. Please try again.';
}
for (const r of [vatResult, ibanResult]) {
if (r.status === 'rejected' && r.reason instanceof VerifNowRateLimitError) throw r.reason;
}
return { ok: Object.keys(errors).length === 0, errors, record };
}Three decisions in there are deliberate, and yours to disagree with:
- A failed VAT call accepts the customer and records
vatVerified: false. Blocking signups while a validation service is unreachable is defensible too — but then say so on the form, and make it a choice rather than an accident. The SDK throws instead of returningvalid: trueprecisely so the choice is yours. - A failed IBAN call blocks. Unlike VAT, an IBAN answer is entirely local and takes milliseconds: if the call failed, something is wrong on your side, and taking bank details you did not check is how money goes to nowhere.
- A spent quota is re-thrown. Silently accepting everything unverified until the end of the month is the kind of failure nobody notices.
Needs @verifnow/sdk 1.3.0 or later, which models ibanDetails. For Java,
io.verifnow:verifnow-spring 2.3.0 or later exposes the same fields through getIbanDetails().
What to store, and what not to
The VAT side you want to keep: source, checked_at, consultation_number. That is the evidence a
tax authority accepts, and it is the reason to store anything at all.
The IBAN side you want to keep less of. It is personal data and it is a payment credential:
- store
normalizedValue, never the raw string with the user’s spacing; - show
formattedback, so the customer recognises their own account; - keep it out of logs, error trackers and analytics events — a validation request that fails should not put an IBAN into a third-party dashboard;
- if you only need to recognise a returning account, store a hash rather than the number.
What we keep, on our side: the usage log that meters your calls stores a masked form of the
value — an IBAN appears as FR***06, a national identifier as *** — which is enough for you to
recognise one of your own calls and no more. VAT consultations are the exception, and deliberately
so: the full number, the source and the consultation number are the receipt, and that is the
product.
What it costs
Two fields, two validations. Both are the same on every plan — validation depth only changes email signals, never VAT or IBAN.
| Plan | Validations per month | Customers billed | Requests in flight |
|---|---|---|---|
| FREE | 250 | 125 | 3 |
| STARTER | 10,000 | 5,000 | 5 |
| GROWTH | 50,000 | 25,000 | 10 |
| PRO | 150,000 | 75,000 | 25 |
A billing form is not a signup form: it is filled once per customer, not once per visitor, so the quota goes much further than the numbers above suggest. What you re-check periodically is the VAT registration — an annual or quarterly sweep of your customer base, which is a batch job you can schedule for a quiet hour rather than a per-request cost. See Rate Limits.
In short
- Reject a VAT number the registry says is not registered. Accept one it could not answer for, and record that you could not.
- Reject an IBAN whose structure is impossible for its country, even when the check digits pass — that is the one a checksum-only validator lets through.
- Keep the VAT evidence. Keep as little of the IBAN as your product allows.
Next: Validate VAT · Validate IBAN · Validating a B2B signup form