How to Validate EU VAT Numbers: A Developer's Guide

How to validate EU VAT numbers in your application — from client-side format checks to live registry lookup, handling upstream unavailability, and building the audit trail your accountant will ask for.

What VAT number validation is and why it matters

Every EU-registered business receives a VAT identification number — for example IE6388047V for Google Ireland or DE134214316 for a German company. Validating that number means confirming two things: its format is correct for the country, and it is currently active in the EU VAT registry.

For developers building B2B SaaS or e-commerce platforms, this matters at the point of invoicing. When a VAT-registered business in one EU member state supplies services to a VAT-registered buyer in another, the transaction is typically subject to the reverse charge mechanism: the seller issues the invoice without VAT and the buyer self-assesses at their local rate. This is straightforwardly beneficial — but it requires the buyer to have a valid, active VAT number at the time the invoice is issued.

Under Council Directive 2018/1910 (Quick Fixes 2020), a valid VAT number is a substantive legal condition — not just an administrative formality — for applying the VAT exemption on intra-EU B2B supplies of goods. For cross-border B2B services, the buyer's VAT number is equally important in practice (it determines whether reverse charge applies), but the substantive-condition framing derives from a different basis under the VAT Directive. Either way, your integration needs to confirm validity at the time of invoicing and retain evidence of that confirmation.

Good practice is to validate at checkout or contract creation, then re-validate periodically for active subscriptions. VAT registrations can lapse.

Step 1: client-side format validation

Before making an API call, check whether the number matches the expected format for its country. This runs locally, costs nothing, and gives the user an immediate error message for obvious typos.

All EU VAT numbers start with the two-letter country prefix. Format rules vary by country:

CountryFormatExample
GermanyDE + 9 digitsDE134214316
FranceFR + 2 alphanumeric + 9 digitsFR40303265045
NetherlandsNL + 9 digits + B + 2 digitsNL123456789B01
PolandPL + 10 digitsPL5260001196
IrelandIE + 7 digits + 1–2 lettersIE6388047V
SpainES + 1 char + 7 digits + 1 charESA12345674
ItalyIT + 11 digitsIT00743110157
SwedenSE + 12 digitsSE556460819201

Here is a minimal client-side check in JavaScript. This runs before you make an API call — it catches clearly malformed input immediately:

JavaScript — client-side format check
// Per-country VAT format patterns (subset — all 27 EU member states)
const VAT_PATTERNS: Record<string, RegExp> = {
  AT: /^ATU\d{8}$/,
  BE: /^BE[01]\d{9}$/,
  DE: /^DE\d{9}$/,
  DK: /^DK\d{8}$/,
  ES: /^ES[A-Z0-9]\d{7}[A-Z0-9]$/,
  FR: /^FR[A-HJ-NP-Z0-9]{2}\d{9}$/,
  IE: /^IE\d{7}[A-W][A-IW]?$|^IE\d[A-Z+*]\d{5}[A-W]$/,
  IT: /^IT\d{11}$/,
  NL: /^NL\d{9}B\d{2}$/,
  PL: /^PL\d{10}$/,
  SE: /^SE\d{12}$/,
  // ... all 27 EU member states
}

function validateVatFormat(vatId: string): { valid: boolean; error?: string } {
  // Normalize: trim, uppercase, remove spaces and dashes
  const normalized = vatId.trim().toUpperCase().replace(/[\s\-.]/g, '')

  if (normalized.length < 4) {
    return { valid: false, error: 'VAT number is too short' }
  }

  const countryCode = normalized.slice(0, 2)
  const pattern = VAT_PATTERNS[countryCode]

  if (!pattern) {
    return { valid: false, error: `Unknown country prefix: ${countryCode}` }
  }

  if (!pattern.test(normalized)) {
    return { valid: false, error: `Invalid format for ${countryCode}. Expected e.g. ${countryCode}123456789` }
  }

  return { valid: true }
}

// Usage in a checkout form
const input = document.getElementById('vat-number') as HTMLInputElement
const check = validateVatFormat(input.value)
if (!check.valid) {
  showFieldError(check.error!) // show inline, do not block form submission yet
}

A format check alone is not sufficient for compliance. A correctly formatted number may belong to a company that has since deregistered. The next step is a live lookup.

Step 2: live EU VAT number validation via API

The vatnode API checks the EU VAT registry in real time and returns a structured JSON response — company name, registered address, legal form, industry code, current VAT rates for the buyer's country, and a unique checkId you can store as an audit record.

The endpoint accepts the full VAT number including country prefix. Whitespace, dashes, and mixed case are normalized automatically.

cURL
curl https://api.vatnode.dev/v1/vat/IE6388047V \
  -H "Authorization: Bearer vat_live_your_key_here"

Example response:

Response JSON
{
  "valid": true,
  "vatId": "IE6388047V",
  "countryCode": "IE",
  "countryName": "Ireland",
  "companyName": "GOOGLE IRELAND LIMITED",
  "companyAddress": "3RD FLOOR, 1 GRAND CANAL QUAY, DUBLIN 2",
  "companyRegistrationDate": null,
  "companyForm": null,
  "industryDescription": null,
  "registryCode": null,
  "registryCodeName": "CRO Number",
  "checkId": "01956b4a-1234-7abc-8def-0123456789ab",
  "verifiedAt": "2026-04-22T10:31:04.201Z",
  "consultationNumber": null,
  "countryVat": {
    "vatName": "Value Added Tax",
    "vatAbbr": "VAT",
    "currency": "EUR",
    "standardRate": 23,
    "reducedRates": [9, 13.5],
    "superReducedRate": 4.8,
    "parkingRate": 13.5,
    "vatNumberFormat": "IE + 7 digits + 1-2 letters",
    "countryVatUpdatedAt": "2026-04-22T07:00:00.000Z"
  },
  "source": "VIES"
}

Key fields to act on in your application:

  • validBoolean — true means the number is currently active in the EU registry. Apply reverse charge only when true.
  • checkIdUnique identifier for this validation event. Store this alongside your invoice record as your audit trail.
  • verifiedAtISO 8601 timestamp of the validation. Required alongside checkId for audit evidence.
  • consultationNumberEU Commission–issued proof of consultation. Present when your own VAT number is configured as a requester. Strongest form of audit evidence.
  • countryVat.standardRateCurrent VAT rate for the buyer's country — useful for displaying the correct rate on your invoice or pre-checkout summary.
Node.js / TypeScript — B2B checkout validation
interface VatCheckResult {
  valid: boolean
  vatId: string
  countryCode: string
  companyName: string | null
  checkId: string
  verifiedAt: string
  consultationNumber: string | null
  countryVat: {
    standardRate: number
    reducedRates: number[]
  }
}

interface VatCheckError {
  error: {
    code: 'INVALID_FORMAT' | 'RATE_LIMITED' | 'VIES_UNAVAILABLE' | 'VIES_ERROR'
    message: string
    requestId: string
  }
}

async function validateBuyerVat(vatId: string): Promise<VatCheckResult> {
  const response = await fetch(
    `https://api.vatnode.dev/v1/vat/${encodeURIComponent(vatId)}`,
    {
      headers: {
        Authorization: `Bearer ${process.env.VATNODE_API_KEY}`,
      },
    }
  )

  const data = (await response.json()) as VatCheckResult | VatCheckError

  if (!response.ok) {
    const err = data as VatCheckError
    throw Object.assign(new Error(err.error.message), { code: err.error.code })
  }

  return data as VatCheckResult
}

// B2B checkout handler
async function handleB2BCheckout(customerId: string, rawVatId: string) {
  try {
    const result = await validateBuyerVat(rawVatId)

    if (!result.valid) {
      // VAT number is known-invalid — do not apply reverse charge
      return {
        applyReverseCharge: false,
        vatCheckId: result.checkId,
        verifiedAt: result.verifiedAt,
        message: 'VAT number could not be verified. Standard VAT will apply.',
      }
    }

    // Store checkId + verifiedAt in your order/invoice record for audit
    await db.orders.update(customerId, {
      vatId: result.vatId,
      vatCheckId: result.checkId,
      vatVerifiedAt: result.verifiedAt,
      vatConsultationNumber: result.consultationNumber,
      companyName: result.companyName,
      applyReverseCharge: true,
    })

    return {
      applyReverseCharge: true,
      companyName: result.companyName,
      vatCheckId: result.checkId,
      verifiedAt: result.verifiedAt,
    }
  } catch (err: unknown) {
    const error = err as { code?: string; message: string }

    if (error.code === 'VIES_UNAVAILABLE') {
      // Upstream temporarily unavailable — see Section 3 for handling
      throw error
    }

    if (error.code === 'INVALID_FORMAT') {
      return {
        applyReverseCharge: false,
        message: 'Invalid VAT number format. Please check and try again.',
      }
    }

    // Unexpected error — log and surface a generic message
    console.error('VAT check failed unexpectedly:', error.message)
    throw error
  }
}

Step 3: handling upstream unavailability without blocking users

The EU VAT registry is a federated system: each member state runs its own national node. Country nodes do occasionally become temporarily unavailable during maintenance windows. When that happens, a naive integration would block checkout or onboarding — an unnecessary and expensive friction point for a customer who is almost certainly legitimate.

The vatnode API includes automatic national registry fallback via local tax authority and company registry APIs for a growing set of EU member states. When a VIES country node is temporarily unavailable, the API attempts the national registry for that country before surfacing an error. For countries without a national fallback adapter, the API returns a VIES_UNAVAILABLE error so your application can handle it explicitly.

Do not block checkout on VIES_UNAVAILABLE.The right pattern is to allow the transaction to proceed, queue the validation for async retry, and set the tax treatment conservatively (charge VAT) until the retry confirms the buyer's status. Most customers in this situation are legitimate EU businesses.

Here is a Python example showing the recommended handling pattern:

Python — VIES_UNAVAILABLE handling
import os
import httpx
from dataclasses import dataclass
from typing import Optional

VATNODE_API_KEY = os.environ["VATNODE_API_KEY"]


@dataclass
class VatResult:
    valid: bool
    vat_id: str
    company_name: Optional[str]
    check_id: str
    verified_at: str
    consultation_number: Optional[str]


class ViesUnavailableError(Exception):
    """VIES or the member-state node is temporarily unavailable."""
    pass


class InvalidVatFormatError(Exception):
    pass


def validate_vat(vat_id: str) -> VatResult:
    """
    Validate EU VAT number via vatnode REST API.
    Raises ViesUnavailableError if the upstream registry is temporarily down.
    Raises InvalidVatFormatError if the VAT number format is invalid.
    """
    with httpx.Client(timeout=15.0) as client:
        response = client.get(
            f"https://api.vatnode.dev/v1/vat/{vat_id}",
            headers={"Authorization": f"Bearer {VATNODE_API_KEY}"},
        )

    if response.status_code == 503:
        data = response.json()
        if data.get("error", {}).get("code") == "VIES_UNAVAILABLE":
            raise ViesUnavailableError(f"VIES unavailable for {vat_id}")

    if response.status_code == 400:
        data = response.json()
        raise InvalidVatFormatError(data.get("error", {}).get("message", "Invalid format"))

    response.raise_for_status()
    data = response.json()

    return VatResult(
        valid=data["valid"],
        vat_id=data["vatId"],
        company_name=data.get("companyName"),
        check_id=data["checkId"],
        verified_at=data["verifiedAt"],
        consultation_number=data.get("consultationNumber"),
    )


# In your checkout or onboarding handler:
def handle_b2b_customer(customer_id: str, vat_id: str) -> dict:
    try:
        result = validate_vat(vat_id)

        # Store audit record with the order/invoice
        db.orders.update(customer_id, {
            "vat_id": result.vat_id,
            "vat_check_id": result.check_id,
            "vat_verified_at": result.verified_at,
            "vat_consultation_number": result.consultation_number,
            "company_name": result.company_name,
            "apply_reverse_charge": result.valid,
        })

        return {"apply_reverse_charge": result.valid, "company_name": result.company_name}

    except ViesUnavailableError:
        # Do not block the transaction — queue for async retry
        job_queue.enqueue("retry_vat_check", customer_id=customer_id, vat_id=vat_id)
        # Conservative: charge VAT until confirmed
        return {"apply_reverse_charge": False, "pending_vat_check": True}

    except InvalidVatFormatError as e:
        return {"apply_reverse_charge": False, "error": str(e)}

Step 4: storing validation records for your audit trail

The validation record is not just useful to have — for EU B2B invoicing it is the evidence that the reverse charge was applied on the basis of a verified VAT status. EU tax authorities may request evidence that a VAT number was verified at the time of invoicing during an audit. The VIES consultation number (consultationNumber) is the EU Commission–issued audit reference — it demonstrates that a live VIES check was performed at a specific timestamp with your VAT identity as the requester.

At minimum, store these fields alongside every invoice you issue with reverse charge applied:

FieldWhat it isWhere to store it
checkIdUnique ID for this validation event (UUIDv7)Invoice record / order metadata
verifiedAtTimestamp the validation was performedInvoice record / order metadata
consultationNumberEU Commission consultation reference (when requester VAT configured)Invoice record — strongest audit evidence
vatIdCustomer's validated VAT number (normalized)Customer record + invoice
companyNameLegal name as returned by the registryInvoice recipient field

To receive a consultationNumber in every response, configure your own EU VAT number as the requester in the vatnode dashboard (Settings → Requester VAT). The API will then call the EU registry via the checkVatApprox operation, which causes the EU Commission to issue a reference number tied to your VAT identity. No changes to your integration code are required.

Alternatively, pass the requester inline per-request using query parameters:

cURL — with consultation number
# Supply your own VAT as requester to receive a consultation number
curl "https://api.vatnode.dev/v1/vat/IE6388047V?requesterCountryCode=DE&requesterVatNumber=134214316" \
  -H "Authorization: Bearer vat_live_your_key_here"

# Response includes:
# "consultationNumber": "WAPIAAAAWpgnLGnO"  ← EU Commission-issued audit reference

Coming from a direct VIES integration? The vatnode API is a REST JSON interface that works with any HTTP client — no protocol-specific libraries needed. See the migration guide for a side-by-side request comparison and what changes in your codebase.

Next steps

  • Quickstart guide — make your first API call in under 5 minutes.
  • Configure your requester VAT in the dashboard to unlock consultation numbers on every response.
  • Set up VAT monitoring subscriptions to be notified when a customer's VAT status changes — available from the Starter plan.
  • See the Stripe VAT validation guide for a full checkout integration pattern with webhook handling.

Ready to validate EU VAT numbers in your app?

Free plan includes 100 requests/month — enough to validate the full flow. No credit card required.