How to Validate VAT Numbers in TypeScript

For Python applications, follow the Python VAT validation guide.

This guide covers how to validate EU, UK, Swiss, Liechtenstein, Norwegian, and Australian VAT/GST numbers in TypeScript using the @avatcado/node SDK. The SDK provides typed responses, a clean error handling pattern, batch validation, and test mode support. We will also show raw fetch() equivalents so you can see what the SDK abstracts away.

If you want to call the European Commission without the SDK, the VIES API guide provides a complete native fetch implementation, including HTTP 200 fault handling and consultation references.

Installation

npm install @avatcado/node

Basic validation

The simplest possible example:

import Avatcado from "@avatcado/node";

const avatcado = new Avatcado("avat_live_your_api_key");

const { data, error } = await avatcado.vat.validate({
  vatNumber: "DE123456789",
});

if (data?.data.valid) {
  console.log(`Company: ${data.data.company?.name}`);
  console.log(`Country: ${data.data.countryCode}`);
}

The SDK returns { data, error }. Exactly one of these is set, never both. data contains the full validation result. error contains a structured error with code and message. The SDK never throws exceptions for API errors.

Error handling

const { data, error } = await avatcado.vat.validate({
  vatNumber: "INVALID",
});

if (error) {
  switch (error.code) {
    case "invalid_vat_format":
      // The input is not a valid VAT number format
      console.error("Bad format:", error.message);
      break;
    case "upstream_unavailable":
      // VIES or HMRC is down
      console.error("Upstream down, try again later");
      break;
    case "rate_limit_exceeded":
      // Monthly quota exceeded
      console.error("Quota exceeded");
      break;
    default:
      console.error(`Error: ${error.code} - ${error.message}`);
  }
  return;
}

// data is guaranteed to be set here
console.log(data.data.valid, data.data.vatNumber);

All error codes are documented at docs.avatcado.com with explanations and suggested handling. Every error includes a docs_url field pointing to the specific error page, and validation errors echo the normalized vat_number you submitted, so your logs always record which number the failed attempt was for.

Batch validation

import Avatcado, { isBatchSuccess } from "@avatcado/node";

const avatcado = new Avatcado("avat_live_your_api_key");

const { data, error } = await avatcado.vat.validateBatch({
  vatNumbers: [
    "DE123456789",
    "FR82542065479",
    "GB987654321",
    "INVALID123",
  ],
});

if (error) {
  console.error("Batch request failed:", error.message);
  return;
}

for (const result of data.data.results) {
  if (isBatchSuccess(result)) {
    console.log(`${result.data.vatNumber}: ${result.data.valid ? "valid" : "invalid"}`);
  } else {
    console.log(`${result.meta.vatNumber}: error - ${result.error.code}`);
  }
}

console.log(`${data.data.summary.succeeded}/${data.data.summary.total} succeeded`);

Batch validation accepts up to 50 VAT numbers in a single request. Available on Pro and Business tiers. The isBatchSuccess() type guard narrows the result type so TypeScript knows whether you have data or error on each item. Results are returned in the same order as the input.

Test mode

// Use a test key - no upstream calls, no quota usage
const avatcado = new Avatcado("avat_test_your_test_key");

// Magic numbers for deterministic testing
const valid = await avatcado.vat.validate({ vatNumber: "DE111111111" });     // Always valid
const invalid = await avatcado.vat.validate({ vatNumber: "DE000000000" });   // Always invalid
const down = await avatcado.vat.validate({ vatNumber: "DE999999999" });      // Simulates upstream outage
const stale = await avatcado.vat.validate({ vatNumber: "DE555555555" });     // Stale cache response

DE111111111 always returns valid with a test company. DE000000000 always returns invalid. DE999999999 simulates an upstream service outage. DE555555555 returns a stale cached response. Test mode is ideal for CI/CD pipelines. You get fast, deterministic responses without hitting VIES or HMRC.

Comparison: SDK vs raw fetch

Here's the same basic validation using raw fetch():

const response = await fetch(
  "https://api.avatcado.com/v1/validate?vat_number=DE123456789",
  {
    headers: {
      Authorization: "Bearer avat_live_your_api_key",
    },
  }
);

const result = await response.json();

if (response.ok) {
  const { data, meta } = result;
  console.log(data.valid, data.company?.name);
} else {
  const { error, meta } = result;
  console.error(error.code, error.message);
}

The raw fetch approach works fine, but the SDK adds typed responses, automatic error parsing, batch support with type guards, and a cleaner API. If you prefer minimal dependencies, the REST API is straightforward to call directly.

Or with curl:

curl https://api.avatcado.com/v1/validate?vat_number=DE123456789 \
  -H "Authorization: Bearer avat_live_your_api_key"

Framework integration examples

Next.js API route

import Avatcado from "@avatcado/node";
import { NextRequest, NextResponse } from "next/server";

const avatcado = new Avatcado(process.env.AVATCADO_API_KEY!);

export async function GET(request: NextRequest) {
  const vatNumber = request.nextUrl.searchParams.get("vat_number");
  if (!vatNumber) {
    return NextResponse.json(
      { error: "vat_number is required" },
      { status: 400 }
    );
  }

  const { data, error } = await avatcado.vat.validate({ vatNumber });

  if (error) {
    return NextResponse.json({ error }, { status: 422 });
  }

  return NextResponse.json({ data });
}

Express middleware

import Avatcado from "@avatcado/node";
import type { Request, Response, NextFunction } from "express";

const avatcado = new Avatcado(process.env.AVATCADO_API_KEY!);

export async function validateVat(req: Request, res: Response, next: NextFunction) {
  const vatNumber = req.body.vat_number;
  if (!vatNumber) return next();

  const { data, error } = await avatcado.vat.validate({ vatNumber });

  if (error) {
    res.status(422).json({ error: error.message });
    return;
  }

  req.vatValidation = data;
  next();
}

Hono handler

import { Hono } from "hono";
import Avatcado from "@avatcado/node";

const app = new Hono();
const avatcado = new Avatcado(process.env.AVATCADO_API_KEY!);

app.get("/validate", async (c) => {
  const vatNumber = c.req.query("vat_number");
  if (!vatNumber) {
    return c.json({ error: "vat_number is required" }, 400);
  }

  const { data, error } = await avatcado.vat.validate({ vatNumber });

  if (error) {
    return c.json({ error }, 422);
  }

  return c.json({ data });
});

Python SDK

Looking for Python? The avatcado package on PyPI offers the same features with typed exceptions and async support. Install with pip install avatcado.

Get started

The free tier includes 500 validations per month. Install the SDK, grab a test key, and start validating. See the full API reference at docs.avatcado.com.

Get your API key →

Frequently asked questions

Does the @avatcado/node SDK work with Bun and Deno?

The SDK uses standard fetch under the hood and requires Node.js 18 or later, the version that made global fetch available, and it has zero runtime dependencies, so there is nothing else to polyfill. Other runtimes with a compatible fetch implementation, including Bun and Deno, generally work for exactly that reason, but only Node.js is officially supported and tested. If you hit an issue on another runtime, the fallback is trivial: the REST API itself is a plain HTTPS GET with a Bearer token, so you can call it with the runtime's own fetch and get the same JSON envelope back, giving up only the typed responses and helpers like the isBatchSuccess type guard. The same reasoning applies to edge environments: wherever a standard fetch exists, the raw API is callable even where the SDK is not officially supported, so runtime support never blocks you from validating, it only changes how much typing help you get.

Can I use the Avatcado API without the SDK?

Yes. The REST API is a plain HTTPS endpoint: GET /v1/validate with the vat_number as a query parameter and your API key in an Authorization: Bearer header. Any HTTP client works, fetch, axios, curl, or your framework's built-in client, and the response is the same snake_case JSON envelope whether or not the SDK is involved. Errors come back with a machine-readable code, a human-readable message, and a docs_url pointing at the specific error page, so error handling with raw HTTP amounts to checking response.ok and switching on error.code. What the SDK adds on top: typed responses, automatic error parsing into the { data, error } pattern (exactly one of the two is ever set, and API errors never throw), batch support with the isBatchSuccess type guard, and test mode ergonomics. If you prefer minimal dependencies, skipping the SDK loses convenience, not capability; the API surface is identical either way.

How does test mode work?

Use an API key prefixed with avat_test_ instead of avat_live_. In test mode, no request ever reaches VIES, HMRC, or any other registry, and no quota is consumed, so you can run validations as often as your CI does. Deterministic behavior comes from magic VAT numbers: DE111111111 always returns valid with a test company, DE000000000 always returns invalid, DE999999999 simulates an upstream service outage so you can exercise your error handling, and DE555555555 returns a stale cached response so you can test your stale-result logic. Because the responses are deterministic and instant, test mode is ideal for CI/CD pipelines, where hitting live registries would be slow and flaky. The response shapes are identical to live mode, so code tested against magic numbers behaves the same in production; only the key prefix changes. The full list of magic numbers and error simulations is documented at docs.avatcado.com.

What is the isBatchSuccess type guard?

isBatchSuccess() is a TypeScript type guard exported by @avatcado/node for working with batch results. A batch request (avatcado.vat.validateBatch, up to 50 numbers per call on Pro and Business tiers) returns per-item results in the same order as the input, and each item independently either succeeded or failed: one malformed number does not fail the whole batch. That means each result item is a union type, either a success carrying data (the validation result) or a failure carrying error (a structured error with a machine-readable code). Calling isBatchSuccess(result) narrows the union: inside the if branch TypeScript knows result.data exists, and in the else branch it knows result.error and result.meta.vatNumber exist, so accessing the wrong field becomes a compile-time error rather than a runtime surprise. The batch response also includes a summary object with succeeded and total counts, useful for quick reporting without iterating over the results twice.

Sources

Related guides