EU VAT Number Validation in Node.js

A complete guide to validating EU VAT numbers in Node.js and TypeScript using the vatnode REST API. Covers basic validation, TypeScript types, checkout flow integration, and VIES error handling.

Why validate EU VAT numbers in Node.js?

If your Node.js application sells to EU businesses (B2B), you need to validate customer VAT numbers before issuing invoices. A valid VAT number in VIES (the EU's official VAT database) lets you apply the reverse charge mechanism — charging zero VAT on cross-border B2B supplies. Applying reverse charge without VIES confirmation is a compliance risk.

The official VIES API uses SOAP — a legacy XML protocol that requires separate libraries and significant boilerplate. The vatnode API wraps VIES in a clean REST interface. One fetch() call, structured JSON response.

Quick start

No package installation needed. The vatnode API works with the built-in fetch in Node.js 18+ (or any HTTP library):

TypeScript — basic validation
const VATNODE_API_KEY = process.env.VATNODE_API_KEY!

async function validateVatNumber(vatId: string) {
  const response = await fetch(
    `https://api.vatnode.dev/v1/vat/${encodeURIComponent(vatId)}`,
    {
      headers: { Authorization: `Bearer ${VATNODE_API_KEY}` },
    }
  )

  if (!response.ok) {
    const err = await response.json()
    throw new Error(err.error?.code ?? `HTTP ${response.status}`)
  }

  return await response.json()
}

// Usage
const result = await validateVatNumber('IE6388047V')
console.log(result.valid)         // true
console.log(result.companyName)   // "GOOGLE IRELAND LIMITED"
console.log(result.checkId)       // "019d2a89-..." — store for audit trail

TypeScript response types

Add these interfaces to get full type safety on the API response:

TypeScript interfaces
interface CountryVat {
  vatName: string          // e.g. "Umsatzsteuer", "TVA", "ALV"
  vatAbbr: string          // e.g. "MwSt", "TVA", "VAT"
  currency: string         // ISO 4217, e.g. "EUR", "PLN", "HUF"
  standardRate: number
  reducedRates: number[]
  superReducedRate: number | null
  parkingRate: number | null
  ratesUpdatedAt: string
}

interface VatResult {
  valid: boolean
  vatId: string
  countryCode: string
  countryName: string
  companyName: string | null
  companyAddress: string | null
  source: 'VIES' | 'BZST'
  checkId: string          // UUID — store on your invoice
  verifiedAt: string       // ISO 8601 timestamp
  countryVat: CountryVat
}

interface VatError {
  error: {
    code: string           // e.g. "VIES_UNAVAILABLE", "INVALID_FORMAT"
    message: string
  }
}

Handle VIES errors gracefully

VIES national nodes go offline for maintenance. Your application must handle VIES_UNAVAILABLE without blocking the user:

TypeScript — error handling
async function validateVatSafe(vatId: string): Promise<{
  valid: boolean | null  // null = VIES unavailable, retry later
  checkId: string | null
  error?: string
}> {
  try {
    const res = await fetch(
      `https://api.vatnode.dev/v1/vat/${encodeURIComponent(vatId)}`,
      { headers: { Authorization: `Bearer ${VATNODE_API_KEY}` } }
    )

    if (res.status === 503) {
      // VIES temporarily unavailable — queue for retry
      return { valid: null, checkId: null, error: 'VIES_UNAVAILABLE' }
    }

    if (res.status === 400) {
      // Invalid VAT number format
      return { valid: false, checkId: null, error: 'INVALID_FORMAT' }
    }

    const data: VatResult = await res.json()
    return { valid: data.valid, checkId: data.checkId }
  } catch {
    // Network error — treat same as VIES unavailable
    return { valid: null, checkId: null, error: 'NETWORK_ERROR' }
  }
}

// In your checkout handler
const vat = await validateVatSafe(customerVatId)

if (vat.error === 'VIES_UNAVAILABLE') {
  // Queue for async retry — do not block checkout
  await queue.add('validate-vat', { vatId: customerVatId, customerId })
  // Apply standard VAT in the meantime
} else if (vat.valid) {
  // Apply reverse charge, store vat.checkId on the invoice
} else {
  // Invalid VAT — charge standard VAT rate
}

Full checkout flow example

A complete example for validating a VAT number during B2B checkout and deciding whether to apply reverse charge:

TypeScript — checkout integration
// POST /api/checkout — Next.js Route Handler or Express endpoint
export async function POST(req: Request) {
  const { vatId, amount, currency } = await req.json()

  let vatResult = null
  let applyReverseCharge = false

  if (vatId) {
    const res = await fetch(
      `https://api.vatnode.dev/v1/vat/${encodeURIComponent(vatId)}`,
      { headers: { Authorization: `Bearer ${process.env.VATNODE_API_KEY}` } }
    )

    if (res.ok) {
      vatResult = await res.json() as VatResult
      applyReverseCharge = vatResult.valid
    }
    // If 503, treat as unverified — charge VAT
  }

  const invoice = {
    lineItems: [{ amount, currency }],
    vatRate: applyReverseCharge ? 0 : getLocalVatRate(currency),
    reverseChargeNote: applyReverseCharge
      ? 'Reverse charge — VAT to be accounted for by the recipient'
      : undefined,
    vatCheckId: vatResult?.checkId ?? null,   // audit trail
    vatVerifiedAt: vatResult?.verifiedAt ?? null,
  }

  return Response.json({ invoice })
}

function getLocalVatRate(currency: string): number {
  // Your local VAT rate logic
  return 0.23
}

Ready to add VAT validation to your Node.js app?

Free plan: 20 requests/month, no credit card required. Up and running in under 5 minutes.