<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[EuroValidate]]></title><description><![CDATA[EuroValidate]]></description><link>https://blog.eurovalidate.com</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>EuroValidate</title><link>https://blog.eurovalidate.com</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 07 Apr 2026 14:27:50 GMT</lastBuildDate><atom:link href="https://blog.eurovalidate.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Why VIES is unreliable — and how we built around it]]></title><description><![CDATA[Why VIES Is Unreliable — and How We Built Around It
If you have ever built a SaaS product that sells to European businesses, you have hit VIES. The VAT Information Exchange System is the European Commission's service for validating EU VAT numbers. Ev...]]></description><link>https://blog.eurovalidate.com/why-vies-unreliable</link><guid isPermaLink="true">https://blog.eurovalidate.com/why-vies-unreliable</guid><category><![CDATA[api]]></category><category><![CDATA[SaaS]]></category><dc:creator><![CDATA[EuroValidate]]></dc:creator><pubDate>Tue, 07 Apr 2026 10:00:00 GMT</pubDate><content:encoded><![CDATA[<h1 id="heading-why-vies-is-unreliable-and-how-we-built-around-it">Why VIES Is Unreliable — and How We Built Around It</h1>
<p>If you have ever built a SaaS product that sells to European businesses, you have hit VIES. The VAT Information Exchange System is the European Commission's service for validating EU VAT numbers. Every B2B transaction that involves reverse charge, every checkout flow that needs to verify a customer's tax status, every KYB onboarding that must confirm a company is real — all of it runs through VIES.</p>
<p>And VIES is unreliable.</p>
<p>Not unreliable in the "sometimes slow" sense. Unreliable in the "Germany generates the vast majority of all VIES errors, the entire system has a global concurrency limit, and two of the largest economies in the EU refuse to return company names" sense.</p>
<p>This post is a technical breakdown of what makes VIES difficult to work with, the specific failure modes we have documented, and the architecture decisions we made at EuroValidate to build a reliable layer on top of it.</p>
<h2 id="heading-how-vies-actually-works">How VIES actually works</h2>
<p>VIES is not a single database. It is a SOAP-based routing layer operated by the European Commission. When you send a VAT validation request, VIES forwards it to the national tax authority of the relevant member state. Germany's request goes to the Bundeszentralamt fur Steuern. France's goes to the DGFiP. Each of the 27 EU member states runs its own backend.</p>
<p>This architecture is the root cause of most reliability problems. VIES is only as available as the weakest national system online at any given moment.</p>
<p>The WSDL endpoint is:</p>
<pre><code>https:<span class="hljs-comment">//ec.europa.eu/taxation_customs/vies/checkVatService.wsdl</span>
</code></pre><p>A typical SOAP request looks like this:</p>
<pre><code class="lang-xml"><span class="hljs-tag">&lt;<span class="hljs-name">soapenv:Envelope</span> <span class="hljs-attr">xmlns:soapenv</span>=<span class="hljs-string">"http://schemas.xmlsoap.org/soap/envelope/"</span>
                  <span class="hljs-attr">xmlns:urn</span>=<span class="hljs-string">"urn:ec.europa.eu:taxud:vies:services:checkVat:types"</span>&gt;</span>
   <span class="hljs-tag">&lt;<span class="hljs-name">soapenv:Body</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">urn:checkVat</span>&gt;</span>
         <span class="hljs-tag">&lt;<span class="hljs-name">urn:countryCode</span>&gt;</span>NL<span class="hljs-tag">&lt;/<span class="hljs-name">urn:countryCode</span>&gt;</span>
         <span class="hljs-tag">&lt;<span class="hljs-name">urn:vatNumber</span>&gt;</span>820646660B01<span class="hljs-tag">&lt;/<span class="hljs-name">urn:vatNumber</span>&gt;</span>
      <span class="hljs-tag">&lt;/<span class="hljs-name">urn:checkVat</span>&gt;</span>
   <span class="hljs-tag">&lt;/<span class="hljs-name">soapenv:Body</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">soapenv:Envelope</span>&gt;</span>
</code></pre>
<p>If the Dutch backend is up, you get a response in 200-2000ms. If it is down, you get a SOAP fault. If too many people are querying it simultaneously, you get <code>MS_MAX_CONCURRENT_REQ</code>. If the entire VIES router is overloaded, you get <code>SERVICE_UNAVAILABLE</code>.</p>
<h2 id="heading-the-specific-problems">The specific problems</h2>
<h3 id="heading-problem-1-msmaxconcurrentreq-is-a-global-limit">Problem 1: MS_MAX_CONCURRENT_REQ is a global limit</h3>
<p>This is the one that surprises most developers. When a member state backend returns <code>MS_MAX_CONCURRENT_REQ</code>, it does not mean your application is sending too many requests. It means the entire country's backend is overloaded — across all consumers worldwide. Every fintech startup, every ERP system, every government cross-check, every tax software vendor — all sharing a single concurrency pool per country.</p>
<p>There is nothing you can do about this except retry with backoff and cache aggressively.</p>
<h3 id="heading-problem-2-germany-and-spain-never-return-company-names">Problem 2: Germany and Spain never return company names</h3>
<p>Germany and Spain have national data protection laws that prohibit VIES from returning trader name and address information. When you validate a German VAT number like <code>DE123456789</code>, the response will confirm valid/invalid but the <code>name</code> and <code>address</code> fields will be empty.</p>
<p>This is not a bug. This is permanent behavior mandated by law.</p>
<p>In our VIES client, we handle this explicitly:</p>
<pre><code class="lang-python"><span class="hljs-comment"># Countries whose national systems do not return name/address</span>
_NO_TRADER_DATA_COUNTRIES: frozenset[str] = frozenset({<span class="hljs-string">"DE"</span>, <span class="hljs-string">"ES"</span>})
</code></pre>
<p>For these countries, we fall back to the GLEIF API to resolve company identity. GLEIF (Global Legal Entity Identifier Foundation) provides company data for any entity that holds an LEI. It is free, requires no authentication, and returns structured JSON instead of SOAP.</p>
<h3 id="heading-problem-3-greece-uses-el-not-gr">Problem 3: Greece uses EL, not GR</h3>
<p>Every ISO 3166-1 standard says Greece is <code>GR</code>. VIES uses <code>EL</code> (from the Greek name "Ellada"). If you send <code>GR</code> to VIES, you get an <code>INVALID_INPUT</code> fault. If your system stores country codes as ISO 3166 (as it should), you need bidirectional mapping.</p>
<p>This is a small thing, but it has broken countless integrations. We have seen production systems that worked for 26 countries and silently failed for Greece for months before anyone noticed.</p>
<p>Our mapping is explicit:</p>
<pre><code class="lang-python">_COUNTRY_TO_VIES: dict[str, str] = {<span class="hljs-string">"GR"</span>: <span class="hljs-string">"EL"</span>}
_VIES_TO_COUNTRY: dict[str, str] = {<span class="hljs-string">"EL"</span>: <span class="hljs-string">"GR"</span>}
</code></pre>
<p>Both directions. Always. Every VAT number input that starts with <code>GR</code> gets mapped to <code>EL</code> before hitting VIES. Every response from VIES with <code>EL</code> gets mapped back to <code>GR</code> before reaching the client.</p>
<h3 id="heading-problem-4-northern-ireland-dual-status">Problem 4: Northern Ireland dual status</h3>
<p>Northern Ireland exists in both EU customs territory (via the Windsor Framework) and UK VAT territory simultaneously. A Northern Irish business might have:</p>
<ul>
<li>An <code>XI</code> EORI number (EU customs)</li>
<li>A <code>GB</code> VAT number (UK tax)</li>
</ul>
<p>Your system needs to handle both. An <code>XI</code> prefix routes to the EU EORI validation system. A <code>GB</code> prefix routes to HMRC's REST API. Same company, different systems, different protocols (SOAP vs REST).</p>
<h3 id="heading-problem-5-territory-exceptions">Problem 5: Territory exceptions</h3>
<p>Not everything inside an EU country is inside the EU VAT territory.</p>
<ul>
<li><strong>Monaco</strong> is treated as France for VAT purposes. A Monaco-based company uses an <code>FR</code> VAT prefix.</li>
<li><strong>The Canary Islands</strong> are Spanish territory but outside the EU VAT area. A company in Las Palmas does not charge EU VAT.</li>
<li><strong>Mount Athos</strong> is Greek territory but outside the EU VAT area.</li>
<li><strong>Campione d'Italia</strong> and <strong>Lake Lugano</strong> are Italian territory but outside the EU VAT area.</li>
</ul>
<p>If your checkout flow does not account for these, you will either overcharge or undercharge VAT. Both are compliance violations.</p>
<h3 id="heading-problem-6-vies-soap-returns-instead-of-null">Problem 6: VIES SOAP returns "---" instead of null</h3>
<p>When trader data is unavailable (but the country does nominally return it), VIES does not send an empty string or omit the field. It sends the literal string <code>"---"</code>. If you are not filtering for this, your database now has company names that are three dashes.</p>
<pre><code class="lang-python"><span class="hljs-keyword">if</span> name <span class="hljs-keyword">and</span> name.strip() <span class="hljs-keyword">in</span> (<span class="hljs-string">"---"</span>, <span class="hljs-string">""</span>):
    name = <span class="hljs-literal">None</span>
<span class="hljs-keyword">if</span> address <span class="hljs-keyword">and</span> address.strip() <span class="hljs-keyword">in</span> (<span class="hljs-string">"---"</span>, <span class="hljs-string">""</span>):
    address = <span class="hljs-literal">None</span>
</code></pre>
<h2 id="heading-our-architecture-how-we-built-around-it">Our architecture: how we built around it</h2>
<p>When we designed EuroValidate, we started from a simple premise: the upstream is unreliable, so every layer of our system must assume it can fail at any moment.</p>
<h3 id="heading-per-country-circuit-breakers">Per-country circuit breakers</h3>
<p>We run 28 independent circuit breakers for VIES — one per EU member state (with <code>GR</code> and <code>EL</code> aliased to the same breaker). Plus one for the EC EORI endpoint and one for HMRC. That is 30 circuit breakers total.</p>
<p>When Germany is down (which happens frequently), the breaker for <code>DE</code> trips to OPEN state. Requests for French, Dutch, or Italian VAT numbers continue flowing unaffected. Germany being down does not equal Europe being down.</p>
<p>Each breaker uses the same configuration:</p>
<ul>
<li><strong>fail_max</strong>: 5 consecutive failures before tripping</li>
<li><strong>reset_timeout</strong>: 60 seconds before allowing a probe request</li>
<li><strong>State machine</strong>: CLOSED (normal) -&gt; OPEN (blocking) -&gt; HALF_OPEN (probing) -&gt; CLOSED</li>
</ul>
<pre><code class="lang-python">breaker_registry = CircuitBreakerRegistry()
breaker = breaker_registry.get_vies_breaker(<span class="hljs-string">"DE"</span>)
<span class="hljs-comment"># If DE has failed 5 times, this raises CircuitBreakerError immediately</span>
<span class="hljs-comment"># No waiting, no timeout, no wasted connection</span>
</code></pre>
<h3 id="heading-dual-layer-cache">Dual-layer cache</h3>
<p>Every successful VIES response is cached in two layers:</p>
<ol>
<li><strong>Redis</strong> (hot cache): sub-millisecond reads, TTL of 24 hours for VAT data</li>
<li><strong>PostgreSQL</strong> (persistent cache): survives Redis restarts, used as fallback</li>
</ol>
<p>The lookup order is: Redis -&gt; PostgreSQL -&gt; upstream VIES.</p>
<p>If VIES is down and the circuit breaker is open, we serve from cache with reduced confidence. The client always gets a response. The confidence score tells them how much to trust it.</p>
<h3 id="heading-confidence-scoring">Confidence scoring</h3>
<p>Every EuroValidate response includes a <code>confidence</code> field: <code>HIGH</code>, <code>MEDIUM</code>, <code>LOW</code>, or <code>UNKNOWN</code>.</p>
<p>The rules are deterministic:</p>
<ul>
<li><strong>HIGH</strong>: Live response from upstream, or deterministic offline check (like IBAN MOD 97), or cached data less than 1 hour old</li>
<li><strong>MEDIUM</strong>: Cached data between 1 and 24 hours old, or cross-referenced sources that agree</li>
<li><strong>LOW</strong>: Cached data older than 24 hours (stale)</li>
<li><strong>UNKNOWN</strong>: Upstream unreachable and no cached data exists</li>
</ul>
<p>Cross-referencing can promote MEDIUM to HIGH. If both VIES and GLEIF agree on a company's identity, and the VIES data is cached within 24 hours, the confidence gets boosted. But cross-referencing never promotes LOW or UNKNOWN — the data is too stale to trust regardless of source agreement.</p>
<pre><code class="lang-python"><span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">score_cross_referenced</span>(<span class="hljs-params">primary, secondary, entities_match</span>):</span>
    <span class="hljs-keyword">if</span> <span class="hljs-keyword">not</span> entities_match:
        <span class="hljs-keyword">return</span> primary  <span class="hljs-comment"># No boost on mismatch</span>
    <span class="hljs-keyword">if</span> (primary.level == ConfidenceLevel.MEDIUM
        <span class="hljs-keyword">and</span> secondary.level &gt;= ConfidenceLevel.MEDIUM):
        <span class="hljs-keyword">return</span> HIGH  <span class="hljs-comment"># MEDIUM -&gt; HIGH when sources agree</span>
    <span class="hljs-keyword">return</span> primary
</code></pre>
<p>This lets clients make informed decisions. A checkout flow might accept HIGH and MEDIUM but flag LOW for manual review. A KYB pipeline might require HIGH only.</p>
<h3 id="heading-graceful-degradation">Graceful degradation</h3>
<p>EuroValidate never returns a bare HTTP 500. If the upstream is down, we return cached data with reduced confidence and an <code>upstream_status</code> field that tells the client what happened.</p>
<p>Compare this to calling VIES directly:</p>
<p><strong>Raw VIES (when Germany is down):</strong></p>
<pre><code class="lang-xml"><span class="hljs-tag">&lt;<span class="hljs-name">soap:Fault</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">faultcode</span>&gt;</span>soap:Server<span class="hljs-tag">&lt;/<span class="hljs-name">faultcode</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">faultstring</span>&gt;</span>MS_UNAVAILABLE<span class="hljs-tag">&lt;/<span class="hljs-name">faultstring</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">soap:Fault</span>&gt;</span>
</code></pre>
<p>Your application crashes or returns a 500 to your user. No data. No context. No fallback.</p>
<p><strong>EuroValidate (same scenario):</strong></p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"success"</span>: <span class="hljs-literal">true</span>,
  <span class="hljs-attr">"data"</span>: {
    <span class="hljs-attr">"valid"</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">"country_code"</span>: <span class="hljs-string">"DE"</span>,
    <span class="hljs-attr">"vat_number"</span>: <span class="hljs-string">"812526315"</span>,
    <span class="hljs-attr">"name"</span>: <span class="hljs-literal">null</span>,
    <span class="hljs-attr">"address"</span>: <span class="hljs-literal">null</span>
  },
  <span class="hljs-attr">"meta"</span>: {
    <span class="hljs-attr">"confidence"</span>: <span class="hljs-string">"MEDIUM"</span>,
    <span class="hljs-attr">"source"</span>: <span class="hljs-string">"cache_postgresql"</span>,
    <span class="hljs-attr">"cached"</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">"response_time_ms"</span>: <span class="hljs-number">3</span>,
    <span class="hljs-attr">"last_verified"</span>: <span class="hljs-string">"2026-04-04T14:30:00Z"</span>,
    <span class="hljs-attr">"upstream_status"</span>: <span class="hljs-string">"vies_de: unavailable"</span>
  },
  <span class="hljs-attr">"request_id"</span>: <span class="hljs-string">"req_7f3k2m"</span>
}
</code></pre>
<p>The client gets data. They know it is cached. They know the upstream is down. They know the confidence level. They can make a business decision.</p>
<h3 id="heading-retry-with-exponential-backoff-and-jitter">Retry with exponential backoff and jitter</h3>
<p>For transient errors (<code>MS_MAX_CONCURRENT_REQ</code>, <code>TIMEOUT</code>, <code>MS_UNAVAILABLE</code>), we retry up to 3 times with exponential backoff plus jitter:</p>
<pre><code class="lang-python"><span class="hljs-meta">@retry(</span>
    retry=retry_if_exception_type(ViesTransientError),
    stop=stop_after_attempt(<span class="hljs-number">3</span>),
    wait=wait_exponential_jitter(initial=<span class="hljs-number">0.5</span>, max=<span class="hljs-number">5</span>, jitter=<span class="hljs-number">1</span>),
    reraise=<span class="hljs-literal">True</span>,
)
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">check_vat</span>(<span class="hljs-params">self, country_code, vat_number</span>):</span>
    ...
</code></pre>
<p>The jitter prevents thundering herd when VIES comes back up after an outage. Without jitter, every cached-out client retries at the exact same interval, recreating the overload.</p>
<h3 id="heading-async-execution">Async execution</h3>
<p>VIES uses SOAP, which means zeep, which is synchronous. In an async FastAPI application, blocking the event loop on a SOAP call would kill throughput. We run all zeep calls in a bounded thread pool:</p>
<pre><code class="lang-python">_executor = ThreadPoolExecutor(max_workers=<span class="hljs-number">10</span>, thread_name_prefix=<span class="hljs-string">"vies"</span>)

<span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">validate_vat</span>(<span class="hljs-params">full_vat: str</span>) -&gt; ViesResult:</span>
    vies_cc, vat_number = _normalise_input(full_vat)
    loop = asyncio.get_running_loop()
    result = <span class="hljs-keyword">await</span> loop.run_in_executor(
        _executor, _soap_client.check_vat, vies_cc, vat_number
    )
    <span class="hljs-keyword">return</span> result
</code></pre>
<p>Ten concurrent VIES calls maximum. Enough to handle burst traffic. Not enough to exhaust connections or contribute to <code>MS_MAX_CONCURRENT_REQ</code>.</p>
<h2 id="heading-the-result">The result</h2>
<p>EuroValidate turns an unreliable SOAP service into a reliable REST API. One endpoint. JSON responses. Confidence scores. Graceful degradation. No SOAP, no XML parsing, no country-code mapping, no retry logic in your application code.</p>
<p>The numbers from our system:</p>
<ul>
<li><strong>130 EU banks</strong> in the IBAN registry for instant BIC/bank lookup</li>
<li><strong>30 independent circuit breakers</strong> (28 VIES + 1 EORI + 1 HMRC)</li>
<li><strong>4 confidence levels</strong> with deterministic scoring rules</li>
<li><strong>3-layer lookup</strong>: Redis (sub-ms) -&gt; PostgreSQL (low ms) -&gt; upstream (200-2000ms)</li>
<li><strong>IBAN validation</strong>: fully offline, deterministic, always HIGH confidence</li>
</ul>
<p>If you are building anything that touches EU VAT, IBAN, or EORI validation, you can try EuroValidate for free at <a target="_blank" href="https://eurovalidate.com">eurovalidate.com</a>. The API docs are at <a target="_blank" href="https://api.eurovalidate.com/docs">api.eurovalidate.com/docs</a>.</p>
<p>We built this because we got tired of writing the same VIES workarounds in every project. Now you do not have to either.</p>
]]></content:encoded></item><item><title><![CDATA[Add EU VAT validation to your Stripe checkout in 10 minutes]]></title><description><![CDATA[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 mandat...]]></description><link>https://blog.eurovalidate.com/stripe-vat-validation-tutorial</link><guid isPermaLink="true">https://blog.eurovalidate.com/stripe-vat-validation-tutorial</guid><category><![CDATA[stripe]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[EuroValidate]]></dc:creator><pubDate>Tue, 07 Apr 2026 10:00:00 GMT</pubDate><content:encoded><![CDATA[<h1 id="heading-add-eu-vat-validation-to-your-stripe-checkout-in-10-minutes">Add EU VAT Validation to Your Stripe Checkout in 10 Minutes</h1>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<h2 id="heading-what-you-need">What you need</h2>
<ul>
<li>A Stripe account with a PaymentIntent-based checkout</li>
<li>An EuroValidate API key (free tier: 100 calls/month) -- get one at <a target="_blank" href="https://eurovalidate.com">eurovalidate.com</a></li>
<li>Basic understanding of EU reverse charge rules</li>
</ul>
<h2 id="heading-how-eu-reverse-charge-works-30-second-version">How EU reverse charge works (30-second version)</h2>
<p>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.</p>
<p>To apply reverse charge, you must:</p>
<ol>
<li>Confirm the buyer's VAT number is valid and active</li>
<li>Confirm the buyer is in a different EU member state than you</li>
<li>Keep proof of validation (timestamp, result, confidence level) for your records</li>
</ol>
<p>If validation fails or the buyer is in the same country as you, charge the standard VAT rate.</p>
<h2 id="heading-step-1-install-the-sdk">Step 1: Install the SDK</h2>
<pre><code class="lang-bash"><span class="hljs-comment"># Python</span>
pip install eurovalidate

<span class="hljs-comment"># Node.js</span>
npm install @eurovalidate/sdk
</code></pre>
<p>Or call the API directly -- no SDK required:</p>
<pre><code class="lang-bash">curl -H <span class="hljs-string">"X-API-Key: your_api_key"</span> \
  https://api.eurovalidate.com/v1/vat/NL820646660B01
</code></pre>
<h2 id="heading-step-2-validate-the-vat-number-before-payment">Step 2: Validate the VAT number before payment</h2>
<p>The VAT number should be validated before you create the Stripe PaymentIntent. This way you know the correct amount to charge.</p>
<h3 id="heading-python">Python</h3>
<pre><code class="lang-python"><span class="hljs-keyword">import</span> eurovalidate
<span class="hljs-keyword">import</span> stripe

eurovalidate.api_key = <span class="hljs-string">"ev_live_your_key"</span>
stripe.api_key = <span class="hljs-string">"sk_live_your_key"</span>

YOUR_COUNTRY = <span class="hljs-string">"PT"</span>  <span class="hljs-comment"># Your business is in Portugal</span>
STANDARD_VAT_RATE = <span class="hljs-number">0.23</span>  <span class="hljs-comment"># Portuguese standard rate</span>

<span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">create_checkout</span>(<span class="hljs-params">customer_vat: str, amount_cents: int, currency: str = <span class="hljs-string">"eur"</span></span>):</span>
    <span class="hljs-string">"""
    Validate VAT, apply reverse charge if eligible, create PaymentIntent.
    """</span>
    vat_result = <span class="hljs-literal">None</span>
    apply_reverse_charge = <span class="hljs-literal">False</span>

    <span class="hljs-keyword">if</span> customer_vat:
        <span class="hljs-comment"># Validate the customer's VAT number</span>
        vat_result = eurovalidate.vat.validate(customer_vat)

        <span class="hljs-keyword">if</span> vat_result.data.valid <span class="hljs-keyword">and</span> vat_result.meta.confidence <span class="hljs-keyword">in</span> (<span class="hljs-string">"HIGH"</span>, <span class="hljs-string">"MEDIUM"</span>):
            customer_country = vat_result.data.country_code
            <span class="hljs-comment"># Reverse charge: valid VAT + different EU country</span>
            <span class="hljs-keyword">if</span> customer_country != YOUR_COUNTRY:
                apply_reverse_charge = <span class="hljs-literal">True</span>

    <span class="hljs-comment"># Calculate final amount</span>
    <span class="hljs-keyword">if</span> apply_reverse_charge:
        total_cents = amount_cents  <span class="hljs-comment"># No VAT added</span>
        vat_cents = <span class="hljs-number">0</span>
    <span class="hljs-keyword">else</span>:
        vat_cents = int(amount_cents * STANDARD_VAT_RATE)
        total_cents = amount_cents + vat_cents

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

    <span class="hljs-keyword">return</span> {
        <span class="hljs-string">"client_secret"</span>: intent.client_secret,
        <span class="hljs-string">"total_cents"</span>: total_cents,
        <span class="hljs-string">"vat_cents"</span>: vat_cents,
        <span class="hljs-string">"reverse_charge"</span>: apply_reverse_charge,
    }
</code></pre>
<h3 id="heading-nodejs">Node.js</h3>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> EuroValidate = <span class="hljs-built_in">require</span>(<span class="hljs-string">"@eurovalidate/sdk"</span>);
<span class="hljs-keyword">const</span> Stripe = <span class="hljs-built_in">require</span>(<span class="hljs-string">"stripe"</span>);

<span class="hljs-keyword">const</span> ev = <span class="hljs-keyword">new</span> EuroValidate(<span class="hljs-string">"ev_live_your_key"</span>);
<span class="hljs-keyword">const</span> stripe = <span class="hljs-keyword">new</span> Stripe(<span class="hljs-string">"sk_live_your_key"</span>);

<span class="hljs-keyword">const</span> YOUR_COUNTRY = <span class="hljs-string">"PT"</span>;
<span class="hljs-keyword">const</span> STANDARD_VAT_RATE = <span class="hljs-number">0.23</span>;

<span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">createCheckout</span>(<span class="hljs-params">customerVat, amountCents, currency = <span class="hljs-string">"eur"</span></span>) </span>{
  <span class="hljs-keyword">let</span> vatResult = <span class="hljs-literal">null</span>;
  <span class="hljs-keyword">let</span> applyReverseCharge = <span class="hljs-literal">false</span>;

  <span class="hljs-keyword">if</span> (customerVat) {
    vatResult = <span class="hljs-keyword">await</span> ev.vat.validate(customerVat);

    <span class="hljs-keyword">if</span> (
      vatResult.data.valid &amp;&amp;
      [<span class="hljs-string">"HIGH"</span>, <span class="hljs-string">"MEDIUM"</span>].includes(vatResult.meta.confidence)
    ) {
      <span class="hljs-keyword">const</span> customerCountry = vatResult.data.country_code;
      <span class="hljs-keyword">if</span> (customerCountry !== YOUR_COUNTRY) {
        applyReverseCharge = <span class="hljs-literal">true</span>;
      }
    }
  }

  <span class="hljs-keyword">const</span> vatCents = applyReverseCharge
    ? <span class="hljs-number">0</span>
    : <span class="hljs-built_in">Math</span>.round(amountCents * STANDARD_VAT_RATE);
  <span class="hljs-keyword">const</span> totalCents = amountCents + vatCents;

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

  <span class="hljs-keyword">return</span> {
    <span class="hljs-attr">clientSecret</span>: intent.client_secret,
    totalCents,
    vatCents,
    <span class="hljs-attr">reverseCharge</span>: applyReverseCharge,
  };
}
</code></pre>
<h2 id="heading-step-3-store-validation-proof-for-tax-audit">Step 3: Store validation proof for tax audit</h2>
<p>EU tax authorities can request proof that you validated a VAT number before applying reverse charge. The proof should include:</p>
<ul>
<li>The VAT number that was validated</li>
<li>The validation result (valid/invalid)</li>
<li>The timestamp of validation</li>
<li>A consultation number (if available via <code>checkVatApprox</code>)</li>
<li>The confidence level of the result</li>
</ul>
<p>EuroValidate returns all of this in the API response. Store it alongside your invoice record:</p>
<pre><code class="lang-python"><span class="hljs-comment"># After successful payment, store the validation record</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">store_validation_proof</span>(<span class="hljs-params">payment_intent_id: str, vat_result</span>):</span>
    <span class="hljs-string">"""
    Store in your database for tax audit compliance.
    """</span>
    record = {
        <span class="hljs-string">"payment_intent_id"</span>: payment_intent_id,
        <span class="hljs-string">"vat_number"</span>: <span class="hljs-string">f"<span class="hljs-subst">{vat_result.data.country_code}</span><span class="hljs-subst">{vat_result.data.vat_number}</span>"</span>,
        <span class="hljs-string">"valid"</span>: vat_result.data.valid,
        <span class="hljs-string">"company_name"</span>: vat_result.data.name,
        <span class="hljs-string">"company_address"</span>: vat_result.data.address,
        <span class="hljs-string">"confidence"</span>: vat_result.meta.confidence,
        <span class="hljs-string">"source"</span>: vat_result.meta.source,
        <span class="hljs-string">"last_verified"</span>: vat_result.meta.last_verified,
        <span class="hljs-string">"request_id"</span>: vat_result.request_id,
        <span class="hljs-string">"validated_at"</span>: datetime.utcnow().isoformat(),
    }
    <span class="hljs-comment"># Insert into your database</span>
    db.validation_proofs.insert(record)
</code></pre>
<p>The <code>request_id</code> field (<code>req_abc123</code> format) is unique per request. If a tax authority questions a specific transaction, you can trace back to the exact validation that occurred.</p>
<h2 id="heading-handling-edge-cases">Handling edge cases</h2>
<h3 id="heading-edge-case-1-vies-is-down">Edge case 1: VIES is down</h3>
<p>VIES goes down regularly. When it does, EuroValidate returns cached data with a reduced confidence score. Your checkout should handle this:</p>
<pre><code class="lang-python"><span class="hljs-keyword">if</span> vat_result.meta.confidence == <span class="hljs-string">"UNKNOWN"</span>:
    <span class="hljs-comment"># No cached data, upstream unreachable</span>
    <span class="hljs-comment"># Option A: Charge VAT and refund later if validated</span>
    <span class="hljs-comment"># Option B: Allow checkout but flag for manual review</span>
    apply_reverse_charge = <span class="hljs-literal">False</span>
    flag_for_review = <span class="hljs-literal">True</span>

<span class="hljs-keyword">elif</span> vat_result.meta.confidence == <span class="hljs-string">"LOW"</span>:
    <span class="hljs-comment"># Stale cache (&gt;24h old)</span>
    <span class="hljs-comment"># The data might be outdated -- proceed with caution</span>
    apply_reverse_charge = <span class="hljs-literal">True</span>
    flag_for_review = <span class="hljs-literal">True</span>
</code></pre>
<p>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).</p>
<h3 id="heading-edge-case-2-germany-and-spain">Edge case 2: Germany and Spain</h3>
<p>Germany and Spain do not return company names through VIES (national data protection law). The VAT number will validate as valid/invalid, but <code>name</code> and <code>address</code> will be <code>null</code>.</p>
<p>If you need company identity for German or Spanish customers, use the <code>/v1/company</code> endpoint, which checks GLEIF:</p>
<pre><code class="lang-python"><span class="hljs-comment"># VAT validated but no name (Germany)</span>
<span class="hljs-keyword">if</span> vat_result.data.valid <span class="hljs-keyword">and</span> vat_result.data.name <span class="hljs-keyword">is</span> <span class="hljs-literal">None</span>:
    company = eurovalidate.company.lookup(customer_vat)
    company_name = company.data.legal_name <span class="hljs-keyword">if</span> company.success <span class="hljs-keyword">else</span> <span class="hljs-literal">None</span>
</code></pre>
<h3 id="heading-edge-case-3-invalid-vat-format">Edge case 3: Invalid VAT format</h3>
<p>If the customer enters a malformed VAT number, EuroValidate returns an error with a clear message. Handle this in your frontend:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> result = <span class="hljs-keyword">await</span> ev.vat.validate(customerVat);

<span class="hljs-keyword">if</span> (!result.success) {
  <span class="hljs-comment">// Show error to user</span>
  showError(<span class="hljs-string">"Please enter a valid EU VAT number (e.g., NL820646660B01)"</span>);
  <span class="hljs-keyword">return</span>;
}

<span class="hljs-keyword">if</span> (!result.data.valid) {
  <span class="hljs-comment">// VAT number is well-formed but not registered/active</span>
  showWarning(<span class="hljs-string">"This VAT number is not currently active. VAT will be charged."</span>);
}
</code></pre>
<h3 id="heading-edge-case-4-same-country-b2b">Edge case 4: Same-country B2B</h3>
<p>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:</p>
<pre><code class="lang-python"><span class="hljs-keyword">if</span> customer_country == YOUR_COUNTRY:
    <span class="hljs-comment"># Same country: charge domestic VAT, no reverse charge</span>
    apply_reverse_charge = <span class="hljs-literal">False</span>
</code></pre>
<p>This is a common mistake. Reverse charge is only for cross-border intra-Community supplies.</p>
<h2 id="heading-validating-at-the-right-moment">Validating at the right moment</h2>
<p>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:</p>
<ol>
<li>Show the correct total (with or without VAT) before payment</li>
<li>Retry if VIES is temporarily down</li>
<li>Fall back to cached data if needed</li>
</ol>
<p>A typical flow:</p>
<pre><code>Customer enters VAT number
    -&gt; EuroValidate /v1/vat/{number}
    -&gt; Update price display (<span class="hljs-keyword">with</span>/without VAT)
    -&gt; Customer confirms and pays
    -&gt; Stripe PaymentIntent <span class="hljs-keyword">with</span> correct amount
    -&gt; Store validation proof
</code></pre><h2 id="heading-vat-rates-by-country">VAT rates by country</h2>
<p>If reverse charge does not apply and you need the correct VAT rate for the customer's country, use the <code>/v1/vat-rates</code> endpoint:</p>
<pre><code class="lang-bash">curl -H <span class="hljs-string">"X-API-Key: your_api_key"</span> \
  https://api.eurovalidate.com/v1/vat-rates/FR
</code></pre>
<pre><code class="lang-json">{
  <span class="hljs-attr">"success"</span>: <span class="hljs-literal">true</span>,
  <span class="hljs-attr">"data"</span>: {
    <span class="hljs-attr">"country_code"</span>: <span class="hljs-string">"FR"</span>,
    <span class="hljs-attr">"standard_rate"</span>: <span class="hljs-number">20.0</span>,
    <span class="hljs-attr">"reduced_rates"</span>: [<span class="hljs-number">10.0</span>, <span class="hljs-number">5.5</span>, <span class="hljs-number">2.1</span>],
    <span class="hljs-attr">"super_reduced_rate"</span>: <span class="hljs-literal">null</span>,
    <span class="hljs-attr">"parking_rate"</span>: <span class="hljs-literal">null</span>
  }
}
</code></pre>
<p>This saves you from hardcoding VAT rates that change (Luxembourg went from 15% to 17% in 2024, for example).</p>
<h2 id="heading-complete-example-stripe-webhook-handler">Complete example: Stripe webhook handler</h2>
<p>After payment succeeds, store the validation proof:</p>
<pre><code class="lang-python"><span class="hljs-meta">@app.post("/webhooks/stripe")</span>
<span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">stripe_webhook</span>(<span class="hljs-params">request: Request</span>):</span>
    payload = <span class="hljs-keyword">await</span> request.body()
    sig = request.headers.get(<span class="hljs-string">"stripe-signature"</span>)
    event = stripe.Webhook.construct_event(payload, sig, webhook_secret)

    <span class="hljs-keyword">if</span> event[<span class="hljs-string">"type"</span>] == <span class="hljs-string">"payment_intent.succeeded"</span>:
        intent = event[<span class="hljs-string">"data"</span>][<span class="hljs-string">"object"</span>]
        metadata = intent[<span class="hljs-string">"metadata"</span>]

        <span class="hljs-keyword">if</span> metadata.get(<span class="hljs-string">"reverse_charge"</span>) == <span class="hljs-string">"True"</span>:
            <span class="hljs-comment"># Store reverse charge proof</span>
            store_validation_proof(
                payment_intent_id=intent[<span class="hljs-string">"id"</span>],
                vat_number=metadata[<span class="hljs-string">"vat_number"</span>],
                vat_valid=metadata[<span class="hljs-string">"vat_valid"</span>],
                confidence=metadata[<span class="hljs-string">"vat_confidence"</span>],
                request_id=metadata[<span class="hljs-string">"validation_request_id"</span>],
            )

    <span class="hljs-keyword">return</span> {<span class="hljs-string">"status"</span>: <span class="hljs-string">"ok"</span>}
</code></pre>
<h2 id="heading-summary">Summary</h2>
<p>Adding EU VAT validation to Stripe takes three steps:</p>
<ol>
<li><strong>Validate</strong> the VAT number with EuroValidate before creating the PaymentIntent</li>
<li><strong>Apply reverse charge</strong> if the number is valid and the customer is in a different EU country</li>
<li><strong>Store the validation proof</strong> (result, timestamp, confidence, request ID) for tax audit compliance</li>
</ol>
<p>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.</p>
<p>Get a free API key at <a target="_blank" href="https://eurovalidate.com">eurovalidate.com</a>. Full API reference at <a target="_blank" href="https://api.eurovalidate.com/docs">api.eurovalidate.com/docs</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Building EuroValidate: replacing 5 government APIs with one REST call]]></title><description><![CDATA[Building EuroValidate: Replacing 5 Government APIs with One REST Call
There are five different government APIs you need to validate European business data. VIES is SOAP. EORI is SOAP. HMRC is REST. GLEIF is REST. IBAN is offline computation. Each has...]]></description><link>https://blog.eurovalidate.com/building-eurovalidate</link><guid isPermaLink="true">https://blog.eurovalidate.com/building-eurovalidate</guid><category><![CDATA[buildinpublic]]></category><category><![CDATA[startup]]></category><dc:creator><![CDATA[EuroValidate]]></dc:creator><pubDate>Tue, 07 Apr 2026 10:00:00 GMT</pubDate><content:encoded><![CDATA[<h1 id="heading-building-eurovalidate-replacing-5-government-apis-with-one-rest-call">Building EuroValidate: Replacing 5 Government APIs with One REST Call</h1>
<p>There are five different government APIs you need to validate European business data. VIES is SOAP. EORI is SOAP. HMRC is REST. GLEIF is REST. IBAN is offline computation. Each has different authentication, different response formats, different failure modes, different country-code conventions, and different ideas about what "available" means.</p>
<p>I built EuroValidate because I got tired of writing the same integration code in every project. This is the story of how it went from "I should just wrap these APIs" to a production system with 30 circuit breakers, 130 bank entries, and a confidence scoring engine -- built solo, from Portugal, on infrastructure that costs less than a coffee per day.</p>
<h2 id="heading-the-problem-five-apis-five-headaches">The problem: five APIs, five headaches</h2>
<p>Here is what you need to validate EU business data end-to-end:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Data</td><td>Source</td><td>Protocol</td><td>Auth</td><td>Quirks</td></tr>
</thead>
<tbody>
<tr>
<td>VAT validation</td><td>VIES</td><td>SOAP</td><td>None</td><td>Germany/Spain hide names, Greece uses EL not GR, global rate limit</td></tr>
<tr>
<td>EORI validation</td><td>EC endpoint</td><td>SOAP</td><td>None</td><td>Separate from VIES, different WSDL</td></tr>
<tr>
<td>UK VAT + EORI</td><td>HMRC</td><td>REST</td><td>None</td><td>Different API entirely, JSON responses</td></tr>
<tr>
<td>Company data (LEI)</td><td>GLEIF</td><td>REST</td><td>None</td><td>Free, reliable, but only for LEI holders</td></tr>
<tr>
<td>IBAN validation</td><td>MOD 97 algorithm</td><td>Offline</td><td>N/A</td><td>Deterministic, but bank lookup needs a registry</td></tr>
</tbody>
</table>
</div><p>If you are building a fintech KYB flow, you need at least three of these. If you are building an e-commerce checkout with EU tax compliance, you need four. If you are doing cross-border logistics, you need all five.</p>
<p>Each integration takes 2-4 days to build properly (with error handling, retries, caching). That is 10-20 days of work before you even start on your actual product. And then you maintain five integrations forever.</p>
<h2 id="heading-why-existing-solutions-did-not-work">Why existing solutions did not work</h2>
<p>I looked at every competitor I could find. Over 30 of them. The landscape breaks into three categories:</p>
<p><strong>VAT-only tools</strong> (Vatstack, Vatchecker, Abstract VAT): They handle VAT validation well but do not touch IBAN, EORI, or company data. You still need three more integrations.</p>
<p><strong>IBAN-only tools</strong> (Ibanapi.com, openiban.com): Same problem from the other direction. You get IBAN validation but nothing else.</p>
<p><strong>Expensive unified tools</strong> (Surepass, Veriphy): They cover multiple data types but price at enterprise levels. A startup doing 5,000 validations per month does not need a sales call and a custom contract.</p>
<p>The gap was clear: a unified API that covers all five validation types, at a price point that makes sense for indie developers and small SaaS teams, with reliability engineering that accounts for the fact that government APIs go down constantly.</p>
<h2 id="heading-architecture-decisions">Architecture decisions</h2>
<h3 id="heading-why-fastapi">Why FastAPI</h3>
<p>FastAPI was the obvious choice for three reasons:</p>
<ol>
<li><p><strong>Async-native</strong>: VIES calls take 200-2000ms. If you are handling concurrent requests (and you are, since this is an API), blocking on each SOAP call kills throughput. FastAPI's async support lets us run VIES calls in thread executors without blocking the event loop.</p>
</li>
<li><p><strong>Auto-generated OpenAPI 3.1 docs</strong>: The API spec is the product. Auto-generated Swagger and Redoc documentation mean the docs are always in sync with the code. No separate doc maintenance.</p>
</li>
<li><p><strong>Pydantic v2 for request/response models</strong>: Every response model is typed. Every error follows RFC 7807. The schema drives SDK generation. One source of truth.</p>
</li>
</ol>
<h3 id="heading-per-country-circuit-breakers">Per-country circuit breakers</h3>
<p>This was the most important architectural decision. VIES routes requests to 27 national backends. Germany goes down frequently. If you use a single circuit breaker for all of VIES, Germany being down trips the breaker and blocks France, Netherlands, and everyone else.</p>
<p>We run 28 independent VIES breakers (one per member state, with <code>GR</code> and <code>EL</code> aliased since they are the same backend). Plus one for EC EORI and one for HMRC UK. Thirty breakers total.</p>
<pre><code class="lang-python">EU_COUNTRY_CODES: list[str] = [
    <span class="hljs-string">"AT"</span>, <span class="hljs-string">"BE"</span>, <span class="hljs-string">"BG"</span>, <span class="hljs-string">"CY"</span>, <span class="hljs-string">"CZ"</span>, <span class="hljs-string">"DE"</span>, <span class="hljs-string">"DK"</span>, <span class="hljs-string">"EE"</span>, <span class="hljs-string">"EL"</span>, <span class="hljs-string">"ES"</span>,
    <span class="hljs-string">"FI"</span>, <span class="hljs-string">"FR"</span>, <span class="hljs-string">"GR"</span>, <span class="hljs-string">"HR"</span>, <span class="hljs-string">"HU"</span>, <span class="hljs-string">"IE"</span>, <span class="hljs-string">"IT"</span>, <span class="hljs-string">"LT"</span>, <span class="hljs-string">"LU"</span>, <span class="hljs-string">"LV"</span>,
    <span class="hljs-string">"MT"</span>, <span class="hljs-string">"NL"</span>, <span class="hljs-string">"PL"</span>, <span class="hljs-string">"PT"</span>, <span class="hljs-string">"RO"</span>, <span class="hljs-string">"SE"</span>, <span class="hljs-string">"SI"</span>, <span class="hljs-string">"SK"</span>,
]

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">CircuitBreakerRegistry</span>:</span>
    <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">__init__</span>(<span class="hljs-params">self</span>):</span>
        self._vies_breakers = {}
        <span class="hljs-keyword">for</span> cc <span class="hljs-keyword">in</span> EU_COUNTRY_CODES:
            self._vies_breakers[cc] = _make_breaker(<span class="hljs-string">f"vies_<span class="hljs-subst">{cc}</span>"</span>)
        <span class="hljs-comment"># Greece: GR and EL are the same backend</span>
        self._vies_breakers[<span class="hljs-string">"GR"</span>] = self._vies_breakers[<span class="hljs-string">"EL"</span>]
        self._eori_breaker = _make_breaker(<span class="hljs-string">"eori_ec"</span>)
        self._hmrc_breaker = _make_breaker(<span class="hljs-string">"hmrc_uk"</span>)
</code></pre>
<p>Each breaker trips after 5 consecutive failures and probes after 60 seconds. State transitions are logged for observability.</p>
<h3 id="heading-dual-layer-cache">Dual-layer cache</h3>
<p>Redis for hot reads (sub-millisecond). PostgreSQL for persistence (survives restarts). The lookup chain:</p>
<ol>
<li>Check Redis (fast path, handles 90%+ of repeat queries)</li>
<li>Check PostgreSQL (warm path, catches Redis misses after restart)</li>
<li>Call upstream (slow path, only on cache miss)</li>
</ol>
<p>VAT data is cached for 24 hours. IBAN validation is purely offline, so there is no cache -- it is always instant. GLEIF data is cached for 7 days (it changes rarely). Bank registry data is cached for 30 days (updated monthly from Bundesbank).</p>
<h3 id="heading-confidence-scoring">Confidence scoring</h3>
<p>Every response includes a confidence level. This was inspired by how financial systems handle data quality -- you never just say "valid" or "invalid" without context.</p>
<p>The scoring rules:</p>
<ul>
<li><strong>HIGH</strong>: Live upstream response, or deterministic offline check (IBAN MOD 97), or cached data under 1 hour old</li>
<li><strong>MEDIUM</strong>: Cached data between 1-24 hours old</li>
<li><strong>LOW</strong>: Cached data over 24 hours old</li>
<li><strong>UNKNOWN</strong>: Upstream unreachable, no cache exists</li>
</ul>
<p>Cross-referencing can promote MEDIUM to HIGH when two independent sources (e.g., VIES and GLEIF) agree on an entity. But it never promotes LOW or UNKNOWN -- stale is stale regardless of how many stale sources agree.</p>
<p>This is the feature that makes EuroValidate different from a simple wrapper. Clients can build decision trees:</p>
<pre><code><span class="hljs-keyword">if</span> confidence == HIGH: auto-approve
<span class="hljs-keyword">if</span> confidence == MEDIUM: auto-approve <span class="hljs-keyword">with</span> note
<span class="hljs-keyword">if</span> confidence == LOW: queue <span class="hljs-keyword">for</span> manual review
<span class="hljs-keyword">if</span> confidence == UNKNOWN: reject or retry later
</code></pre><h2 id="heading-tech-stack-and-costs">Tech stack and costs</h2>
<p>The full stack:</p>
<pre><code>Python <span class="hljs-number">3.12</span>+ / FastAPI (<span class="hljs-keyword">async</span>, auto OpenAPI <span class="hljs-number">3.1</span>)
zeep (VIES + EORI SOAP)
httpx (GLEIF + HMRC <span class="hljs-keyword">async</span> REST)
Redis <span class="hljs-number">7</span> (hot cache)
PostgreSQL <span class="hljs-number">16</span> (persistent cache, API keys, usage logs)
pybreaker (circuit breakers)
tenacity (retry <span class="hljs-keyword">with</span> exponential backoff + jitter)
Stripe Billing Meters (usage-based billing)
Hetzner CX22 (hosting)
Cloudflare (CDN, DDoS, SSL)
</code></pre><p>Monthly cost at launch:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Item</td><td>Cost</td></tr>
</thead>
<tbody>
<tr>
<td>Hetzner CX22 (2 vCPU, 4GB RAM)</td><td>~7.50 EUR</td></tr>
<tr>
<td>Cloudflare (free plan)</td><td>0 EUR</td></tr>
<tr>
<td>Domain</td><td>~1 EUR/mo amortized</td></tr>
<tr>
<td>Stripe</td><td>0 EUR (pay-as-you-go)</td></tr>
<tr>
<td>Sentry (free tier)</td><td>0 EUR</td></tr>
<tr>
<td>Uptime Robot (free tier)</td><td>0 EUR</td></tr>
<tr>
<td><strong>Total</strong></td><td><strong>~10 EUR/mo</strong></td></tr>
</tbody>
</table>
</div><p>All upstream data sources are free. VIES, EORI, HMRC, GLEIF -- all government or public-interest APIs with no usage fees. The business model is API arbitrage: free government data in, reliable paid API out. The margin is essentially the reliability engineering.</p>
<h2 id="heading-the-iban-validator-435-lines-of-offline-correctness">The IBAN validator: 435 lines of offline correctness</h2>
<p>The IBAN validator deserves its own mention because it demonstrates a different approach. Unlike VIES (which requires network calls to unreliable SOAP services), IBAN validation is entirely offline and deterministic.</p>
<p>The validator implements:</p>
<ol>
<li><strong>Format normalization</strong>: Strip spaces, dashes, uppercase</li>
<li><strong>Country-specific length validation</strong>: 88 countries supported (from the SWIFT IBAN Registry)</li>
<li><strong>MOD 97 check digit verification</strong>: ISO 7064 algorithm</li>
<li><strong>BBAN extraction</strong>: Country-specific parsing rules</li>
<li><strong>Bank lookup</strong>: BIC and bank name from an in-memory registry of 130 EU banks across 27 countries</li>
<li><strong>SEPA reachability</strong>: Is this IBAN in the Single Euro Payments Area?</li>
</ol>
<p>A single function call returns all of this:</p>
<pre><code class="lang-python">result = validate_iban(<span class="hljs-string">"NL91ABNA0417164300"</span>)
<span class="hljs-comment"># IbanResult(</span>
<span class="hljs-comment">#   valid=True,</span>
<span class="hljs-comment">#   country="NL",</span>
<span class="hljs-comment">#   check_digits="91",</span>
<span class="hljs-comment">#   bban="ABNA0417164300",</span>
<span class="hljs-comment">#   bic="ABNANL2AXXX",</span>
<span class="hljs-comment">#   bank_name="ABN AMRO Bank N.V.",</span>
<span class="hljs-comment">#   bank_city="Amsterdam",</span>
<span class="hljs-comment">#   sepa_reachable=True</span>
<span class="hljs-comment"># )</span>
</code></pre>
<p>No network call. No cache. No retry. No circuit breaker. Always HIGH confidence. Response time: effectively zero (CPU computation only).</p>
<p>The bank registry covers all 27 EU member states plus the UK. Each country has 3-6 major clearing banks mapped to their BIC codes. The extraction rules are country-specific -- Germany uses an 8-digit Bankleitzahl, France uses a 5-digit code banque, Netherlands uses 4-letter bank codes, Italy skips the first character (CIN check digit) before extracting the ABI code.</p>
<h2 id="heading-the-greece-bug-and-other-war-stories">The Greece bug and other war stories</h2>
<p>The Greece <code>EL</code> vs <code>GR</code> issue is a rite of passage for anyone integrating with VIES. ISO 3166-1 says Greece is <code>GR</code>. VIES says <code>EL</code> (from Ellada, the Greek name). If you send <code>GR</code> to VIES, you get <code>INVALID_INPUT</code>. If you store <code>EL</code> in your database and join it with anything that uses ISO country codes, your joins break.</p>
<p>We handle this with bidirectional mapping at the boundary:</p>
<pre><code class="lang-python">_COUNTRY_TO_VIES: dict[str, str] = {<span class="hljs-string">"GR"</span>: <span class="hljs-string">"EL"</span>}
_VIES_TO_COUNTRY: dict[str, str] = {<span class="hljs-string">"EL"</span>: <span class="hljs-string">"GR"</span>}
</code></pre>
<p>Input gets mapped to VIES format. Output gets mapped back to ISO. The rest of the system only sees <code>GR</code>. This seems trivial until you realize that the circuit breaker registry also needs to alias <code>GR</code> and <code>EL</code> to the same breaker instance, not two separate ones.</p>
<p>Other edge cases we handle:</p>
<ul>
<li><strong>Germany and Spain</strong> never return company names (national law). We return <code>null</code> instead of empty strings and fall back to GLEIF for company identity.</li>
<li><strong>Northern Ireland</strong> has dual status: XI for EU customs, GB for UK VAT. Different validation systems, different protocols.</li>
<li><strong>Monaco</strong> uses French VAT prefixes (FR). <strong>Canary Islands</strong> are outside the EU VAT area despite being Spanish territory.</li>
<li><strong>VIES returns "---"</strong> as a literal string when data is unavailable. Three dashes. Not null. Not empty string. Three dashes.</li>
</ul>
<h2 id="heading-solo-founder-what-i-would-do-differently">Solo founder: what I would do differently</h2>
<p><strong>Ship the landing page first.</strong> I built the API before the landing page. I should have put up a landing page with an email capture, validated demand with SEO content about VIES reliability problems, and then built the API. The content marketing pipeline should start before the code.</p>
<p><strong>Start with fewer endpoints.</strong> EuroValidate launched with VAT, IBAN, EORI, company lookup, VAT rates, batch processing, monitoring webhooks, and a status endpoint. That is eight endpoints. I should have launched with VAT and IBAN only, gotten paying customers, then expanded. Two endpoints that work perfectly beat eight that are untested in production.</p>
<p><strong>Invest in test data earlier.</strong> EU validation has dozens of edge cases (Greece codes, Northern Ireland, territorial exceptions). I should have built a comprehensive test data matrix on day one instead of discovering edge cases during integration testing.</p>
<h2 id="heading-what-is-next">What is next</h2>
<p>The roadmap is driven by EU regulatory changes and developer requests:</p>
<p><strong>ViDA compliance</strong> (VAT in the Digital Age): The EU is rolling out mandatory e-invoicing and real-time digital reporting. EuroValidate will provide ViDA-ready validation endpoints that check whether a transaction meets the new requirements before you submit it to your tax authority.</p>
<p><strong>GLEIF bulk import</strong>: Pre-warming the cache with the entire GLEIF database (~2.5M entities with LEIs). This means company lookups for LEI holders will always be instant, never hitting the upstream API.</p>
<p><strong>Go SDK</strong>: Python and Node.js SDKs are generated from the OpenAPI spec. Go is next, followed by PHP. The spec-first approach means SDK generation is mostly automated.</p>
<p><strong>Expanded bank registry</strong>: The current 130-bank registry covers the major clearing institutions. We are working on importing the full Bundesbank SCL-Directory (thousands of German banks) and equivalent registries from other countries.</p>
<h2 id="heading-the-numbers">The numbers</h2>
<p>As of launch:</p>
<ul>
<li><strong>130 EU banks</strong> in the IBAN registry across 27 countries</li>
<li><strong>30 circuit breakers</strong> (28 VIES + 1 EORI + 1 HMRC)</li>
<li><strong>88 countries</strong> supported for IBAN length validation</li>
<li><strong>38 SEPA countries</strong> in the reachability check</li>
<li><strong>4 confidence levels</strong> with deterministic scoring</li>
<li><strong>3 retry attempts</strong> with exponential backoff + jitter for transient VIES errors</li>
<li><strong>10 concurrent</strong> VIES threads (bounded pool to avoid contributing to MS_MAX_CONCURRENT_REQ)</li>
<li><strong>~10 EUR/mo</strong> infrastructure cost</li>
</ul>
<p>The business model is straightforward. Free tier at 100 calls/month for testing. Paid tiers from $19/mo (5,000 calls) to $149/mo (100,000 calls). Enterprise above that. Usage-based billing through Stripe Billing Meters.</p>
<p>The gross margin is 95%+ because every upstream data source is free. You are paying for the reliability layer: caching, circuit breakers, confidence scoring, graceful degradation, unified JSON responses, and the engineering time spent handling every VIES edge case so you do not have to.</p>
<h2 id="heading-try-it">Try it</h2>
<p>EuroValidate is live at <a target="_blank" href="https://eurovalidate.com">eurovalidate.com</a>. API documentation at <a target="_blank" href="https://api.eurovalidate.com/docs">api.eurovalidate.com/docs</a>. Free tier, no credit card required.</p>
<p>If you have built VIES integrations before, you know the pain. If you have not, you now know what to expect. Either way, you should not have to write another SOAP client in 2026.</p>
]]></content:encoded></item></channel></rss>