Tutorial · JavaScript, Python, PHP

Validate VAT numbers in code

Three checks, from instant to authoritative: a format regex, the check digit and a live registration check, with tested code in JavaScript, Python and PHP.

Updated October 30, 2026 · 10 min read

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.

CheckCatchesRunsCost
FormatWrong prefix, length or charactersBrowser or serverFree, instant
Check digitMost typosBrowser or serverFree, instant
RegistrationNumbers that are not (or no longer) registered; returns name and addressServerOne 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:

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": []
}
StatusMeaningBilled
200An answer: registered, not registered, or format_valid / checksum_valid false (no register asked)Yes, 1 request
401, 403Missing or invalid API key, referrer not allowed, or unverified free accountNo
422Missing vat_number, unsupported country, invalid requester_vat_number, or VIES rejected the inputNo
429Monthly quota used up, or the free plan’s 10 requests a minuteNo
503The register is not available or did not answer in timeNo

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:

AnswerWhat to do
format_valid or checksum_valid is falseAsk the customer to correct the number
is_registered: trueTreat the customer as a business; store name, address, consultation_number and checked_at with the order
is_registered: falseTreat the customer as a consumer and charge VAT
503 (VatCheckUnavailable)Accept the order, mark the number as unchecked, check again later
422Unsupported 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.

Frequently asked questions

Is a regex enough to validate a VAT number?

No. A regex catches wrong formats and a check-digit test catches most typos, but only a registration check (VIES for EU and XI numbers, a UK register for GB numbers) tells you whether the number is registered. DE136695976 has a valid format and check digit and is not registered in VIES.

How do I validate a VAT number in Python?

Use python-stdnum for the format and check digit: stdnum.eu.vat.is_valid() covers the 27 member states and XI, stdnum.gb.vat.is_valid() covers GB numbers. Then check the registration on your server with an API call such as GET https://api.vatcheckapi.com/v2/check?vat_number=… with your key in the apikey header.

Should I validate VAT numbers in the browser?

Check the format in the browser for instant feedback, and the registration on your server, so your API key stays secret and the result you store cannot be faked by the client.

What does vatcheckapi answer for a mistyped VAT number?

A 200 with format_valid: false or checksum_valid: false and is_registered: false. No register is asked, and the call counts as one request. Errors such as 422 (invalid parameters) and 503 (register unavailable) are not billed.

What should my code do when VIES is down?

vatcheckapi answers 503 when the register does not answer within about 55 seconds, and does not bill the call. Accept the order, mark the VAT number as unchecked and check it again later, instead of rejecting a customer whose number may be fine.

Which VAT numbers can vatcheckapi check?

29 country codes: the 27 EU member states (Greece as EL, GR is accepted), Northern Ireland (XI) and the United Kingdom (GB). EU and XI numbers are checked in VIES, GB numbers through a commercial VAT data service for Great Britain.

Are there official vatcheckapi libraries?

Yes, for Python, PHP, JavaScript, Ruby, C#, Rust, Go and R. They are listed in the documentation. The plain HTTP code in this guide needs no library.

VAT validation API

Validate VAT numbers in your app

vatcheckapi checks the format and checksum of EU and UK VAT numbers and confirms the registration live (VIES for EU numbers), with company name and address where the country provides them.

/v2/check documentation VAT number API All documentation

150 free validations every month. No credit card required.

GET https://api.vatcheckapi.com/v2/check?vat_number=LU26375245

{
  "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,
    "checked_at": "2026-10-16T09:12:44.000000Z"
  },
  "registration_info_history": []
}

Keep reading

Start using our VAT validation API for free today

Get 150 validations / month for free