VIES API Guide: REST Endpoint, SOAP & Integration

By Remco from Avatcado

Call the European Commission's REST endpoint from Node.js, distinguish invalid registrations from service faults, and keep the evidence your billing system needs.

VIES is the European Commission service for checking whether an EU or Northern Ireland VAT number is recorded as valid. The Commission offers an official JSON REST endpoint as well as the older SOAP service. This guide starts with the REST API, then shows where SOAP still matters and what a production integration must preserve.

Short answer: use the official REST endpoint

For a new server-side integration, call POST /vies/rest-api/check-vat-number with a country code and the VAT number without its country prefix. It needs no API key. It covers the 27 EU member states and XI numbers used for Northern Ireland goods arrangements. GB numbers moved out of VIES on January 1, 2021 and must be checked with HMRC.

VIES does not maintain one central company database. It sends the request to the relevant national VAT database, so a result can be unavailable even while the Commission endpoint itself is reachable. A format check only catches malformed input. A completed VIES lookup checks the registration recorded by that member state at that moment, and still does not decide the VAT treatment of a transaction by itself.

Official VIES REST endpoint

Send countryCode and vatNumber as JSON. Keep letters inside the local number, such as the B01 suffix in a Dutch VAT number. If you want a consultation reference, also send a genuine requester with requesterMemberStateCode and requesterNumber. The target-only request below omits those optional fields.

VIES · cURL
Ready to copy
curl --max-time 15 \
  --request POST \
  --header 'Content-Type: application/json' \
  --data '{"countryCode":"NL","vatNumber":"123456789B01"}' \
  'https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number'

A successful response includes the checked country and number, requestDate, the validity boolean, and a requestIdentifier field. Name and address are present only when the member state releases them. This target-only response is synthetic and its empty identifier means it carries no consultation proof. A genuine requester can produce a non-empty identifier, but VIES does not guarantee one. This example is not evidence of a live lookup.

VIES · JSON
Ready to copy
{
  "countryCode": "NL",
  "vatNumber": "123456789B01",
  "requestDate": "2026-09-15T10:00:00.000Z",
  "valid": true,
  "requestIdentifier": "",
  "name": "Example BV",
  "address": "Keizersgracht 1, Amsterdam"
}

Run the TypeScript example

The complete Node.js 20+ example below validates its inputs and response, uses a 15-second timeout, preserves the Commission timestamp and reference, and defensively checks the response body for fault fields before reading validity. Put it in a server-side file because requester VAT numbers can be sensitive business data. The source includes the exact command to run it.

VIES · TypeScript
Ready to copy
type ViesCheckResult =
  | {
      status: "Valid" | "Invalid";
      vat_number: string;
      name: string | null;
      address: string | null;
      checked_at: string;
      request_identifier: string | null;
      proof_status: "provided" | "not_provided";
    }
  | { status: "Unavailable" | "Error"; code: string; message: string | null };

const VIES_URL =
  "https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number";
const OUTAGE_CODES = new Set([
  "MS_UNAVAILABLE",
  "MS_MAX_CONCURRENT_REQ",
  "SERVICE_UNAVAILABLE",
  "GLOBAL_MAX_CONCURRENT_REQ",
  "TIMEOUT",
  "SERVER_BUSY",
  "VOW-ERR-11",
  "timeout",
  "network_error",
]);

function normalizeVat(value: string): string {
  return value.toUpperCase().replace(/[\s.-]/g, "");
}

function splitVat(value: string): { countryCode: string; number: string } | null {
  const normalized = normalizeVat(value);
  const match = /^([A-Z]{2})([A-Z0-9]+)$/.exec(normalized);
  return match ? { countryCode: match[1], number: match[2] } : null;
}

function record(value: unknown): Record<string, unknown> | null {
  return value !== null && typeof value === "object" && !Array.isArray(value)
    ? value as Record<string, unknown>
    : null;
}

function text(value: unknown): string | null {
  return typeof value === "string" && value.trim().length > 0 ? value : null;
}

function owns(value: Record<string, unknown>, key: string): boolean {
  return Object.prototype.hasOwnProperty.call(value, key);
}

function companyField(value: unknown): string | null | undefined {
  if (value === undefined || value === null) return null;
  if (typeof value !== "string") return undefined;
  const trimmed = value.trim();
  return trimmed === "" || trimmed === "---" ? null : trimmed;
}

function failure(code: string, message: string | null, httpStatus?: number): ViesCheckResult {
  const unavailable = OUTAGE_CODES.has(code) || httpStatus === 429 || (httpStatus !== undefined && httpStatus >= 500);
  return { status: unavailable ? "Unavailable" : "Error", code, message };
}

// Server-side Node.js 20+. VIES does not require credentials.
export async function checkVatWithVies(
  vatNumber: string,
  requesterVatNumber?: string,
): Promise<ViesCheckResult> {
  const target = splitVat(vatNumber);
  if (!target) return failure("invalid_vat_number", "Use an EU country prefix followed by letters or digits.");

  const requester = requesterVatNumber === undefined ? null : splitVat(requesterVatNumber);
  if (requesterVatNumber !== undefined && !requester) {
    return failure("invalid_requester_vat_number", "Supply a genuine requester VAT number or omit it.");
  }

  const body: Record<string, string> = {
    countryCode: target.countryCode,
    vatNumber: target.number,
  };
  if (requester) {
    body.requesterMemberStateCode = requester.countryCode;
    body.requesterNumber = requester.number;
  }

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 15_000);
  try {
    const response = await fetch(VIES_URL, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
      signal: controller.signal,
    });

    let parsed: unknown;
    try {
      parsed = await response.json();
    } catch (error) {
      if (controller.signal.aborted) return failure("timeout", "VIES did not answer within 15 seconds.");
      if (error instanceof TypeError) return failure("network_error", "The VIES response could not be read.");
      if (!response.ok) return failure("http_" + response.status, "VIES returned a non-JSON error.", response.status);
      return failure("malformed_response", "VIES returned a response that was not JSON.");
    }
    const data = record(parsed);
    if (!data) return failure("malformed_response", "VIES returned an unexpected response shape.");

    if (owns(data, "actionSucceed") && typeof data.actionSucceed !== "boolean") {
      return failure("malformed_response", "VIES returned an invalid action status.");
    }
    if (owns(data, "userError") && text(data.userError) === null) {
      return failure("malformed_response", "VIES returned an invalid error code.");
    }

    let wrappedCode: string | null = null;
    let wrappedMessage: string | null = null;
    if (owns(data, "errorWrappers")) {
      if (!Array.isArray(data.errorWrappers)) {
        return failure("malformed_response", "VIES returned invalid error wrappers.");
      }
      for (const value of data.errorWrappers) {
        const wrapper = record(value);
        if (!wrapper) return failure("malformed_response", "VIES returned an invalid error wrapper.");
        const hasError = owns(wrapper, "error");
        const hasMessage = owns(wrapper, "message");
        if (
          (!hasError && !hasMessage) ||
          (hasError && text(wrapper.error) === null) ||
          (hasMessage && typeof wrapper.message !== "string")
        ) {
          return failure("malformed_response", "VIES returned an invalid error wrapper.");
        }
        if (!wrappedCode && hasError) {
          wrappedCode = text(wrapper.error);
          wrappedMessage = text(wrapper.message);
        }
      }
      if (data.errorWrappers.length > 0 && !wrappedCode && !owns(data, "userError")) {
        return failure("malformed_response", "VIES returned error details without an error code.");
      }
    }
    const upstreamCode = text(data.userError) ?? wrappedCode;
    const upstreamMessage = wrappedMessage;
    if (upstreamCode) return failure(upstreamCode, upstreamMessage, response.status);
    if (data.actionSucceed === false) {
      return failure("malformed_response", "VIES reported failure without an error code.");
    }
    if (!response.ok) return failure("http_" + response.status, null, response.status);

    const name = companyField(data.name);
    const address = companyField(data.address);
    if (
      typeof data.valid !== "boolean" ||
      (data.isValid !== undefined && (typeof data.isValid !== "boolean" || data.isValid !== data.valid)) ||
      data.countryCode !== target.countryCode ||
      data.vatNumber !== target.number ||
      typeof data.requestDate !== "string" ||
      !Number.isFinite(Date.parse(data.requestDate)) ||
      name === undefined ||
      address === undefined
    ) {
      return failure("malformed_response", "VIES returned inconsistent validation data.");
    }

    if (owns(data, "requestIdentifier") && typeof data.requestIdentifier !== "string") {
      return failure("malformed_response", "VIES returned invalid registration evidence.");
    }
    const requestIdentifier = text(data.requestIdentifier);
    return {
      status: data.valid ? "Valid" : "Invalid",
      vat_number: target.countryCode + target.number,
      name,
      address,
      checked_at: data.requestDate,
      request_identifier: requestIdentifier,
      proof_status: requestIdentifier ? "provided" : "not_provided",
    };
  } catch (error) {
    if (controller.signal.aborted) return failure("timeout", "VIES did not answer within 15 seconds.");
    if (error instanceof TypeError) return failure("network_error", "VIES could not be reached.");
    return failure("unexpected_error", error instanceof Error ? error.message : null);
  } finally {
    clearTimeout(timer);
  }
}

// Save as vies-rest.ts, then run with Node.js 20+:
// npx --yes tsx vies-rest.ts NL123456789B01
// Add your genuine requester VAT number as the second argument when you need evidence.
const [targetVat, requesterVat] = process.argv.slice(2);
if (targetVat) {
  void checkVatWithVies(targetVat, requesterVat).then(console.log, console.error);
}

The example intentionally makes one request. Decide where retries belong in your job or checkout workflow, and avoid multiplying load during a member-state outage. For a smaller SDK example, see how to validate VAT numbers in TypeScript.

Invalid VAT number or unavailable service?

Do not read valid until you have checked the error fields. Avatcado's production client and the runnable example defensively handle bodies where HTTP 200 carries actionSucceed: false and an errorWrappers array, or where userError identifies a failure. A transport-level success therefore does not prove that the member state completed the lookup.

ResultMeaningApplication action
valid: false, no service errorThe completed lookup did not find a valid registration.Ask for correction or review other evidence before deciding tax treatment.
INVALID_INPUTThe country code or number is malformed.Fix the input. Repeating the same request will not help.
MS_UNAVAILABLE or TIMEOUTThe member-state service did not complete the check.Keep the outcome unavailable, then retry later or use documented stored evidence.
SERVICE_UNAVAILABLEThe VIES service could not complete the request.Preserve the error and retry later.
MS_MAX_CONCURRENT_REQ or GLOBAL_MAX_CONCURRENT_REQA concurrency limit was reached.Reduce concurrency and retry with bounded backoff.

An outage is missing evidence, not evidence that a number is invalid. Keep those states separate in storage and in the business rule that follows. The VIES downtime guide covers caching, stale results and review queues in more detail.

Requester details, proof and missing company data

Supplying your own VAT registration as the requester can return a requestIdentifier. Retain that identifier with the original VIES requestDate, checked number and raw result. A reference documents the check. It does not guarantee the customer's identity or the correct VAT treatment, and it is not returned in every country or response.

Treat ---, a blank string or an absent name or address as missing company data. Member states decide what identity details they disclose. A valid response without a name is therefore a normal case, not a malformed result. If your process must match a legal name or address, collect separate evidence rather than inventing a value.

VIES REST vs SOAP

The REST endpoint uses JSON and fits native fetch. The SOAP endpoint remains useful for existing integrations and generated clients built from the Commission's checkVatService WSDL. SOAP exposes checkVat plus checkVatApprox, and reports service failures as XML faults. Both interfaces ultimately depend on the same national databases.

VIES · cURL
Ready to copy
curl --max-time 15 --fail-with-body \
  --request POST \
  --header 'Content-Type: text/xml; charset=utf-8' \
  --header 'SOAPAction: ""' \
  --data-binary @- \
  'https://ec.europa.eu/taxation_customs/vies/services/checkVatService' <<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
  xmlns:urn="urn:ec.europa.eu:taxud:vies:services:checkVat:types">
  <soapenv:Body>
    <urn:checkVat>
      <urn:countryCode>FR</urn:countryCode>
      <urn:vatNumber>40303265045</urn:vatNumber>
    </urn:checkVat>
  </soapenv:Body>
</soapenv:Envelope>
XML

The following synthetic SOAP response shows the complete envelope and documented response fields. It is not evidence of a live lookup.

VIES · XML
Ready to copy
<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
  <soap:Body>
    <checkVatResponse xmlns="urn:ec.europa.eu:taxud:vies:services:checkVat:types">
      <countryCode>FR</countryCode>
      <vatNumber>40303265045</vatNumber>
      <requestDate>2026-09-15+02:00</requestDate>
      <valid>true</valid>
      <name>EXAMPLE SAS</name>
      <address>1 RUE EXAMPLE 75001 PARIS</address>
    </checkVatResponse>
  </soap:Body>
</soap:Envelope>

A safe ERP validation workflow

  1. Normalize the prefix and local number without discarding valid letters.
  2. Reject malformed input locally, then make the registry request on your server.
  3. Check transport, parsing and VIES error fields before reading validity.
  4. Store valid, invalid and unavailable as separate outcomes.
  5. Retain the source timestamp, response and consultation reference when one exists.
  6. Let a tax rule assess the supply separately from the registration result.

The programmatic EU VAT validation guide shows how this boundary fits a checkout or billing system.

VIES directly vs Avatcado

Direct VIES access is a sound choice when EU and XI coverage is enough and your team can own the failure states, evidence storage and operational controls. Avatcado is useful when you want one contract across registries and explicit source and freshness metadata.

ConcernVIES directlyAvatcado
CoverageEU member states and XIEU, XI, GB, CH, LI, NO and AU through one endpoint
Response contractVIES JSON or SOAP fields and faultsOne { data, meta } or { error, meta } envelope
Freshness and sourceStore the original response yourselfInspect requested_at, source, source_status, cached and stale
OutagesBuild your own retry, cache and review policyBuilt-in result caching with stored-result outage handling
National register fallbackIntegrate each register yourselfAvailable for ten EU countries on Pro and Business
Requester modeSend requester fields and preserve the referencerequester_vat_number disables cache reads and register fallback by default. Explicit flags can opt back in.

Avatcado's free plan includes 500 validations each month with no credit card required. The free tier keeps the cache-on-outage path. National register fallback and batch validation require Pro or Business.

This native cURL request keeps the API key in a server environment variable and quotes the URL so the shell does not interpret its query string. It performs a target-only lookup. Only add requester_vat_number when you need consultation evidence and can supply your own genuine registered requester VAT number. A non-EU seller without an eligible requester can omit it.

Avatcado · cURL
Ready to copy
# Set AVATCADO_API_KEY in this server environment first.
curl --max-time 15 \
  "https://api.avatcado.com/v1/validate?vat_number=NL123456789B01" \
  --header "Authorization: Bearer $AVATCADO_API_KEY"

After the request, inspect requested_at, meta.source, meta.source_status, meta.cached and meta.stale. With requester_vat_number, cache reads and national register fallback default to off so Avatcado asks VIES for a fresh check and preserves any consultation reference it receives. Explicit cache or fallback flags opt back into stored or alternate-source behavior, so keep the defaults when fresh VIES evidence is required.

Frequently asked questions

Is VIES free to use?

Yes. The European Commission's REST and SOAP VIES interfaces do not require an API key or per-request payment. Each check still depends on the relevant national VAT database. Handle documented service and concurrency errors as unavailable outcomes, and design bulk work so it does not amplify an outage.

Why is VIES returning false for a valid VAT number?

First confirm the prefix and local format, including letters that belong inside some national numbers. Then inspect userError and actionSucceed/errorWrappers before reading valid, because the REST service can carry a fault in an HTTP 200 response. A clean valid: false is a completed registration result. A service error is missing evidence and should be retried or reviewed separately.

Does VIES support UK VAT numbers?

VIES does not support GB-prefixed VAT numbers. GB checks moved to HMRC on January 1, 2021. XI-prefixed numbers used for Northern Ireland goods arrangements remain in VIES. A direct integration therefore routes GB to HMRC and XI to VIES, while Avatcado performs that routing behind one endpoint.

What is a VIES consultation number?

It is the requestIdentifier VIES can return when you send a genuine requester VAT registration with the target. Store it with the original request date, source and raw result. It supports evidence that a check happened, but is not guaranteed and does not determine tax treatment. In Avatcado, requester_vat_number disables cache reads and national register fallback by default unless you explicitly opt back in.

Sources

Related guides