vatverify home
All guides

Offline VAT validation: zero dependencies, real checksums

Validate EU VAT numbers without a network call using @vatverify/vat-rates. 44-country rates data, real MOD-11 / MOD-97 / Luhn / HMRC 97-55 algorithms.

TL;DR

  • npm install @vatverify/vat-rates or pip install vatverify-rates: zero runtime dependencies.
  • validate('IE6388047V') runs real checksum algorithms (MOD-11, MOD-97, Luhn, HMRC 97-55) in ~2μs.
  • Use offline for format + checksum; use the live API when you need registry confirmation.
Package status

@vatverify/vat-rates (JavaScript) is on npm today, published and passing. vatverify-rates (Python) ships on PyPI alongside the API launch; v0.1.0 is packaged with 160 tests green. The hybrid examples below use @vatverify/node which also ships at launch; until then, substitute a plain fetch call to /v1/validate.

When you want offline vs live

Use offline if…Use live API if…
You need to reject obviously wrong VAT numbers at the form levelYou need to confirm the company is actively registered
Latency is critical (e.g., validating on every keystroke)You need the company name or address
You're processing a bulk CSV with millions of rowsYou need an audit trail of when the number was verified
You're running in a serverless environment with tight CPU budgetsYou're issuing an invoice with reverse-charge treatment
Network is unavailable (offline-first app, edge worker, test suite)You need the invoice_note for Article 226 compliance

Installation

# JavaScript / TypeScript
npm install @vatverify/vat-rates

# Python
pip install vatverify-rates
# or
uv add vatverify-rates

Format + checksum in one call

validate.ts
import { validate } from '@vatverify/vat-rates';

// Valid format and checksum
const good = validate('IE6388047V');
// { valid: true }

// Invalid checksum (well-formed but wrong check digit)
const bad = validate('IE6388047X');
// { valid: false, errors: ['invalid checksum'] }

// Invalid format
const ugly = validate('NOTAVAT');
// { valid: false, errors: ['invalid format'] }
validate.py
from vatverify_rates import validate

good = validate("IE6388047V")
# ValidateResult(valid=True, errors=())

bad = validate("DE000000000")
# ValidateResult(valid=False, errors=("invalid checksum",))

Rate lookups

Retrieve the standard VAT rate for any country:

rates.ts
import { getStandardRate, getRate } from '@vatverify/vat-rates';

// Standard rate as a number
const rate = getStandardRate('DE');
// 19

// Full Rate object with all brackets
const fullRate = getRate('DE');
// {
//   standard: 19,
//   reduced: [7],
//   super_reduced: null,
//   parking: null,
//   currency: 'EUR',
//   last_verified: '2026-04-01',
//   // ...plus country_code, country, flag, format, pattern, checksum, etc.
// }

Country utilities

country-utils.ts
import { countryName, getFlag, isEUMember } from '@vatverify/vat-rates';

countryName('DE');   // 'Germany'
getFlag('DE');       // '🇩🇪'
isEUMember('DE');    // true
isEUMember('NO');    // false  (Norway: EEA but not EU)
isEUMember('GB');    // false  (UK: post-Brexit)

Which countries, which algorithms

The library covers 44 countries. EU-27 is fully supported, plus UK (HMRC 97-55), Switzerland (CHE UID MOD-11), Liechtenstein (same UID endpoint), Norway (MOD-11), and a set of non-EU jurisdictions that publish a format and, in many cases, a real checksum.

CountryPrefixAlgorithmCatches
AustriaATMOD-11 varianttypos in any of the 8 digits
BelgiumBEMOD-97transposition errors
BulgariaBGWeighted MOD-11 (9 or 10 digits)transposed digits
CyprusCYSum-weighted check letterwrong check letter
CzechiaCZWeighted MOD-11single-digit errors
GermanyDEMOD-11transposition in 8 digits
DenmarkDKWeighted MOD-11any single-digit error
EstoniaEEWeighted MOD-10typos
GreeceELWeighted MOD-11typos in 8 digits
SpainESLetter-position checksum (NIF / NIE / CIF)mixed alphanum errors
FinlandFIWeighted MOD-11typos in 7 digits
FranceFRMOD-97 of SIRENSIREN transposition
CroatiaHROIB MOD-11,10transposition
HungaryHUWeighted MOD-10typos
IrelandIEWeighted MOD-23 (letter check)typos in 7 digits
ItalyITLuhn (11 digits)transposition
LithuaniaLTWeighted MOD-11 (9 or 12 digits)typos
LuxembourgLUMOD-89transposition
LatviaLVMOD-3, weightedtypos
MaltaMTWeighted MOD-37typos
NetherlandsNLWeighted MOD-11 + fixed Btypos
PolandPLWeighted MOD-11transposition
PortugalPTWeighted MOD-11transposition
RomaniaROWeighted MOD-11typos
SwedenSELuhn on 10 digits + fixed 01 suffixtransposition
SloveniaSIWeighted MOD-11typos
SlovakiaSKMOD-11typos
UKGBHMRC 97-553-variant check (0, 42, 55)
Northern IrelandXIHMRC 97-55 (same as GB)same
SwitzerlandCHEUID MOD-11typos in 8 digits
LiechtensteinLIRouted to CHE UIDsame
NorwayNOMOD-11 org-numbertypos

Twelve additional non-EU European countries (AD, AL, BA, GE, IS, MD, ME, MK, RS, TR, UA, XK) are supported with format checks only. No authoritative checksum is published by the tax authority, so regex is what exists.

What the real algorithms catch that regex can't: transposed digits (DE123456798 vs DE123456789), single-digit typos, accidental letter substitution in mixed-alphanumeric formats (ES, IE), and the surprisingly common case of someone writing their own VAT number with the last digit zeroed out. On a checkout form with human-typed input, checksum rejection cuts "invalid VAT" support tickets by roughly half in our internal testing.

Why it beats regex: benchmarks

Regex catches structural errors only: wrong prefix, wrong digit count, missing separator. It cannot catch mathematical errors: a transposed digit, a typoed check character, a recycled number that fails its country's modular arithmetic. A mathematical checksum rejects thousands of numbers a regex would pass.

@vatverify/vat-rateslive API callhand-rolled regex
Latency per validation~2 μs~200 ms (avg incl. VIES)~0.5 μs
Catches format errorsyesyesyes
Catches checksum errorsyesyesno
Catches deregistered numbersnoyesno
Network requirednoyesno
Bundle size< 50 kB min+gz~150 kB (full SDK)~0 (inline regex)
API key requirednoyesno

Measured on an M1 MacBook Pro, Node 20, 10k iterations per run, median of 5 runs. The library is roughly 100,000× faster than a live round-trip for the overwhelming majority of inputs you need to reject (the ones that are structurally or mathematically wrong), and under a tenth the bundle size of a full SDK. Regex is faster still, but only at the cost of admitting numbers that aren't mathematically valid, which defeats the point.

The pragmatic pattern is a two-stage filter: @vatverify/vat-rates locally to kill obvious garbage at ~2 μs, then the live API only for the numbers that pass format + checksum. On a bulk import of 10,000 customer records, this typically cuts live API calls by 15–30%.

Combining with the live API

The most efficient pattern: use offline validation to filter out obviously invalid numbers, then only call the live API for numbers that pass the format + checksum check:

hybrid-validate.ts
import { validate as offlineValidate } from '@vatverify/vat-rates';
import { Vatverify } from '@vatverify/node';

const vat = new Vatverify(process.env.VATVERIFY_API_KEY!);

export async function validateVat(vatNumber: string) {
  // Step 1: fast offline check, no network, no API quota consumed
  const offline = offlineValidate(vatNumber);
  if (!offline.valid) {
    return { valid: false, source: 'offline' as const, errors: offline.errors };
  }

  // Step 2: live registry check, only reached if format + checksum pass
  const live = await vat.validate(vatNumber);
  return { ...live, source: 'live' as const };
}

This pattern saves API quota on bulk imports and reduces live API calls by rejecting malformed numbers before they hit the network.

hybrid_validate.py
import os
import httpx
from vatverify_rates import validate as offline_validate

_client = httpx.Client(
    base_url="https://api.vatverify.dev",
    headers={"Authorization": f"Bearer {os.environ['VATVERIFY_API_KEY']}"},
    timeout=30.0,
)


def validate_vat(vat_number: str):
    # Step 1: fast offline check
    offline = offline_validate(vat_number)
    if not offline.valid:
        return {"valid": False, "source": "offline", "errors": list(offline.errors)}

    # Step 2: live registry check (REST API, no Python SDK yet)
    response = _client.get("/v1/validate", params={"vat_number": vat_number})
    response.raise_for_status()
    return {"source": "live", **response.json()}

FAQ

The docs say "44 countries" but VIES only covers 32. What's the difference?

The 32-country live API coverage refers to registries we query in real time (EU-27 + UK, Northern Ireland (XI), Switzerland, Liechtenstein, Norway). The 44-country offline library adds twelve non-EU European jurisdictions where no live registry is publicly accessible but the official VAT number format is documented (Andorra, Albania, Bosnia, Georgia, Iceland, Moldova, Montenegro, North Macedonia, Serbia, Turkey, Ukraine, Kosovo). These are format-only checks. No authoritative checksum algorithm is published for them.

How big is the bundle?

@vatverify/vat-rates is under 50 kB minified + gzipped, including all country data and algorithm implementations. It has zero runtime dependencies.

Is it tree-shakeable?

Yes, all exports are named and the package ships with "sideEffects": false. If you only import validate, bundlers will strip the rate lookup and country utility code.

Does it work in the browser?

Yes, the library targets ES2020, has no Node.js-specific APIs, and works in any modern browser, Deno, Cloudflare Workers, and Vercel Edge Functions.

Do TypeScript types ship with the package?

Yes, the npm package includes full TypeScript declarations. ValidationResult, Rate, and all utility return types are exported.

Validate VAT in three lines.

Free up to 500 requests per month. No credit card.

Start free