Skip to main content

Command Palette

Search for a command to run...

Add EU VAT validation to your Stripe checkout in 10 minutes

Step-by-step tutorial: validate VAT, apply reverse charge, store proof for audit.

Updated
7 min read

Add EU VAT Validation to Your Stripe Checkout in 10 Minutes

If you sell B2B in the European Union, you are required to validate your customer's VAT number before applying the reverse charge mechanism. This is not optional. The EU VAT Directive mandates that you verify the number is valid and active before zero-rating an intra-Community supply.

Most Stripe integrations skip this step or do it incorrectly. The result: either you charge VAT when you should not (your customer complains) or you do not charge VAT when you should (your tax authority complains). Both are bad.

This guide shows you how to add proper EU VAT validation to a Stripe checkout flow using EuroValidate. We will cover validation, reverse charge logic, and audit trail storage. Python and Node.js examples included.

What you need

  • A Stripe account with a PaymentIntent-based checkout
  • An EuroValidate API key (free tier: 100 calls/month) -- get one at eurovalidate.com
  • Basic understanding of EU reverse charge rules

How EU reverse charge works (30-second version)

When a VAT-registered business in EU Country A sells to a VAT-registered business in EU Country B, the seller does not charge VAT. Instead, the buyer self-assesses VAT in their own country. This is the reverse charge mechanism.

To apply reverse charge, you must:

  1. Confirm the buyer's VAT number is valid and active
  2. Confirm the buyer is in a different EU member state than you
  3. Keep proof of validation (timestamp, result, confidence level) for your records

If validation fails or the buyer is in the same country as you, charge the standard VAT rate.

Step 1: Install the SDK

# Python
pip install eurovalidate

# Node.js
npm install @eurovalidate/sdk

Or call the API directly -- no SDK required:

curl -H "X-API-Key: your_api_key" \
  https://api.eurovalidate.com/v1/vat/NL820646660B01

Step 2: Validate the VAT number before payment

The VAT number should be validated before you create the Stripe PaymentIntent. This way you know the correct amount to charge.

Python

import eurovalidate
import stripe

eurovalidate.api_key = "ev_live_your_key"
stripe.api_key = "sk_live_your_key"

YOUR_COUNTRY = "PT"  # Your business is in Portugal
STANDARD_VAT_RATE = 0.23  # Portuguese standard rate

async def create_checkout(customer_vat: str, amount_cents: int, currency: str = "eur"):
    """
    Validate VAT, apply reverse charge if eligible, create PaymentIntent.
    """
    vat_result = None
    apply_reverse_charge = False

    if customer_vat:
        # Validate the customer's VAT number
        vat_result = eurovalidate.vat.validate(customer_vat)

        if vat_result.data.valid and vat_result.meta.confidence in ("HIGH", "MEDIUM"):
            customer_country = vat_result.data.country_code
            # Reverse charge: valid VAT + different EU country
            if customer_country != YOUR_COUNTRY:
                apply_reverse_charge = True

    # Calculate final amount
    if apply_reverse_charge:
        total_cents = amount_cents  # No VAT added
        vat_cents = 0
    else:
        vat_cents = int(amount_cents * STANDARD_VAT_RATE)
        total_cents = amount_cents + vat_cents

    # Create the Stripe PaymentIntent
    intent = stripe.PaymentIntent.create(
        amount=total_cents,
        currency=currency,
        metadata={
            "vat_number": customer_vat or "",
            "vat_valid": str(vat_result.data.valid) if vat_result else "not_provided",
            "vat_confidence": vat_result.meta.confidence if vat_result else "N/A",
            "reverse_charge": str(apply_reverse_charge),
            "vat_amount_cents": str(vat_cents),
            "validation_request_id": vat_result.request_id if vat_result else "",
        },
    )

    return {
        "client_secret": intent.client_secret,
        "total_cents": total_cents,
        "vat_cents": vat_cents,
        "reverse_charge": apply_reverse_charge,
    }

Node.js

const EuroValidate = require("@eurovalidate/sdk");
const Stripe = require("stripe");

const ev = new EuroValidate("ev_live_your_key");
const stripe = new Stripe("sk_live_your_key");

const YOUR_COUNTRY = "PT";
const STANDARD_VAT_RATE = 0.23;

async function createCheckout(customerVat, amountCents, currency = "eur") {
  let vatResult = null;
  let applyReverseCharge = false;

  if (customerVat) {
    vatResult = await ev.vat.validate(customerVat);

    if (
      vatResult.data.valid &&
      ["HIGH", "MEDIUM"].includes(vatResult.meta.confidence)
    ) {
      const customerCountry = vatResult.data.country_code;
      if (customerCountry !== YOUR_COUNTRY) {
        applyReverseCharge = true;
      }
    }
  }

  const vatCents = applyReverseCharge
    ? 0
    : Math.round(amountCents * STANDARD_VAT_RATE);
  const totalCents = amountCents + vatCents;

  const intent = await stripe.paymentIntents.create({
    amount: totalCents,
    currency,
    metadata: {
      vat_number: customerVat || "",
      vat_valid: vatResult ? String(vatResult.data.valid) : "not_provided",
      vat_confidence: vatResult ? vatResult.meta.confidence : "N/A",
      reverse_charge: String(applyReverseCharge),
      vat_amount_cents: String(vatCents),
      validation_request_id: vatResult ? vatResult.request_id : "",
    },
  });

  return {
    clientSecret: intent.client_secret,
    totalCents,
    vatCents,
    reverseCharge: applyReverseCharge,
  };
}

Step 3: Store validation proof for tax audit

EU tax authorities can request proof that you validated a VAT number before applying reverse charge. The proof should include:

  • The VAT number that was validated
  • The validation result (valid/invalid)
  • The timestamp of validation
  • A consultation number (if available via checkVatApprox)
  • The confidence level of the result

EuroValidate returns all of this in the API response. Store it alongside your invoice record:

# After successful payment, store the validation record
def store_validation_proof(payment_intent_id: str, vat_result):
    """
    Store in your database for tax audit compliance.
    """
    record = {
        "payment_intent_id": payment_intent_id,
        "vat_number": f"{vat_result.data.country_code}{vat_result.data.vat_number}",
        "valid": vat_result.data.valid,
        "company_name": vat_result.data.name,
        "company_address": vat_result.data.address,
        "confidence": vat_result.meta.confidence,
        "source": vat_result.meta.source,
        "last_verified": vat_result.meta.last_verified,
        "request_id": vat_result.request_id,
        "validated_at": datetime.utcnow().isoformat(),
    }
    # Insert into your database
    db.validation_proofs.insert(record)

The request_id field (req_abc123 format) is unique per request. If a tax authority questions a specific transaction, you can trace back to the exact validation that occurred.

Handling edge cases

Edge case 1: VIES is down

VIES goes down regularly. When it does, EuroValidate returns cached data with a reduced confidence score. Your checkout should handle this:

if vat_result.meta.confidence == "UNKNOWN":
    # No cached data, upstream unreachable
    # Option A: Charge VAT and refund later if validated
    # Option B: Allow checkout but flag for manual review
    apply_reverse_charge = False
    flag_for_review = True

elif vat_result.meta.confidence == "LOW":
    # Stale cache (>24h old)
    # The data might be outdated -- proceed with caution
    apply_reverse_charge = True
    flag_for_review = True

The confidence level gives you a framework for deciding. HIGH and MEDIUM are safe for automated decisions. LOW and UNKNOWN should trigger manual review or a conservative fallback (charge VAT, refund if validated later).

Edge case 2: Germany and Spain

Germany and Spain do not return company names through VIES (national data protection law). The VAT number will validate as valid/invalid, but name and address will be null.

If you need company identity for German or Spanish customers, use the /v1/company endpoint, which checks GLEIF:

# VAT validated but no name (Germany)
if vat_result.data.valid and vat_result.data.name is None:
    company = eurovalidate.company.lookup(customer_vat)
    company_name = company.data.legal_name if company.success else None

Edge case 3: Invalid VAT format

If the customer enters a malformed VAT number, EuroValidate returns an error with a clear message. Handle this in your frontend:

const result = await ev.vat.validate(customerVat);

if (!result.success) {
  // Show error to user
  showError("Please enter a valid EU VAT number (e.g., NL820646660B01)");
  return;
}

if (!result.data.valid) {
  // VAT number is well-formed but not registered/active
  showWarning("This VAT number is not currently active. VAT will be charged.");
}

Edge case 4: Same-country B2B

If the customer's VAT number is from the same country as your business, reverse charge does not apply. You charge domestic VAT as usual:

if customer_country == YOUR_COUNTRY:
    # Same country: charge domestic VAT, no reverse charge
    apply_reverse_charge = False

This is a common mistake. Reverse charge is only for cross-border intra-Community supplies.

Validating at the right moment

Validate the VAT number as early as possible in your checkout flow, ideally when the customer enters it (not after they click "Pay"). This gives you time to:

  1. Show the correct total (with or without VAT) before payment
  2. Retry if VIES is temporarily down
  3. Fall back to cached data if needed

A typical flow:

Customer enters VAT number
    -> EuroValidate /v1/vat/{number}
    -> Update price display (with/without VAT)
    -> Customer confirms and pays
    -> Stripe PaymentIntent with correct amount
    -> Store validation proof

VAT rates by country

If reverse charge does not apply and you need the correct VAT rate for the customer's country, use the /v1/vat-rates endpoint:

curl -H "X-API-Key: your_api_key" \
  https://api.eurovalidate.com/v1/vat-rates/FR
{
  "success": true,
  "data": {
    "country_code": "FR",
    "standard_rate": 20.0,
    "reduced_rates": [10.0, 5.5, 2.1],
    "super_reduced_rate": null,
    "parking_rate": null
  }
}

This saves you from hardcoding VAT rates that change (Luxembourg went from 15% to 17% in 2024, for example).

Complete example: Stripe webhook handler

After payment succeeds, store the validation proof:

@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
    payload = await request.body()
    sig = request.headers.get("stripe-signature")
    event = stripe.Webhook.construct_event(payload, sig, webhook_secret)

    if event["type"] == "payment_intent.succeeded":
        intent = event["data"]["object"]
        metadata = intent["metadata"]

        if metadata.get("reverse_charge") == "True":
            # Store reverse charge proof
            store_validation_proof(
                payment_intent_id=intent["id"],
                vat_number=metadata["vat_number"],
                vat_valid=metadata["vat_valid"],
                confidence=metadata["vat_confidence"],
                request_id=metadata["validation_request_id"],
            )

    return {"status": "ok"}

Summary

Adding EU VAT validation to Stripe takes three steps:

  1. Validate the VAT number with EuroValidate before creating the PaymentIntent
  2. Apply reverse charge if the number is valid and the customer is in a different EU country
  3. Store the validation proof (result, timestamp, confidence, request ID) for tax audit compliance

The confidence score is the key differentiator. Instead of a binary valid/invalid that breaks when VIES is down, you get a graduated assessment that lets your checkout flow degrade gracefully.

Get a free API key at eurovalidate.com. Full API reference at api.eurovalidate.com/docs.