Validate VAT numbers in Django
Add VAT validation to Django forms, admin, and DRF serializers by calling the vatverify REST API with httpx. Covers sync and async views.
TL;DR
- There is no official Python SDK yet. Call the REST API via
httpx(orrequests) from a small helper module. - Drop the helper into
clean_<field>(), DRF serializer validators, and admin actions. - Use
vatverify-ratesfor offline format + checksum checks when you want to reject garbage before spending an API call.
vatverify-rates ships on PyPI alongside the API launch (v0.1.0 packaged, 160 tests green). Reading this before launch? Every code sample below uses httpx against the REST API, which is live today. You can ship the helper, form, admin action, and DRF serializer without any vatverify package installed. The JavaScript sibling @vatverify/vat-rates is already on npm if you need offline checksum validation from another part of your stack today.
Why framework-aware validation matters
Dropping a raw HTTP call anywhere in your codebase works for a prototype. In a Django project, it creates problems fast: the same logic gets duplicated across forms, API endpoints, and admin actions; errors surface in the wrong place; there is no single hook to change behaviour across all entry-points at once. Wiring validation into Django's form and serializer lifecycle means the check runs at exactly the right moment (before save()) and raises the right exception type wherever the data originated.
Installation
pip install httpx
# optional offline helper for format + checksum
pip install vatverify-ratesSettings
import os
VATVERIFY_API_KEY = os.environ.get("VATVERIFY_API_KEY", "")
VATVERIFY_BASE_URL = os.environ.get("VATVERIFY_BASE_URL", "https://api.vatverify.dev")The shared client
Put the HTTP client and a small helper in one place. Every form, serializer, admin action, and view imports from here.
from __future__ import annotations
import httpx
from django.conf import settings
class VatverifyError(Exception):
"""Raised when the API call fails for a transport or server reason."""
def __init__(self, message: str, status_code: int | None = None) -> None:
super().__init__(message)
self.status_code = status_code
_client = httpx.Client(
base_url=settings.VATVERIFY_BASE_URL,
timeout=30.0,
)
def validate(vat_number: str) -> dict:
"""Call GET /v1/validate and return the full {data, meta} envelope."""
response = _client.get(
"/v1/validate",
params={"vat_number": vat_number},
headers={"Authorization": f"Bearer {settings.VATVERIFY_API_KEY}"},
)
if response.status_code >= 400:
body = response.json() if response.content else {}
message = body.get("error", {}).get("message", f"HTTP {response.status_code}")
raise VatverifyError(message, status_code=response.status_code)
return response.json()Form validation with ModelForm
from django import forms
from .models import Company
from .vatverify_client import validate, VatverifyError
class CompanyForm(forms.ModelForm):
class Meta:
model = Company
fields = ["name", "vat_number", "country"]
def clean_vat_number(self) -> str:
value: str = self.cleaned_data.get("vat_number", "").strip()
if not value:
return value
try:
result = validate(value)
except VatverifyError as exc:
raise forms.ValidationError(f"VAT lookup failed: {exc}") from exc
data = result["data"]
if not data["valid"]:
raise forms.ValidationError(f"{data['vat_number']} is not registered.")
return valueThe VatverifyError catch guards against transient network failures. The user sees a clear message instead of a 500.
Admin integration
Add a custom action to validate VAT numbers directly from the Django admin changelist.
from django.contrib import admin, messages
from django.http import HttpRequest
from .models import Company
from .vatverify_client import validate, VatverifyError
@admin.action(description="Validate VAT number now")
def validate_vat_number(
modeladmin: admin.ModelAdmin,
request: HttpRequest,
queryset,
) -> None:
for company in queryset.exclude(vat_number=""):
try:
result = validate(company.vat_number)
except VatverifyError as exc:
modeladmin.message_user(
request,
f"{company.name}: lookup failed: {exc}",
level=messages.ERROR,
)
continue
data = result["data"]
if data["valid"]:
modeladmin.message_user(
request,
f"{company.name}: VAT {data['vat_number']} is valid.",
level=messages.SUCCESS,
)
else:
modeladmin.message_user(
request,
f"{company.name}: {data['vat_number']} is not registered.",
level=messages.WARNING,
)
@admin.register(Company)
class CompanyAdmin(admin.ModelAdmin):
list_display = ["name", "vat_number", "country"]
actions = [validate_vat_number]DRF serializer
Validation in a ModelSerializer follows the same validate_<field> convention.
from rest_framework import serializers
from .models import Company
from .vatverify_client import validate, VatverifyError
class CompanySerializer(serializers.ModelSerializer):
class Meta:
model = Company
fields = ["id", "name", "vat_number", "country"]
def validate_vat_number(self, value: str) -> str:
if not value:
return value
try:
result = validate(value)
except VatverifyError as exc:
raise serializers.ValidationError(f"VAT lookup failed: {exc}") from exc
data = result["data"]
if not data["valid"]:
raise serializers.ValidationError(f"{data['vat_number']} is not registered.")
return valueAsync views (Django 5.0+)
Django 5.0 ships native async view support. Use httpx.AsyncClient to avoid blocking the event loop.
import httpx
from django.conf import settings
from django.http import JsonResponse
_async_client = httpx.AsyncClient(
base_url=settings.VATVERIFY_BASE_URL,
timeout=30.0,
)
async def validate_vat_view(request):
vat_number = request.GET.get("vat_number", "").strip()
if not vat_number:
return JsonResponse({"error": "vat_number is required"}, status=400)
response = await _async_client.get(
"/v1/validate",
params={"vat_number": vat_number},
headers={"Authorization": f"Bearer {settings.VATVERIFY_API_KEY}"},
)
if response.status_code >= 400:
return JsonResponse({"error": f"HTTP {response.status_code}"}, status=503)
data = response.json()["data"]
return JsonResponse(
{
"valid": data["valid"],
"vat_number": data["vat_number"],
"country": data["country"],
"company": data.get("company"),
}
)Offline filter (optional)
Install vatverify-rates to reject malformed numbers before spending an API call:
pip install vatverify-ratesfrom vatverify_rates import validate as offline_validate
def validate_hybrid(vat_number: str) -> dict:
offline = offline_validate(vat_number)
if not offline.valid:
# Skip the network; offline check already rejected this number.
return {
"data": {"valid": False, "vat_number": vat_number, "company": None},
"meta": {"source_status": "offline"},
"offline_errors": list(offline.errors),
}
return validate(vat_number)Testing with test-mode
Use @override_settings to inject a test API key and the magic VAT numbers that return deterministic responses.
from django.test import TestCase, override_settings
from myapp.forms import CompanyForm
@override_settings(VATVERIFY_API_KEY="vtv_test_xxx")
class CompanyFormValidationTests(TestCase):
def test_valid_vat_number_passes(self):
form = CompanyForm(
data={"name": "Acme Ltd", "vat_number": "IE6388047V", "country": "IE"}
)
self.assertTrue(form.is_valid())
def test_invalid_vat_number_raises_error(self):
form = CompanyForm(
data={"name": "Acme Ltd", "vat_number": "NOTAVAT", "country": "IE"}
)
self.assertFalse(form.is_valid())
self.assertIn("vat_number", form.errors)
def test_empty_vat_number_skips_validation(self):
form = CompanyForm(
data={"name": "Acme Ltd", "vat_number": "", "country": "IE"}
)
self.assertNotIn("vat_number", form.errors)The vtv_test_xxx key never hits the live API, so tests run offline and are free.
FAQ
Does this work with Django 3 or Django 4?
Yes. The sync helper works on Django 3.2 LTS and 4.x. The async view pattern requires Django 5.0+; on Django 4.2, stick to sync views or wrap the sync helper with asgiref.sync.sync_to_async.
Is there an official Python SDK?
Not yet. A first-class vatverify Python SDK is on the roadmap. Until it ships, the httpx helper shown in this guide is the supported integration path.
What happens when VIES is down?
The API returns the most recently cached result with meta.source_status: "degraded". Your integration keeps working. See Handle VIES downtime for how to surface the degraded status to end-users.
Can I use this inside a Celery task?
Yes. httpx.Client is thread-safe enough for task workers when instantiated at module level. For async Celery tasks, use httpx.AsyncClient inside an async task body.
How do I migrate from django-stdnum?
django-stdnum performs format and checksum validation only. To migrate: start by running vatverify-rates (the offline library) as a drop-in replacement for format checks, then wire in the live API via the helper above to add real registry lookups. The clean_vat_number pattern shown above is a direct replacement for a stdnum-based validator.