To validate VAT numbers in code, run three checks in order: the format (instant, works in the browser), the check digit (offline, catches most typos) and the registration (a live query to VIES or the UK register, the only check that says whether the number is active). This tutorial builds all three in JavaScript, Python and PHP. Every snippet was run: the regexes and check digits against real VAT numbers and python-stdnum, the API calls against a local server that returns the documentation’s example answers, with Node.js 26, Python 3.11 and PHP 8.5.
| Check | Catches | Runs | Cost |
|---|---|---|---|
| Format | Wrong prefix, length or characters | Browser or server | Free, instant |
| Check digit | Most typos | Browser or server | Free, instant |
| Registration | Numbers that are not (or no longer) registered; returns name and address | Server | One API request |
1. Normalize the input
People type VAT numbers with spaces, dots and dashes, in lower case, and Greek numbers with GR instead of the official EL. Normalize before you check anything:
- remove spaces, dots and dashes,
- convert to upper case,
- replace a leading
GRwithEL.
de 136.695-976 becomes DE136695976. vatcheckapi reads input the same way, so you can send the normalized number or the raw input.
2. Check the format with a regex
Each country has its own structure. These patterns cover all 29 prefixes that matter for EU VAT: the 27 member states, Northern Ireland (XI) and the UK (GB). The VAT number format page explains each one.
JavaScript
// VAT number formats of the 27 EU member states, Northern Ireland (XI) and the UK (GB).
export const VAT_FORMATS = {
AT: /^ATU\d{8}$/,
BE: /^BE[01]\d{9}$/,
BG: /^BG\d{9,10}$/,
CY: /^CY\d{8}[A-Z]$/,
CZ: /^CZ\d{8,10}$/,
DE: /^DE\d{9}$/,
DK: /^DK\d{8}$/,
EE: /^EE\d{9}$/,
EL: /^EL\d{9}$/,
ES: /^ES[A-Z0-9]\d{7}[A-Z0-9]$/,
FI: /^FI\d{8}$/,
FR: /^FR[0-9A-HJ-NP-Z]{2}\d{9}$/,
HR: /^HR\d{11}$/,
HU: /^HU\d{8}$/,
IE: /^IE(\d{7}[A-W][A-IW]?|\d[A-Z+*]\d{5}[A-W])$/,
IT: /^IT\d{11}$/,
LT: /^LT(\d{9}|\d{12})$/,
LU: /^LU\d{8}$/,
LV: /^LV\d{11}$/,
MT: /^MT\d{8}$/,
NL: /^NL\d{9}B\d{2}$/,
PL: /^PL\d{10}$/,
PT: /^PT\d{9}$/,
RO: /^RO\d{2,10}$/,
SE: /^SE\d{10}01$/,
SI: /^SI\d{8}$/,
SK: /^SK\d{10}$/,
GB: /^GB(\d{9}|\d{12}|GD\d{3}|HA\d{3})$/,
XI: /^XI(\d{9}|\d{12}|GD\d{3}|HA\d{3})$/,
};
// "de 136.695-976" -> "DE136695976"; Greece's GR prefix -> EL.
export function normalizeVat(input) {
const vat = String(input).toUpperCase().replace(/[\s.\-]/g, '');
return vat.startsWith('GR') ? `EL${vat.slice(2)}` : vat;
}
export function hasValidFormat(input) {
const vat = normalizeVat(input);
const pattern = VAT_FORMATS[vat.slice(0, 2)];
return pattern !== undefined && pattern.test(vat);
}
hasValidFormat('de 136.695-976'), hasValidFormat('gr094259216') and hasValidFormat('GBGD001') return true; DE13669597 (one digit short), AT13585627 (no U) and NL004495445B1 return false. In our test it accepted all 40 numbers of our test set that python-stdnum accepts, among them an example number for each of the 29 prefixes.
PHP
The same map in PHP. The D modifier matters: without it, $ also matches before a final line break, and preg_match('/^DE\d{9}$/', "DE136695976\n") returns 1.
<?php
declare(strict_types=1);
// VAT number formats of the 27 EU member states, Northern Ireland (XI) and the UK (GB).
const VAT_FORMATS = [
'AT' => '/^ATU\d{8}$/D',
'BE' => '/^BE[01]\d{9}$/D',
'BG' => '/^BG\d{9,10}$/D',
'CY' => '/^CY\d{8}[A-Z]$/D',
'CZ' => '/^CZ\d{8,10}$/D',
'DE' => '/^DE\d{9}$/D',
'DK' => '/^DK\d{8}$/D',
'EE' => '/^EE\d{9}$/D',
'EL' => '/^EL\d{9}$/D',
'ES' => '/^ES[A-Z0-9]\d{7}[A-Z0-9]$/D',
'FI' => '/^FI\d{8}$/D',
'FR' => '/^FR[0-9A-HJ-NP-Z]{2}\d{9}$/D',
'HR' => '/^HR\d{11}$/D',
'HU' => '/^HU\d{8}$/D',
'IE' => '/^IE(\d{7}[A-W][A-IW]?|\d[A-Z+*]\d{5}[A-W])$/D',
'IT' => '/^IT\d{11}$/D',
'LT' => '/^LT(\d{9}|\d{12})$/D',
'LU' => '/^LU\d{8}$/D',
'LV' => '/^LV\d{11}$/D',
'MT' => '/^MT\d{8}$/D',
'NL' => '/^NL\d{9}B\d{2}$/D',
'PL' => '/^PL\d{10}$/D',
'PT' => '/^PT\d{9}$/D',
'RO' => '/^RO\d{2,10}$/D',
'SE' => '/^SE\d{10}01$/D',
'SI' => '/^SI\d{8}$/D',
'SK' => '/^SK\d{10}$/D',
'GB' => '/^GB(\d{9}|\d{12}|GD\d{3}|HA\d{3})$/D',
'XI' => '/^XI(\d{9}|\d{12}|GD\d{3}|HA\d{3})$/D',
];
// "de 136.695-976" -> "DE136695976"; Greece's GR prefix -> EL.
function normalizeVat(string $input): string
{
$vat = strtoupper(preg_replace('/[\s.\-]/', '', $input));
return str_starts_with($vat, 'GR') ? 'EL' . substr($vat, 2) : $vat;
}
function hasValidFormat(string $input): bool
{
$vat = normalizeVat($input);
$pattern = VAT_FORMATS[substr($vat, 0, 2)] ?? null;
return $pattern !== null && preg_match($pattern, $vat) === 1;
}
It gives the same results as the JavaScript version on the same test numbers. In Python, python-stdnum (next section) checks the format and the check digit in one call; if you use the patterns with re, call re.fullmatch, because Python’s $ also matches before a trailing newline.
3. Verify the check digit
Most countries build a check digit into the number, so a single mistyped digit usually fails. The algorithms differ by country.
Python: python-stdnum
python-stdnum (pip install python-stdnum, version 2.2 in our test) implements them. stdnum.eu.vat covers the 27 member states and XI; GB numbers need stdnum.gb.vat:
from stdnum.eu import vat as eu_vat
from stdnum.gb import vat as gb_vat
def has_valid_check_digits(vat_number: str) -> bool:
"""Format and check digit, offline. EU member states and XI, plus GB."""
if eu_vat.compact(vat_number).startswith("GB"):
return gb_vat.is_valid(vat_number)
return eu_vat.is_valid(vat_number)
print(has_valid_check_digits("DE 136 695 976")) # True
print(has_valid_check_digits("DE 136 695 977")) # False: wrong check digit
eu_vat.is_valid("GB980780684") returns False, which is why GB numbers take the other module.
JavaScript: jsvat or your own
The npm package jsvat (npm install jsvat, version 2.5.4) checks format and check digit:
import { checkVAT, countries } from 'jsvat';
checkVAT('DE136695976', countries).isValid; // true
checkVAT('DE136695977', countries).isValid; // false
On our 76 test numbers it gave the same answer as python-stdnum for 75. The exception: it does not know the XI prefix and returns false for XI304775894. Check XI numbers with GB in front instead; they are the same number.
For a single country you can write the algorithm yourself. German VAT numbers use ISO 7064 MOD 11,10:
// German VAT numbers: the 9th digit is an ISO 7064 MOD 11,10 check digit.
export function germanCheckDigitIsValid(vat) {
const digits = vat.replace(/^DE/, '');
if (!/^[1-9]\d{8}$/.test(digits)) return false;
let product = 10;
for (let i = 0; i < 8; i++) {
const sum = (Number(digits[i]) + product) % 10 || 10;
product = (2 * sum) % 11;
}
const check = (11 - product) % 10;
return check === Number(digits[8]);
}
It agreed with python-stdnum on 25,000 generated numbers.
What a check digit cannot tell you: DE136695976 passes and appears in many code examples, but VIES answered “invalid” for it when we checked on 29 October 2026. Only the registration check below catches that.
4. Check the registration with the API
The registration check asks the official register: VIES for EU and XI numbers, a commercial VAT data service for GB numbers. vatcheckapi checks the format and check digit first and only asks the register if both pass.
curl -G https://api.vatcheckapi.com/v2/check \
-d vat_number=LU26375245 \
-d requester_vat_number=ATU78304602 \
-H "apikey: $VATCHECKAPI_KEY"
requester_vat_number is optional: send your own EU VAT number and VIES returns a consultation number, the proof of the check you keep with the invoice. The answer:
{
"country_code": "LU",
"vat_number": "26375245",
"format_valid": true,
"checksum_valid": true,
"registration_info": {
"is_registered": true,
"name": "AMAZON EUROPE CORE S.A R.L.",
"address": "38, AVENUE JOHN F. KENNEDY\nL-1855 LUXEMBOURG",
"address_parts": null,
"consultation_number": "WAPIAAAAZ3x9Q1bk",
"checked_at": "2026-10-17T09:30:00.000000Z"
},
"registration_info_history": []
}
| Status | Meaning | Billed |
|---|---|---|
200 | An answer: registered, not registered, or format_valid / checksum_valid false (no register asked) | Yes, 1 request |
401, 403 | Missing or invalid API key, referrer not allowed, or unverified free account | No |
422 | Missing vat_number, unsupported country, invalid requester_vat_number, or VIES rejected the input | No |
429 | Monthly quota used up, or the free plan’s 10 requests a minute | No |
503 | The register is not available or did not answer in time | No |
A check makes up to 5 attempts within about 55 seconds before it answers 503, so give your HTTP client a timeout of at least 60 seconds. The Validate VAT Number documentation lists every field and error. Keep the key in an environment variable (VATCHECKAPI_KEY below), never in code or in the browser.
JavaScript (Node.js 18 or later)
const API_URL = 'https://api.vatcheckapi.com/v2/check';
// The register did not answer (503). Not billed: retry later.
export class VatCheckUnavailable extends Error {}
export async function checkRegistration(vatNumber, requesterVatNumber) {
const params = new URLSearchParams({ vat_number: vatNumber });
if (requesterVatNumber) params.set('requester_vat_number', requesterVatNumber);
let response;
try {
response = await fetch(`${API_URL}?${params}`, {
headers: { apikey: process.env.VATCHECKAPI_KEY },
signal: AbortSignal.timeout(60_000), // VIES can take up to about 55 seconds
});
} catch (error) {
throw new VatCheckUnavailable(`No answer from vatcheckapi: ${error.message}`);
}
const body = await response.json().catch(() => ({}));
if (response.status === 503) throw new VatCheckUnavailable(body.message);
if (!response.ok) {
// 401/403: API key or plan, 422: bad input, 429: quota used up
throw new Error(`vatcheckapi ${response.status}: ${body.message ?? response.statusText}`);
}
return body;
}
import { checkRegistration, VatCheckUnavailable } from './check-registration.mjs';
try {
const result = await checkRegistration('LU26375245', process.env.MY_VAT_NUMBER);
const info = result.registration_info;
if (!result.format_valid || !result.checksum_valid) {
console.log('Typo: check the number with the customer');
} else if (info.is_registered) {
console.log('Registered:', info.name, info.consultation_number, info.checked_at);
} else {
console.log('Not registered: charge VAT');
}
} catch (error) {
if (!(error instanceof VatCheckUnavailable)) throw error;
console.log('Register unavailable: accept the order and check again later');
}
Python
With requests (2.34 in our test); add it to the module from section 3:
import os
import requests
API_URL = "https://api.vatcheckapi.com/v2/check"
class VatCheckUnavailable(Exception):
"""The register did not answer (503). Not billed: retry later."""
def check_registration(vat_number: str, requester_vat_number: str | None = None) -> dict:
params = {"vat_number": vat_number}
if requester_vat_number:
params["requester_vat_number"] = requester_vat_number
try:
response = requests.get(
API_URL,
params=params,
headers={"apikey": os.environ["VATCHECKAPI_KEY"]},
timeout=60, # VIES can take up to about 55 seconds
)
except (requests.ConnectionError, requests.Timeout) as error:
raise VatCheckUnavailable(str(error)) from error
if response.status_code == 503:
raise VatCheckUnavailable(response.json().get("message"))
# 401/403: API key or plan, 422: bad input, 429: quota used up
response.raise_for_status()
return response.json()
try:
result = check_registration("LU26375245", os.environ.get("MY_VAT_NUMBER"))
except VatCheckUnavailable:
print("Register unavailable: accept the order and check again later")
else:
info = result["registration_info"]
print(info["is_registered"], info["name"], info["consultation_number"])
PHP
With the cURL extension. There is no curl_close(): it has had no effect since PHP 8.0 and is deprecated since PHP 8.5.
<?php
declare(strict_types=1);
const VATCHECKAPI_URL = 'https://api.vatcheckapi.com/v2/check';
// The register did not answer (503). Not billed: retry later.
final class VatCheckUnavailable extends RuntimeException {}
function checkRegistration(string $vatNumber, ?string $requesterVatNumber = null): array
{
$params = ['vat_number' => $vatNumber];
if ($requesterVatNumber !== null && $requesterVatNumber !== '') {
$params['requester_vat_number'] = $requesterVatNumber;
}
$ch = curl_init(VATCHECKAPI_URL . '?' . http_build_query($params));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['apikey: ' . getenv('VATCHECKAPI_KEY')],
CURLOPT_TIMEOUT => 60, // VIES can take up to about 55 seconds
]);
$body = curl_exec($ch);
if ($body === false) {
throw new VatCheckUnavailable('No answer from vatcheckapi: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data = json_decode($body, true) ?? [];
if ($status === 503) {
throw new VatCheckUnavailable($data['message'] ?? 'Register not available');
}
if ($status !== 200) {
// 401/403: API key or plan, 422: bad input, 429: quota used up
throw new RuntimeException("vatcheckapi $status: " . ($data['message'] ?? $body));
}
return $data;
}
try {
$result = checkRegistration('LU26375245', getenv('MY_VAT_NUMBER') ?: null);
$info = $result['registration_info'];
echo $info['is_registered'] ? "Registered: {$info['name']}\n" : "Not registered\n";
} catch (VatCheckUnavailable $e) {
echo "Register unavailable: accept the order and check again later\n";
}
Against the local test server, all three versions returned the registered name and consultation number for LU26375245, checksum_valid: false for DE136695977, is_registered: false for an unregistered number, raised VatCheckUnavailable for a 503 and for an unreachable server, and raised an error with the API’s message for 401, 422 and 429.
5. Put the checks together
In the browser: run hasValidFormat() as the customer types and show the expected format when it fails. Don’t call the API from the browser: the key would be public.
On the server: call the registration check when the order or sign-up is submitted, and act on the answer:
| Answer | What to do |
|---|---|
format_valid or checksum_valid is false | Ask the customer to correct the number |
is_registered: true | Treat the customer as a business; store name, address, consultation_number and checked_at with the order |
is_registered: false | Treat the customer as a consumer and charge VAT |
503 (VatCheckUnavailable) | Accept the order, mark the number as unchecked, check again later |
422 | Unsupported country or invalid requester number: show the format hint |
Why the stored fields matter for invoicing without VAT is explained in reverse charge VAT; what VIES does behind the call, in VIES VAT validation.
Testing: the free plan has 150 checks a month and 10 requests a minute. On the Medium and Large plans you can create sandbox keys (vat_dev_…): they never ask a register and cost nothing, every number with a valid format and check digit comes back as registered, and ATU00000006 comes back as not registered. Plans are on the pricing page; to try a number by hand, use the VAT number validator.