Validate EU VAT numbers in Node, Python and PHP
To validate an EU VAT number from Node.js, Python or PHP, send one GET to /api/v1/vat/validate with the number, read valid, and read source.kind to know whether VIES answered live or a dated cached answer was served. The three functions below do exactly that with each language’s standard HTTP client (fetch, requests, curl) and branch on the stable code of an error. No SDK to install: the API is plain HTTPS and JSON, described by an OpenAPI 3.1 document.
- The first 20 calls a day need no key; set
VERIPORT_KEYonce you have one. - Pass
requester(your own VAT number) to obtain a consultation number on the paid tiers. - Treat
vies_unavailableas "retry later", never as "invalid". - Log
source.kind,source.checkedAtandconsultationNumberwith the order: that is your audit evidence.
Node.js
Native fetch (Node 18+), no dependency.
const API = "https://veriport.io/api/v1";
const KEY = process.env.VERIPORT_KEY; // optional for the first 20 calls a day
export async function validateVat(country, number, requester) {
const url = new URL(API + "/vat/validate");
url.searchParams.set("country", country);
url.searchParams.set("number", number);
if (requester) url.searchParams.set("requester", requester);
const res = await fetch(url, { headers: KEY ? { Authorization: "Bearer " + KEY } : {} });
const body = await res.json();
if (!res.ok) {
// RFC 9457 problem: body.code is stable, body.detail is for humans
if (body.code === "vies_unavailable") throw new Error("VIES down, retry in " + body.retryAfter + "s");
throw new Error(body.code + ": " + body.detail);
}
// body.valid, body.name, body.address, body.consultationNumber
// body.source.kind is "live" or "cache"; body.source.stale tells you which
return body;
}
const r = await validateVat("DE", "136695976");
console.log(r.valid, r.source.kind, r.name);Python
requests; the standard library’s urllib works the same way.
import os
import requests
API = "https://veriport.io/api/v1"
KEY = os.environ.get("VERIPORT_KEY") # optional for the first 20 calls a day
def validate_vat(country: str, number: str, requester: str | None = None) -> dict:
params = {"country": country, "number": number}
if requester:
params["requester"] = requester
headers = {"Authorization": f"Bearer {KEY}"} if KEY else {}
r = requests.get(f"{API}/vat/validate", params=params, headers=headers, timeout=15)
body = r.json()
if not r.ok:
# RFC 9457 problem: body["code"] is stable, body["detail"] is for humans
if body.get("code") == "vies_unavailable":
raise RuntimeError(f"VIES down, retry in {body.get('retryAfter')}s")
raise RuntimeError(f"{body.get('code')}: {body.get('detail')}")
return body # valid, name, address, consultationNumber, source.kind, source.stale
r = validate_vat("DE", "136695976")
print(r["valid"], r["source"]["kind"], r["name"])PHP
The curl extension; Guzzle users replace the transport and keep the branches.
<?php
const API = "https://veriport.io/api/v1";
$key = getenv("VERIPORT_KEY") ?: null; // optional for the first 20 calls a day
function validateVat(string $country, string $number, ?string $requester = null, ?string $key = null): array {
$query = http_build_query(array_filter(["country" => $country, "number" => $number, "requester" => $requester]));
$ch = curl_init(API . "/vat/validate?" . $query);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => $key ? ["Authorization: Bearer $key"] : [],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$body = json_decode($raw, true);
if ($status >= 400) {
// RFC 9457 problem: $body["code"] is stable, $body["detail"] is for humans
if (($body["code"] ?? "") === "vies_unavailable") {
throw new RuntimeException("VIES down, retry in " . ($body["retryAfter"] ?? 30) . "s");
}
throw new RuntimeException(($body["code"] ?? "error") . ": " . ($body["detail"] ?? ""));
}
return $body; // valid, name, address, consultationNumber, source.kind, source.stale
}
$r = validateVat("DE", "136695976", null, $key);
var_dump($r["valid"], $r["source"]["kind"], $r["name"]);What should you store?
At minimum: the normalised number (full), valid, requestDate, source.kind, source.checkedAt and consultationNumber. For an invoice, print the VAT number and keep the rest with the order. Re-check numbers you invoice recurrently every quarter; deregistrations happen.
IBAN in the same style
The IBAN endpoint never fails for a bad IBAN: it answers 200 with valid: false and the list of failed checks, so a form can be specific.
const res = await fetch("https://veriport.io/api/v1/iban/validate?iban=" + encodeURIComponent(iban));
const r = await res.json();
if (!r.valid) {
// r.failures: ["length"] | ["check_digits"] | ["bban_pattern"] | ["country_unknown"] | ["characters"]
showError("IBAN rejected: " + r.failures.join(", "));
} else if (!r.sepa) {
showWarning(r.countryName + " is outside SEPA: expect correspondent-bank fees and delays");
}Questions
- Is there an official SDK?
- Yes:
@veriport/sdkfor TypeScript (generated from /openapi.json, with MIT offline helpers for the VAT number structure and the IBAN mod-97 check) andveriportfor Python (httpx, sync and async), plus an MCP server for agents, all MIT in the public SDK repository linked from the About page; the npm and PyPI releases are announced in the changelog. The functions above need no SDK: the API is plain HTTPS and JSON, and the OpenAPI document feeds any generator (openapi-generator, orval, Kiota). - Can I call it from the browser?
- Technically yes (CORS is open), but a key in browser code is public. Use the anonymous allowance for demos and call from your server in production.
100 calls a month, no card.
Get a free keySources
- VIES REST API — check-vat-number, check-status (technical information) — European Commission, DG TAXUD, consulted 2026-10-24