Validate EU VAT Numbers in Stripe Checkout

Stripe collects customer VAT IDs but does not validate them against VIES. This guide shows how to wire vatnode into your Stripe integration to verify EU VAT numbers and apply reverse charge correctly.

The problem: Stripe collects VAT IDs, but does not validate them

Stripe's tax ID collection feature lets you gather customer VAT numbers at checkout. Stripe does basic format checking — it will reject INVALID123 — but it does not query VIES to confirm the number is actually registered and active.

This matters for EU B2B invoicing. To apply the reverse charge mechanism (zero VAT on cross-border B2B supplies), you need proof that the customer's VAT number was valid in VIES at the time of invoicing. Applying zero VAT to an invalid or lapsed registration exposes you to back-taxes, interest, and penalties from your local tax authority.

The solution: validate the VAT number via VIES using the vatnode API immediately after Stripe collects it, then update the customer's tax treatment accordingly.

Integration architecture

The recommended approach uses Stripe webhooks. When a checkout session completes or a customer updates their tax ID in the billing portal, Stripe fires an event. Your webhook handler validates the VAT number via vatnode and updates the Stripe customer's tax_exempt status:

  • 1Valid VAT number in VIES → set tax_exempt: "reverse" (reverse charge applies)
  • 2Invalid or not found in VIES → set tax_exempt: "none" (charge local VAT)
  • 3VIES temporarily unavailable → queue for retry, do not block the transaction

Node.js / TypeScript example

This handler listens for checkout.session.completed and customer.tax_id.created events, validates the VAT number, and updates the Stripe customer accordingly.

Node.js / TypeScript — webhook handler
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

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

  if (res.status === 503) {
    // VIES temporarily unavailable — queue for retry
    throw new Error('VIES_UNAVAILABLE')
  }

  if (!res.ok) {
    throw new Error(`vatnode error: ${res.status}`)
  }

  return await res.json()
}

// Express / Next.js route handler
export async function POST(req: Request) {
  const sig = req.headers.get('stripe-signature')!
  const body = await req.text()

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch {
    return new Response('Invalid signature', { status: 400 })
  }

  if (
    event.type === 'checkout.session.completed' ||
    event.type === 'customer.tax_id.created'
  ) {
    const customerId =
      event.type === 'checkout.session.completed'
        ? (event.data.object as Stripe.Checkout.Session).customer as string
        : (event.data.object as Stripe.TaxId).customer as string

    // Get the customer's tax IDs from Stripe
    const taxIds = await stripe.customers.listTaxIds(customerId)
    const euVatId = taxIds.data.find(
      (t) => t.type.startsWith('eu_vat') || t.type === 'eu_oss_vat'
    )

    if (!euVatId?.value) {
      return new Response('No EU VAT ID', { status: 200 })
    }

    try {
      const result = await validateVatViaVatnode(euVatId.value)

      await stripe.customers.update(customerId, {
        tax_exempt: result.valid ? 'reverse' : 'none',
        metadata: {
          vat_check_id: result.checkId,
          vat_verified_at: result.verifiedAt,
          vat_valid: String(result.valid),
        },
      })
    } catch (err: unknown) {
      if (err instanceof Error && err.message === 'VIES_UNAVAILABLE') {
        // Queue for retry — do not fail the webhook
        console.warn('VIES unavailable, queuing VAT check for retry')
        // Add to your job queue here (e.g. BullMQ, Inngest, etc.)
      }
      // Return 200 so Stripe does not retry the webhook
    }
  }

  return new Response('OK', { status: 200 })
}

Python example

The same pattern in Python using httpx and Flask:

Python — Flask webhook handler
import os
import stripe
import httpx
from flask import Flask, request, jsonify

app = Flask(__name__)
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
VATNODE_API_KEY = os.environ["VATNODE_API_KEY"]


def validate_vat(vat_id: str) -> dict:
    """Validate EU VAT number via vatnode. Raises on VIES unavailability."""
    response = httpx.get(
        f"https://api.vatnode.dev/v1/vat/{vat_id}",
        headers={"Authorization": f"Bearer {VATNODE_API_KEY}"},
        timeout=10.0,
    )
    if response.status_code == 503:
        raise RuntimeError("VIES_UNAVAILABLE")
    response.raise_for_status()
    return response.json()


@app.route("/webhooks/stripe", methods=["POST"])
def stripe_webhook():
    sig = request.headers.get("Stripe-Signature")
    try:
        event = stripe.Webhook.construct_event(
            request.data, sig, os.environ["STRIPE_WEBHOOK_SECRET"]
        )
    except stripe.error.SignatureVerificationError:
        return jsonify(error="Invalid signature"), 400

    if event["type"] in ("checkout.session.completed", "customer.tax_id.created"):
        if event["type"] == "checkout.session.completed":
            customer_id = event["data"]["object"]["customer"]
        else:
            customer_id = event["data"]["object"]["customer"]

        tax_ids = stripe.Customer.list_tax_ids(customer_id)
        eu_vat = next(
            (t for t in tax_ids["data"] if t["type"].startswith("eu_vat")), None
        )

        if eu_vat and eu_vat.get("value"):
            try:
                result = validate_vat(eu_vat["value"])
                stripe.Customer.modify(
                    customer_id,
                    tax_exempt="reverse" if result["valid"] else "none",
                    metadata={
                        "vat_check_id": result["checkId"],
                        "vat_verified_at": result["verifiedAt"],
                        "vat_valid": str(result["valid"]).lower(),
                    },
                )
            except RuntimeError as e:
                if str(e) == "VIES_UNAVAILABLE":
                    # Queue for retry — do not fail the webhook
                    print(f"VIES unavailable for {eu_vat['value']}, queuing retry")

    return jsonify(ok=True)

What to show the customer if the VAT number is invalid

When valid is false, the customer likely entered the number incorrectly or their VAT registration has lapsed. Avoid blocking the checkout — instead, inform them and charge VAT:

  • Show a non-blocking warning: “Your VAT number could not be verified in VIES. Standard VAT will apply to this invoice. You can update your VAT ID later in the billing portal.”
  • Set tax_exempt to none and charge at the applicable VAT rate.
  • Offer a grace period: let the customer re-submit their VAT number and re-validate via the billing portal.

Do not block checkout on VIES unavailability. VIES national nodes go down for maintenance. If vatnode returns VIES_UNAVAILABLE, allow the transaction to proceed and re-validate asynchronously. Most customers are legitimate.

Keep VAT data fresh with vatnode monitoring

A customer's VAT registration can lapse after checkout. vatnode's monitoring feature watches any VAT ID you subscribe to and fires a webhook when anything changes:

VAT_BECAME_INVALID

Stop applying reverse charge on the next invoice immediately

VAT_BECAME_VALID

Reactivate B2B reverse charge pricing for a returning customer

COMPANY_NAME_CHANGED

Update your records — the buyer's legal name changed

COMPANY_ADDRESS_CHANGED

Update registered address for compliance

Monitoring is available from the Starter plan (€19/month). Subscribe a customer's VAT ID via the vatnode API after validation:

Subscribe to monitoring
curl -X POST https://api.vatnode.dev/v1/monitors \
  -H "Authorization: Bearer $VATNODE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"vatId": "IE6388047V"}'

Add VIES validation to your Stripe integration

Free plan includes 20 requests/month — enough to test the full flow. No credit card required to start.