Add EU VAT validation to your Stripe checkout in 10 minutes
Step-by-step tutorial: validate VAT, apply reverse charge, store proof for audit.
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:
- Confirm the buyer's VAT number is valid and active
- Confirm the buyer is in a different EU member state than you
- 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:
- Show the correct total (with or without VAT) before payment
- Retry if VIES is temporarily down
- 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:
- Validate the VAT number with EuroValidate before creating the PaymentIntent
- Apply reverse charge if the number is valid and the customer is in a different EU country
- 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.
