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.

Node.js

Native fetch (Node 18+), no dependency.

Node.js 18+ (fetch, 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.

Python 3 (requests)
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 8 (curl extension)
<?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.

Node.js — IBAN before a SEPA transfer
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/sdk for TypeScript (generated from /openapi.json, with MIT offline helpers for the VAT number structure and the IBAN mod-97 check) and veriport for 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 key

Sources