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-ratesorpip 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.
@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 level | You 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 rows | You need an audit trail of when the number was verified |
| You're running in a serverless environment with tight CPU budgets | You'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-ratesFormat + checksum in one call
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'] }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:
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
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.
| Country | Prefix | Algorithm | Catches |
|---|---|---|---|
| Austria | AT | MOD-11 variant | typos in any of the 8 digits |
| Belgium | BE | MOD-97 | transposition errors |
| Bulgaria | BG | Weighted MOD-11 (9 or 10 digits) | transposed digits |
| Cyprus | CY | Sum-weighted check letter | wrong check letter |
| Czechia | CZ | Weighted MOD-11 | single-digit errors |
| Germany | DE | MOD-11 | transposition in 8 digits |
| Denmark | DK | Weighted MOD-11 | any single-digit error |
| Estonia | EE | Weighted MOD-10 | typos |
| Greece | EL | Weighted MOD-11 | typos in 8 digits |
| Spain | ES | Letter-position checksum (NIF / NIE / CIF) | mixed alphanum errors |
| Finland | FI | Weighted MOD-11 | typos in 7 digits |
| France | FR | MOD-97 of SIREN | SIREN transposition |
| Croatia | HR | OIB MOD-11,10 | transposition |
| Hungary | HU | Weighted MOD-10 | typos |
| Ireland | IE | Weighted MOD-23 (letter check) | typos in 7 digits |
| Italy | IT | Luhn (11 digits) | transposition |
| Lithuania | LT | Weighted MOD-11 (9 or 12 digits) | typos |
| Luxembourg | LU | MOD-89 | transposition |
| Latvia | LV | MOD-3, weighted | typos |
| Malta | MT | Weighted MOD-37 | typos |
| Netherlands | NL | Weighted MOD-11 + fixed B | typos |
| Poland | PL | Weighted MOD-11 | transposition |
| Portugal | PT | Weighted MOD-11 | transposition |
| Romania | RO | Weighted MOD-11 | typos |
| Sweden | SE | Luhn on 10 digits + fixed 01 suffix | transposition |
| Slovenia | SI | Weighted MOD-11 | typos |
| Slovakia | SK | MOD-11 | typos |
| UK | GB | HMRC 97-55 | 3-variant check (0, 42, 55) |
| Northern Ireland | XI | HMRC 97-55 (same as GB) | same |
| Switzerland | CHE | UID MOD-11 | typos in 8 digits |
| Liechtenstein | LI | Routed to CHE UID | same |
| Norway | NO | MOD-11 org-number | typos |
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-rates | live API call | hand-rolled regex | |
|---|---|---|---|
| Latency per validation | ~2 μs | ~200 ms (avg incl. VIES) | ~0.5 μs |
| Catches format errors | yes | yes | yes |
| Catches checksum errors | yes | yes | no |
| Catches deregistered numbers | no | yes | no |
| Network required | no | yes | no |
| Bundle size | < 50 kB min+gz | ~150 kB (full SDK) | ~0 (inline regex) |
| API key required | no | yes | no |
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:
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.
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.
Related guides
- Open-source VAT libraries: the full library lineup, npm + PyPI links
- Validate VAT in Node.js
- Validate VAT in Python
- Validate VAT in Next.js
- EU reverse-charge API
- Handle VIES downtime