Home / Blog / VIES Downtime: How to Build a Resilient VAT Validation Flow

Tutorial12 min read

VIES Downtime: How to Build a Resilient VAT Validation Flow

VIES has scheduled and unscheduled maintenance windows. An API that hard-fails when VIES is down will block legitimate customers with valid numbers. Here is how to build a resilient flow.


VIES — the EU's official VAT Information Exchange System — is the only authoritative source for validating EU VAT numbers in real time. It is also a SOAP service built on early-2000s infrastructure that goes offline several times per month, sometimes for hours at a time. Every production checkout or onboarding flow that validates EU VAT numbers will eventually receive a service_unavailable response. How you handle it determines whether legitimate customers with valid VAT numbers get blocked or sail through. This post covers every strategy in detail, with production-ready code for Express.js, background re-validation jobs, circuit breakers, and test patterns.

Why VIES Goes Down: The Architecture Behind the Outages

VIES is not a single monolithic database. It is a federation of 27 separate national tax authority systems connected through a central routing gateway operated by the European Commission. When you submit a validation request, VIES routes it to the national system of the target country — Germany's Bundeszentralamt für Steuern (BZST), France's DGFiP, Spain's AEAT, and so on. Each national system is maintained by a different government agency on its own infrastructure and maintenance schedule.

This federated architecture means VIES can be unavailable in several distinct ways. The central gateway itself can be down — this makes all 27 countries unreachable simultaneously. Individual member state systems can be down — Germany might be unavailable while France works fine. A specific country can return errors for some number prefixes but not others. And the gateway can be reachable but extremely slow, causing timeouts that look like outages from the client side.

Scheduled maintenance is the most predictable form of downtime. Germany typically takes BZST offline for several hours on Sunday mornings (Central European Time). France has quarterly DGFiP maintenance windows that are announced internally but not publicised externally. The VIES central gateway has its own maintenance schedule. Unscheduled outages — caused by infrastructure failures, DDoS events, or unexpected load spikes during tax season — are harder to predict and can last from minutes to several hours.

Member StateCommon Downtime PatternImpact When DownNotes
Germany (DE)Sunday morning BZST maintenanceAll DE numbers return service_unavailableMost common scheduled downtime
France (FR)Quarterly DGFiP releasesFR numbers unavailable for 30-120 minUsually announced on ec.europa.eu
Italy (IT)Agenzia delle Entrate peaksSlow responses (2-4s) before timeoutOften during Italian business hours
Netherlands (NL)Belastingdienst quarterlyNL numbers unavailableFairly reliable otherwise
Spain (ES)AEAT maintenance windowsES numbers unavailableMid-week evening windows
VIES GatewayEC Commission releasesAll 27 countries unreachableRare but affects everyone
Small member statesIrregular, unannouncedVariable country unavailableMT, CY, LU most common

Warning

A hard-fail on service_unavailable blocks a legitimate customer with a perfectly valid VAT number because their government's tax authority happened to be doing weekend maintenance. This is a UX failure, an audit risk (you have no re-validation record), and a revenue problem. Build the fallback from day one.

The service_unavailable Response: What It Means and What It Does Not

The TaxID API returns a structured service_unavailable status when VIES cannot be reached for the requested country, rather than a generic 500 error or a timeout. This explicit status code is the key to building correct resilience logic. The response looks identical in structure to a successful validation — same fields, same format — but with status: 'service_unavailable' and valid: false. Critically, valid: false here does not mean the VAT number is invalid. It means 'we could not determine validity at this time'.

json
// service_unavailable — do NOT treat this as invalid
{
  "valid": false,
  "status": "service_unavailable",
  "vat": "DE123456789",
  "country_code": "DE",
  "company_name": null,
  "address": null,
  "cached": false,
  "request_id": "req_01j9kx..."
}

// genuine invalid — VAT number not registered
{
  "valid": false,
  "status": "inactive",
  "vat": "DE000000000",
  "country_code": "DE",
  "company_name": null,
  "address": null,
  "cached": false,
  "request_id": "req_01j9ky..."
}

The difference between these two responses should drive entirely different code paths in your application. An inactive number means the business is not registered — block the transaction and ask the customer to correct their VAT number. A service_unavailable means you cannot check right now — allow the transaction, store the number, and re-validate later. If your code only checks the valid boolean, you will conflate these two very different situations and block legitimate customers every time Germany does maintenance.

Note also the cached field. The TaxID API caches validated active numbers in Redis for 24 hours. If a German customer was validated successfully 3 hours ago and VIES goes down, the next validation request for the same number returns cached: true with status: active — no service_unavailable, because the response comes from cache, not from VIES. The service_unavailable case only arises for numbers that have not been recently validated. This is why validating at signup (not just at payment) is a useful resilience strategy — it warms the cache for the payment flow.

Strategy 1: Allow and Re-validate Async (Recommended for SaaS)

The most robust approach for SaaS billing systems and subscription platforms: when you receive service_unavailable, allow the transaction to proceed, store the VAT number with a pending_revalidation flag, and run a background job to re-validate once VIES recovers. If the eventual re-validation returns invalid, you can then switch the customer to B2C billing for the next invoice and notify them. This pattern handles downtime completely transparently for the customer and maintains a clean audit trail.

The implementation has two parts: the checkout handler that catches service_unavailable and allows through, and the background job that processes the re-validation queue. The checkout handler should be written to create a complete audit record even for unavailable responses — you need to record that you attempted validation at a specific time, received service_unavailable, and queued the number for re-validation. That record demonstrates due diligence even if VIES was down.

typescriptvat-checkout.ts
type VatOutcome = 'valid' | 'invalid' | 'unavailable';

export async function checkVatAtCheckout(
  vatNumber: string,
  customerId: string,
  invoiceId: string
): Promise<VatOutcome> {
  const country = vatNumber.slice(0, 2).toUpperCase();

  const res = await fetch(
    `https://taxid.dev/api/v1/validate/${country}/${vatNumber}`,
    {
      headers: { Authorization: `Bearer ${process.env.TAXID_API_KEY}` },
      signal: AbortSignal.timeout(5000),
    }
  );
  const data = await res.json();

  // Always record the attempt for audit purposes
  await db.vatAuditLog.create({
    data: {
      customerId, invoiceId,
      vatNumber,
      status: data.status,
      requestId: data.request_id,
      checkedAt: new Date(),
    },
  });

  if (data.status === 'service_unavailable') {
    await db.vatPendingRevalidation.upsert({
      where: { customerId },
      update: { vatNumber, retryAfter: new Date(Date.now() + 60 * 60_000), attempts: { increment: 1 } },
      create: { customerId, vatNumber, retryAfter: new Date(Date.now() + 60 * 60_000), attempts: 1 },
    });
    return 'unavailable';
  }

  if (data.valid) {
    await db.customer.update({
      where: { id: customerId },
      data: { vatNumber, vatStatus: 'active', companyName: data.company_name, vatValidatedAt: new Date() },
    });
    return 'valid';
  }

  return 'invalid';
}

Strategy 2: Use the Cached Result

TaxID caches validated active VAT numbers in Upstash Redis for 24 hours. If a customer's VAT number was confirmed active 4 hours ago and VIES goes down 10 minutes before they complete checkout, the API returns the cached active result — status: active, valid: true, cached: true. From your application's perspective, the request succeeded normally. The service_unavailable case never surfaces.

You can leverage this by validating VAT numbers eagerly — at the time the customer enters them, not only at payment. In a typical SaaS signup flow, the customer enters their VAT number on the billing details step. Validate it immediately via an API call and display the result (company name, address) as a confirmation. This warms the cache so that by the time payment is processed — potentially minutes or hours later — the result is served from cache regardless of VIES status. Even if the customer comes back the next day, 24 hours of cache coverage means most repeat checkouts will be unaffected by downtime.

Tip

Validate eagerly at input time, not just at payment. An immediate validation call when the customer types their VAT number improves UX (shows company name as confirmation), warms the Redis cache for the payment flow, and reduces the window where downtime can affect the checkout.

Strategy 3: Soft Error UI

For low-volume workflows, manual order processes, or B2B sales with longer payment cycles, you can surface the unavailability directly to the customer: 'EU VAT validation is temporarily unavailable due to an EU system issue. Your VAT number has been saved and will be verified automatically. You can complete your order now — we will apply the correct tax treatment once validation is confirmed.' This approach is honest, non-blocking, and maintains an explicit audit trail.

The soft error approach is most appropriate when (a) you have a human review step before finalising invoices, (b) your order volume is low enough that a delayed tax classification does not create operational complexity, or (c) you are implementing validation for an internal tool rather than a customer-facing checkout. For high-volume automated billing, the allow-and-re-validate async strategy is more appropriate.

Production Express.js Middleware with Timeout

A production VAT validation middleware needs to handle not just service_unavailable but also network errors and timeouts. VIES sometimes responds slowly enough to trigger connection timeouts even when it is nominally up. Treat timeouts and network errors the same as service_unavailable — allow through and queue for re-validation.

typescriptvat-middleware.ts
import { Request, Response, NextFunction } from 'express';

declare global {
  namespace Express {
    interface Request {
      vatStatus?: 'valid' | 'pending' | 'invalid';
      vatCompanyName?: string | null;
    }
  }
}

export async function vatValidationMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  const { vatNumber } = req.body;
  if (!vatNumber) return next(); // B2C — no VAT number, skip

  const country = String(vatNumber).slice(0, 2).toUpperCase();

  try {
    const apiRes = await fetch(
      `https://taxid.dev/api/v1/validate/${country}/${vatNumber}`,
      {
        headers: { Authorization: `Bearer ${process.env.TAXID_API_KEY}` },
        signal: AbortSignal.timeout(4000), // Hard 4s timeout
      }
    );

    if (!apiRes.ok) throw new Error(`HTTP ${apiRes.status}`);
    const data = await apiRes.json();

    if (data.status === 'service_unavailable') {
      req.vatStatus = 'pending';
      return next();
    }

    if (!data.valid) {
      return res.status(422).json({
        error: 'invalid_vat_number',
        message: 'VAT number is not registered or has been deactivated.',
      });
    }

    req.vatStatus = 'valid';
    req.vatCompanyName = data.company_name;
    next();

  } catch {
    // Network error or timeout — treat as service_unavailable
    req.vatStatus = 'pending';
    next();
  }
}

Circuit Breaker Pattern for High-Volume APIs

If you are processing hundreds of VAT validations per minute and VIES goes down, continuing to make outbound API calls during an outage wastes resources and adds latency to every request. A circuit breaker pattern solves this: after a threshold of consecutive failures, the circuit 'opens' and subsequent calls fail immediately (returning service_unavailable) without making the outbound call. After a cooldown period, it enters a 'half-open' state and allows one probe request. If that succeeds, the circuit closes and normal operation resumes.

typescriptvat-circuit-breaker.ts
type CircuitState = 'closed' | 'open' | 'half-open';

const circuit = {
  state: 'closed' as CircuitState,
  failures: 0,
  lastFailureAt: 0,
  THRESHOLD: 5,        // open after 5 consecutive failures
  COOLDOWN_MS: 60_000, // try again after 60 seconds
};

export async function validateWithCircuitBreaker(
  country: string,
  vat: string
): Promise<{ valid: boolean; status: string }> {
  const now = Date.now();

  if (circuit.state === 'open') {
    if (now - circuit.lastFailureAt < circuit.COOLDOWN_MS) {
      // Circuit open — fail fast without API call
      return { valid: false, status: 'service_unavailable' };
    }
    circuit.state = 'half-open';
  }

  try {
    const res = await fetch(
      `https://taxid.dev/api/v1/validate/${country}/${vat}`,
      {
        headers: { Authorization: `Bearer ${process.env.TAXID_API_KEY}` },
        signal: AbortSignal.timeout(3000),
      }
    );
    const data = await res.json();

    if (data.status === 'service_unavailable') {
      circuit.failures++;
      if (circuit.failures >= circuit.THRESHOLD) {
        circuit.state = 'open';
        circuit.lastFailureAt = now;
      }
    } else {
      // Success — reset circuit
      circuit.failures = 0;
      circuit.state = 'closed';
    }

    return data;
  } catch {
    circuit.failures++;
    circuit.lastFailureAt = now;
    if (circuit.failures >= circuit.THRESHOLD) circuit.state = 'open';
    return { valid: false, status: 'service_unavailable' };
  }
}

Background Re-validation Job

The allow-and-re-validate strategy requires a background job that processes the pending queue once VIES recovers. Run it on a cron schedule — every 30 minutes is a good starting point. Implement exponential back-off for persistent failures: if a number fails re-validation because VIES is still down, double the retry interval up to a maximum of 4 hours.

typescriptrevalidate-job.ts
// Run on cron: every 30 minutes
export async function revalidatePendingVat() {
  const pending = await db.vatPendingRevalidation.findMany({
    where: { retryAfter: { lte: new Date() }, resolvedAt: null },
    orderBy: { attempts: 'asc' },
    take: 200,
  });

  for (const record of pending) {
    const country = record.vatNumber.slice(0, 2).toUpperCase();

    try {
      const res = await fetch(
        `https://taxid.dev/api/v1/validate/${country}/${record.vatNumber}`,
        { headers: { Authorization: `Bearer ${process.env.TAXID_API_KEY}` } }
      );
      const data = await res.json();

      if (data.status === 'service_unavailable') {
        // Exponential back-off: 1h → 2h → 4h → 4h max
        const backoffHours = Math.min(Math.pow(2, record.attempts), 4);
        await db.vatPendingRevalidation.update({
          where: { id: record.id },
          data: {
            retryAfter: new Date(Date.now() + backoffHours * 60 * 60_000),
            attempts: { increment: 1 },
          },
        });
        continue;
      }

      await db.vatPendingRevalidation.update({
        where: { id: record.id },
        data: { resolvedAt: new Date(), resolvedValid: data.valid },
      });

      if (!data.valid) {
        // Switch to B2C billing and notify customer
        await handleInvalidVatDiscovered(record.customerId, record.vatNumber);
      } else {
        // Confirm B2B status
        await db.customer.update({
          where: { id: record.customerId },
          data: { vatStatus: 'active', vatValidatedAt: new Date() },
        });
      }
    } catch {
      // Skip — will retry next cron run
    }
  }
}

Testing Your Downtime Handling

The most important invariant to test is: your checkout must not block when VAT validation returns service_unavailable. Mock the API at the fetch level with Vitest or Jest to simulate all four response states: active, inactive, format_invalid, and service_unavailable. Verify that each state triggers the correct business logic path.

typescriptvat-checkout.test.ts
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { checkVatAtCheckout } from './vat-checkout';

const mockFetch = vi.spyOn(global, 'fetch');

const makeResponse = (data: object) =>
  Promise.resolve({ ok: true, json: async () => data } as Response);

describe('checkVatAtCheckout', () => {
  beforeEach(() => vi.clearAllMocks());

  it('returns valid for an active VAT number', async () => {
    mockFetch.mockImplementationOnce(() =>
      makeResponse({ valid: true, status: 'active', company_name: 'Acme GmbH',
                     address: 'Berlin', cached: false, request_id: 'r1' })
    );
    expect(await checkVatAtCheckout('DE123456789', 'cus_1', 'inv_1')).toBe('valid');
  });

  it('returns invalid for an inactive number', async () => {
    mockFetch.mockImplementationOnce(() =>
      makeResponse({ valid: false, status: 'inactive', company_name: null,
                     address: null, cached: false, request_id: 'r2' })
    );
    expect(await checkVatAtCheckout('DE000000000', 'cus_2', 'inv_2')).toBe('invalid');
  });

  it('returns unavailable — NOT invalid — when VIES is down', async () => {
    mockFetch.mockImplementationOnce(() =>
      makeResponse({ valid: false, status: 'service_unavailable', company_name: null,
                     address: null, cached: false, request_id: 'r3' })
    );
    const result = await checkVatAtCheckout('DE123456789', 'cus_3', 'inv_3');
    expect(result).toBe('unavailable'); // Must NOT be 'invalid'

    // Assert re-validation was queued
    const pending = await db.vatPendingRevalidation.findFirst(
      { where: { customerId: 'cus_3' } }
    );
    expect(pending).not.toBeNull();
  });

  it('treats network timeout as service_unavailable', async () => {
    mockFetch.mockRejectedValueOnce(new DOMException('The operation was aborted', 'AbortError'));
    expect(await checkVatAtCheckout('DE123456789', 'cus_4', 'inv_4')).toBe('unavailable');
  });
});

Monitoring: What to Track and When to Alert

VIES downtime monitoring gives you visibility into which member states are having problems, how often your customers are affected, and whether your re-validation queue is clearing correctly. Track these metrics in your application monitoring (Datadog, Grafana, or even a simple Postgres table with a daily cron query).

Timeout Configuration: How Long to Wait for VIES

Timeout configuration is a critical and often overlooked part of VIES resilience. Set your timeout too high and a slow VIES response blocks your checkout for 10+ seconds — long enough that most users abandon the page. Set it too low and you trigger unnecessary service_unavailable responses for countries (like Italy) that are genuinely slow but functional. The right balance depends on your use case.

For synchronous checkout flows — where the user is waiting — a 4-second timeout is the practical maximum. Beyond 4 seconds, user experience degrades significantly and cart abandonment rises. For asynchronous flows — background re-validation jobs or server-side order processing where the user is not waiting — you can afford 8-10 seconds, which catches most slow-but-functional VIES responses and reduces false service_unavailable reports.

Connect timeout (time to establish the TCP connection to the TaxID API) and read timeout (time to receive the full response body) should be configured separately where your HTTP client supports it. A 2-second connect timeout with a 4-second read timeout is a sensible starting point. The TaxID API itself has its own internal timeout for the VIES SOAP call — if VIES does not respond within the internal limit, the API returns service_unavailable rather than leaving your connection hanging indefinitely.

VIES Scheduled Maintenance: When to Expect Downtime

Scheduled VIES maintenance is announced on the European Commission's taxation portal (ec.europa.eu/taxation_customs/vies), but these announcements are often posted with little advance notice and may not cover all member state maintenance windows. The most reliable way to stay informed is to monitor your own service_unavailable rate and correlate spikes with the day and time.

Based on historical patterns, these windows carry elevated downtime risk: Sunday mornings between 01:00-07:00 CET (German BZST maintenance); last Sunday of each quarter, 02:00-06:00 CET (French DGFiP quarterly releases); first Tuesday of each month, 22:00-02:00 CET (VIES gateway maintenance); major EU public holidays when staff are unavailable to respond to incidents. None of these windows are guaranteed — maintenance can be cancelled, extended, or rescheduled without notice.

The practical takeaway: if you are planning to onboard a large batch of B2B customers (CSV import, bulk migration, or a marketing campaign that will drive signups), schedule it for Tuesday through Thursday during business hours in the target member state's timezone. Avoid Sunday morning CET for German-heavy customer bases. And always have your re-validation queue running so that customers who hit a downtime window during signup are automatically validated once VIES recovers — no manual intervention required.

Start validating EU VAT numbers

Free plan — 100 validations/month. No credit card required.