How to validate VAT numbers in Python
Validate EU, UK, Swiss, and Norwegian VAT numbers from Python by calling the vatverify REST API directly with httpx or requests. Sync, async, and offline modes.
TL;DR
- There is no official Python SDK yet (on the roadmap). For now, call the REST API directly with
httpxorrequests. GET https://api.vatverify.dev/v1/validate?vat_number=IE6388047Vwith anAuthorization: Bearerheader.- For offline format + checksum validation, install
vatverify-rates(pip install vatverify-rates).
vatverify-rates ships on PyPI alongside the API launch (v0.1.0 packaged, 160 tests green). Reading this before launch? The REST API is live today. The httpx / requests examples below work without any package install. The JavaScript sibling @vatverify/vat-rates is already on npm if you need offline checksum validation today.
Why format checking isn't enough
A regex can confirm that DE123456789 looks like a German VAT number (two letters, nine digits, correct prefix). It cannot confirm the final check digit is mathematically correct, and it cannot confirm the business is actively registered.
Every EU country embeds a checksum algorithm into its VAT number format: Germany and the Netherlands use MOD-11 variants, France layers a MOD-97 key onto the SIREN, Italy uses a Luhn-style check, the UK uses HMRC's 97-55 algorithm, Norway and Switzerland run their own MOD-11 schemes. python-stdnum and similar libraries implement these, but format + checksum still only tells you a number is well-formed, not that it belongs to a live, VAT-registered business.
DE000000000 passes a format regex. It fails the German MOD-11 check. Even a number that passes both can be deregistered, recycled, or typoed into a different company's number. Only a live lookup against the national registry settles that, which is what the live validator does.
Calling the REST API from Python
The public API is plain REST with JSON responses. Any HTTP client works. Examples below use httpx (supports both sync and async) and the popular requests library.
Sync with httpx
import os
import httpx
API_KEY = os.environ["VATVERIFY_API_KEY"]
BASE_URL = "https://api.vatverify.dev"
def validate_vat(vat_number: str) -> dict:
response = httpx.get(
f"{BASE_URL}/v1/validate",
params={"vat_number": vat_number},
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=30.0,
)
response.raise_for_status()
return response.json()
result = validate_vat("IE6388047V")
data = result["data"]
if data["valid"]:
company = data.get("company") or {}
print(f"Valid: {company.get('name', data['vat_number'])}")
else:
print(f"{data['vat_number']} is not registered.")Sync with requests
import os
import requests
API_KEY = os.environ["VATVERIFY_API_KEY"]
response = requests.get(
"https://api.vatverify.dev/v1/validate",
params={"vat_number": "IE6388047V"},
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=30,
)
response.raise_for_status()
result = response.json()
print(result["data"]["valid"], result["data"]["country"]["code"])Async with httpx.AsyncClient
Use this shape inside FastAPI, Starlette, or any async framework. Share one AsyncClient across the process to reuse the connection pool.
import asyncio
import os
import httpx
API_KEY = os.environ["VATVERIFY_API_KEY"]
client = httpx.AsyncClient(
base_url="https://api.vatverify.dev",
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=30.0,
)
async def validate_vat(vat_number: str) -> dict:
response = await client.get("/v1/validate", params={"vat_number": vat_number})
response.raise_for_status()
return response.json()
async def main() -> None:
result = await validate_vat("IE6388047V")
print(result["data"]["valid"])
asyncio.run(main())Handling rate limits (HTTP 429)
When you exceed your plan limit, the API returns 429 with a Retry-After header (in seconds). Honour it: it's the single source of truth for when you can retry.
import time
import httpx
def validate_with_retry(vat_number: str, client: httpx.Client) -> dict:
for _ in range(3):
response = client.get("/v1/validate", params={"vat_number": vat_number})
if response.status_code == 429:
wait = int(response.headers.get("Retry-After", "1"))
time.sleep(wait)
continue
response.raise_for_status()
return response.json()
raise RuntimeError("rate limited after 3 attempts")Offline format + checksum with vatverify-rates
For format + checksum validation without a network call, install the offline package. Zero runtime dependencies, works in lambdas and containers without any credentials:
pip install vatverify-ratesfrom vatverify_rates import validate, get_rate, get_standard_rate
result = validate("IE6388047V")
# ValidateResult(valid=True)
bad = validate("DE000000000")
# ValidateResult(valid=False, errors=("invalid checksum",))
rate = get_rate("DE")
# Rate(standard=19.0, reduced=(7.0,), super_reduced=None, ...)
print(get_standard_rate("DE")) # 19.0ValidateResult has two shapes: valid=True (no errors) or valid=False with an errors tuple. No API key, no network, no latency.
Hybrid pattern: offline filter then live lookup
The efficient pattern is to reject obvious garbage offline (zero cost), then call the API only for numbers that pass format + checksum:
import os
import httpx
from vatverify_rates import validate as offline_validate
API_KEY = os.environ["VATVERIFY_API_KEY"]
client = httpx.Client(
base_url="https://api.vatverify.dev",
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=30.0,
)
def validate_vat(vat_number: str) -> dict:
offline = offline_validate(vat_number)
if not offline.valid:
return {"valid": False, "source": "offline", "errors": list(offline.errors)}
response = client.get("/v1/validate", params={"vat_number": vat_number})
response.raise_for_status()
return {"source": "live", **response.json()}Django and FastAPI patterns
For framework-specific guides, see the dedicated pages: Validate VAT in Django for forms, admin actions, DRF serializers, and async views.
For FastAPI, wrap the httpx.AsyncClient in a dependency:
from fastapi import Depends, HTTPException, Query
import httpx
_client = httpx.AsyncClient(
base_url="https://api.vatverify.dev",
timeout=30.0,
)
async def require_valid_vat(vat_number: str = Query(...)) -> dict:
import os
response = await _client.get(
"/v1/validate",
params={"vat_number": vat_number},
headers={"Authorization": f"Bearer {os.environ['VATVERIFY_API_KEY']}"},
)
if response.status_code == 429:
raise HTTPException(status_code=429, detail="rate limited")
response.raise_for_status()
data = response.json()["data"]
if not data["valid"]:
raise HTTPException(status_code=422, detail=f"{data['vat_number']} is not registered")
return dataTesting with test-mode keys
Use vtv_test_* keys for deterministic fixture responses. See Test mode.
import httpx
def test_valid_irish_vat_returns_company_name():
response = httpx.get(
"https://api.vatverify.dev/v1/validate",
params={"vat_number": "IE6388047V"},
headers={"Authorization": "Bearer vtv_test_xxx"},
)
response.raise_for_status()
data = response.json()["data"]
assert data["valid"] is True
assert data["country"]["code"] == "IE"FAQ
Is there an official Python SDK?
Not yet. A first-class Python SDK is on the roadmap. In the meantime, httpx or requests against the REST API is the supported integration path.
Can I use vatverify-rates offline in a lambda or container?
Yes, vatverify-rates has zero runtime dependencies and bundles its own country data. The installed wheel is small and works fine on cold-start-sensitive runtimes.
How does VIES downtime affect my integration?
When VIES is unavailable, the API returns the most recent cached result with meta.source_status: "degraded". See handle-vies-downtime for retry guidance.
What does an invalid VAT number response look like?
When the registry responds but the number is not registered, data.valid is false and data.company is null. Obvious format errors return a structured 400 response with an error.code like invalid_format or country_unsupported.