<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Iurii Rogulia</title>
    <description>The latest articles on DEV Community by Iurii Rogulia (@iurii_rogulia).</description>
    <link>https://dev.to/iurii_rogulia</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3561015%2Fd1b53175-2e87-4fa8-9c54-90f6b713141b.jpg</url>
      <title>DEV Community: Iurii Rogulia</title>
      <link>https://dev.to/iurii_rogulia</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/iurii_rogulia"/>
    <language>en</language>
    <item>
      <title>A Green Test Can Still Miss the Race: Testing Concurrency in PostgreSQL</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Wed, 14 Oct 2026 10:01:09 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/a-green-test-can-still-miss-the-race-testing-concurrency-in-postgresql-4ndd</link>
      <guid>https://dev.to/iurii_rogulia/a-green-test-can-still-miss-the-race-testing-concurrency-in-postgresql-4ndd</guid>
      <description>&lt;p&gt;An invoice is open. A charging worker checks it and prepares to take payment. At the same time, a write-off operation checks that no payment is in flight and prepares to credit the invoice. Each check can pass before either operation writes its decision.&lt;/p&gt;

&lt;p&gt;The result could be money moving against an invoice that has just been closed. This is a failure scenario, not a claim that a customer experienced it. It is the kind of race &lt;a href="https://iurii.rogulia.fi/projects/kri-beauty-booking-saas" rel="noopener noreferrer"&gt;KRI&lt;/a&gt;, the booking SaaS I built for beauty businesses, guards against with a shared per-tenant PostgreSQL advisory lock.&lt;/p&gt;

&lt;p&gt;Testing that guard raises a harder question: did the database actually block the competing operation, or did that operation simply start too late to cause trouble?&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Events a Concurrency Test Must Distinguish
&lt;/h2&gt;

&lt;p&gt;A useful test controls or observes three separate events:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The first transaction has acquired the lock.&lt;/li&gt;
&lt;li&gt;The competing operation has reached that same lock and is waiting.&lt;/li&gt;
&lt;li&gt;The first transaction changes the state and releases the lock; the competitor resumes and reacts to the new state.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Starting two promises establishes none of those events by itself. Even starting a transaction first does not prove it acquired a connection, began, or reached its lock statement before the next function ran.&lt;/p&gt;

&lt;p&gt;KRI’s existing billing suites combine held transactions, provider stubs and short timed waits. Reviewing them exposed two limits: the first lock acquisition needs its own readiness signal, and a timed negative assertion does not establish the second event. The pattern below adds the readiness signal; the following section explains what is still needed for a stronger test. It is an improved test sketch, not a claim that the existing suite already implements every step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal That the Lock Is Held Before Starting the Competitor
&lt;/h2&gt;

&lt;p&gt;A gate is a promise the test resolves itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;open&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wait&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;open&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use two gates: one reports readiness, the other controls release. The fixture and full charge arguments are omitted here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;acquired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;release&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;held&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`SELECT pg_advisory_xact_lock(hashtext(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;billing:tenant:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;))`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;acquired&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;release&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;charging&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;ReturnType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;chargeInvoice&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// If the transaction fails before acquiring the lock, propagate that error.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;race&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;acquired&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;held&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="nx"&gt;charging&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;chargeInvoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chargeArgs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Observe this operation waiting on this lock before changing the fixture.&lt;/span&gt;
  &lt;span class="c1"&gt;// The database observation needed here is described below.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;waitForChargeToBlock&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chargeRecurring&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toHaveBeenCalled&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;billingDocuments&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cancelled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;settlementState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hold&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;billingDocuments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="nx"&gt;release&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;held&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;charging&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;skipped&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chargeRecurring&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toHaveBeenCalled&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;release&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allSettled&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;held&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;charging&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;charging&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[])]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fixture update deliberately uses a separate connection. It simulates the state the competing operation must see after the lock is released; it does not exercise the real credit-note function. That function needs its own tests. The &lt;code&gt;finally&lt;/code&gt; block ensures an assertion failure does not leave the held transaction parked indefinitely. Database statement and lock timeouts should also bound failures in the test environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observe Blocking Instead of Assuming Enough Time Passed
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;waitForChargeToBlock()&lt;/code&gt; above is a test-harness requirement, not a built-in Drizzle helper. Its implementation must identify the backend running this particular charge transaction. A test-only hook can report its &lt;code&gt;pg_backend_pid()&lt;/code&gt; before lock acquisition; another connection can then inspect that backend.&lt;/p&gt;

&lt;p&gt;PostgreSQL reports advisory-lock waits through &lt;code&gt;pg_stat_activity&lt;/code&gt; as &lt;code&gt;wait_event_type = 'Lock'&lt;/code&gt; and &lt;code&gt;wait_event = 'advisory'&lt;/code&gt;. Check the target PID and use &lt;code&gt;pg_blocking_pids(target_pid)&lt;/code&gt; to confirm that the holder is the test’s transaction. An unrelated session waiting on some other advisory lock is not evidence. Both facilities are documented in &lt;a href="https://www.postgresql.org/docs/current/monitoring-stats.html" rel="noopener noreferrer"&gt;PostgreSQL’s monitoring guide&lt;/a&gt; and &lt;a href="https://www.postgresql.org/docs/current/functions-info.html" rel="noopener noreferrer"&gt;system information functions&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Poll with a deadline and fail if the expected state never appears. A short delay between polls only limits query frequency; it is the observed database state that satisfies the assertion. Size the pool for the holder, the competing transaction and the observer/update connection. With only two connections, the test can exhaust its own pool before it ever releases the gate.&lt;/p&gt;

&lt;p&gt;The existing suite instead waits 400 milliseconds and asserts that the provider has not been called. On a slow runner, that can pass because the charge has not reached the lock yet. Its final &lt;code&gt;skipped&lt;/code&gt; result can also pass after the fixture changes, even if the lock was removed. A readiness gate fixes the first ordering problem; observing the blocked backend closes this second gap.&lt;/p&gt;

&lt;p&gt;As a sanity check, deliberately remove the guard in a disposable test change: the test should fail because the expected blocking never occurs. A concurrency test that stays green when its protection disappears is testing the wrong thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Reverse Direction: Money May Already Be Moving
&lt;/h2&gt;

&lt;p&gt;Once a charge reaches the provider, crediting the invoice must account for the possibility that payment will succeed. Here a provider stub gives the test a precise event to wait for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;answered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;chargeRecurring&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mockImplementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;answered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;providerOpenPaymentFixture&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;charging&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;chargeInvoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chargeArgs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;race&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;charging&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chargeRecurring&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveBeenCalledTimes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;issueCreditNote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;issuedOn&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;rejects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toThrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/in flight/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;answered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;charging&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;KRI records the pending attempt before making that call. The credit-note operation takes the shared lock, reads the attempt and refuses to close the invoice while money may be moving. The stub controls the network boundary while the database operations remain real.&lt;/p&gt;

&lt;p&gt;Reconciliation uses another controlled boundary. A stubbed provider-history response can perform a competing settlement write before returning. The test then checks that reconciliation does not overwrite a newly settled attempt with a stale conclusion that no payment was found.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep PostgreSQL Real, Keep Stress Tests in Their Place
&lt;/h2&gt;

&lt;p&gt;The lock manager, transaction snapshots and partial unique index that permits one live payment attempt per invoice are PostgreSQL behaviour. A mocked database cannot demonstrate that those protections work together. Stub the provider’s network calls; run the transactions against an isolated real database.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;Promise.allSettled&lt;/code&gt; loop is still useful alongside controlled tests. It can check that charging and crediting never both succeed, or that cancellation and renewal produce one allowed outcome. It cannot guarantee that any run reached the dangerous ordering. Ten green runs are evidence about those ten runs, not proof that a narrow race is impossible.&lt;/p&gt;

&lt;p&gt;Use this machinery where an ordering mistake has a meaningful consequence: billing, limited inventory or booking slots. A conditional atomic update may remove a particular check-then-write gap, though workflows around it can still race. One administrator or one scheduled job is not proof of serial execution: retries, multiple tabs and overlapping job runs can still produce concurrent requests.&lt;/p&gt;

&lt;p&gt;The useful promise of a concurrency test is specific: it establishes the ordering being tested, observes the guard, and checks the result after release. If one of those steps is inferred from elapsed time, say so.&lt;/p&gt;




&lt;p&gt;Building a billing or booking feature? &lt;a href="https://iurii.rogulia.fi/contact" rel="noopener noreferrer"&gt;Get in touch&lt;/a&gt; to scope the failure cases and verification alongside the &lt;a href="https://iurii.rogulia.fi/services/mvp-development" rel="noopener noreferrer"&gt;MVP implementation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Further reading:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://iurii.rogulia.fi/projects/kri-beauty-booking-saas" rel="noopener noreferrer"&gt;KRI: booking websites for beauty businesses&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://iurii.rogulia.fi/blog/oversell-prevention-inventory-locks" rel="noopener noreferrer"&gt;Inventory locks under concurrent checkouts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://iurii.rogulia.fi/blog/idempotency-keys-api-retries" rel="noopener noreferrer"&gt;Idempotency keys and safe retries&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>typescript</category>
      <category>node</category>
      <category>drizzle</category>
      <category>postgres</category>
    </item>
    <item>
      <title>VAT Number Regex Isn't Validation: What Format Checks Miss</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Wed, 14 Oct 2026 09:00:54 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/vat-number-regex-isnt-validation-what-format-checks-miss-11f4</link>
      <guid>https://dev.to/iurii_rogulia/vat-number-regex-isnt-validation-what-format-checks-miss-11f4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/vat-number-regex-not-validation" rel="noopener noreferrer"&gt;vatnode.dev&lt;/a&gt;. The version on vatnode.dev is the canonical source — refer to it for the latest content.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A regex can tell you a string looks like a VAT number. It cannot tell you the number was ever issued, and for some countries it can’t even tell you the digits are internally consistent. Three different things get called ‘VAT validation,’ and conflating them is how integrations end up either rejecting real customers or accepting garbage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a regex can actually prove
&lt;/h2&gt;

&lt;p&gt;A VAT number regex checks shape: the right country prefix, the right length, digits where digits belong, letters where letters belong. That’s it. There are three distinct outcomes once you’re dealing with a real VAT number, and a shape regex only resolves the first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Malformed&lt;/strong&gt; – wrong prefix, wrong length, wrong character types. The regex rejects it, correctly, with zero network calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Well-formed but not registered&lt;/strong&gt; – matches the shape for its country, but no tax authority has that number on file, or it was cancelled. A regex can’t see this; only a live lookup against VIES can.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Well-formed and registered&lt;/strong&gt; – matches the shape and VIES confirms it. This is the only outcome that means anything for an actual transaction.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A shape regex can only separate outcome 1 from everything else. It has no way to distinguish 2 from 3, because both are well-formed strings – the difference lives in a government database, not in the string itself. If your validation stops at the regex, you’re one keystroke-perfect fake VAT ID away from treating it as real.&lt;/p&gt;

&lt;p&gt;This is a narrower, code-level version of a point already made from the business side in &lt;a href="https://vatnode.dev/blog/vat-id-format-vs-valid" rel="noopener noreferrer"&gt;VAT ID Format vs Valid&lt;/a&gt; – read that one for the ‘what does a customer-facing form need to tell the user’ framing. This post is about what the regex itself can and can’t do, and what to call each outcome in code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The EU VAT number pattern, country by country
&lt;/h2&gt;

&lt;p&gt;There’s no single EU VAT number regex. Each of the 27 member states, plus &lt;code&gt;XI&lt;/code&gt; for Northern Ireland, has its own prefix and its own body – some fixed-length digit strings, some with a letter mixed in. &lt;code&gt;DE&lt;/code&gt; is &lt;code&gt;DE&lt;/code&gt; + 9 digits. &lt;code&gt;IE&lt;/code&gt; has three live shapes at once (see the &lt;a href="https://vatnode.dev/blog/validate-irish-vat-number-nodejs" rel="noopener noreferrer"&gt;Irish VAT number guide&lt;/a&gt; for that one specifically). The full per-country table, with an example for every country, is in the &lt;a href="https://vatnode.dev/guides/eu-vat-number-formats" rel="noopener noreferrer"&gt;EU VAT number formats guide&lt;/a&gt; – this post won’t reproduce it.&lt;/p&gt;

&lt;p&gt;At the code level, this pattern set isn’t a private lookup table. It’s published as the open-source &lt;a href="https://vatnode.dev/vat-rates" rel="noopener noreferrer"&gt;&lt;code&gt;eu-vat-rates-data&lt;/code&gt;&lt;/a&gt; npm package, and the same per-country pattern comes back from the vatnode API itself – any &lt;code&gt;GET /v1/vat/{vatId}&lt;/code&gt; response carries it under &lt;code&gt;countryVat.vatNumberPattern&lt;/code&gt; (illustrative, trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"countryVat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"vatNumberPattern"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^DE&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;d{9}$"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both come from the same dataset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;validateFormat&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eu-vat-rates-data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;validateFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DE123456789&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// true  — matches DE's pattern&lt;/span&gt;
&lt;span class="nf"&gt;validateFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DE12345&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// false — too short&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One prefix to get right: Greece’s VIES prefix is &lt;code&gt;EL&lt;/code&gt;, not the ISO code &lt;code&gt;GR&lt;/code&gt; – &lt;code&gt;validateFormat('EL123456789')&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;validateFormat('GR123456789')&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;, because &lt;code&gt;GR&lt;/code&gt; isn’t a VIES prefix at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a check digit fits in
&lt;/h2&gt;

&lt;p&gt;Some national VAT number formats embed an arithmetic check digit – a letter or digit computed from the rest of the number, so a single mistyped character usually produces an internally inconsistent string rather than a different valid one. A shape regex doesn’t compute that arithmetic; it only checks that a character sits in the right position and is the right type (digit vs. letter). A string with the wrong check digit but the right shape – right prefix, right length, a letter where a letter goes – passes a shape regex exactly as well as one with the correct check digit.&lt;/p&gt;

&lt;p&gt;The local format check here is shape-only – the regexes in &lt;a href="https://vatnode.dev/vat-rates" rel="noopener noreferrer"&gt;&lt;code&gt;eu-vat-rates-data&lt;/code&gt;&lt;/a&gt;, and the pattern vatnode validates against locally, don’t compute any check digit arithmetic. That’s a real gap: a well-formed string with the wrong check digit passes &lt;code&gt;validateFormat()&lt;/code&gt; cleanly. The regex can’t catch it, and neither can vatnode’s local check. Whatever VIES answers comes back as one of the two well-formed responses described below, never the local 400.&lt;/p&gt;

&lt;p&gt;That split is exactly why the result is bucketed separately from ‘registered.’ Several of the country-specific posts in this series walk through one national check digit algorithm each, with the real formula – see &lt;a href="https://vatnode.dev/blog/validate-irish-vat-number-nodejs" rel="noopener noreferrer"&gt;Ireland’s mod-23 check letter&lt;/a&gt;, &lt;a href="https://vatnode.dev/blog/validate-italian-vat-number-nodejs" rel="noopener noreferrer"&gt;Italy’s check digit&lt;/a&gt;, or &lt;a href="https://vatnode.dev/blog/validate-belgian-vat-number-nodejs" rel="noopener noreferrer"&gt;Belgium’s mod-97 check&lt;/a&gt;. This post won’t repeat those formulas, and you rarely need them: a string with a wrong check digit isn’t a number anyone was issued, so the live check returns it as one of the well-formed outcomes below without you implementing anything. The country posts are there if you want the arithmetic as a free local pre-filter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validating in JavaScript/Node
&lt;/h2&gt;

&lt;p&gt;Treat &lt;code&gt;validateFormat()&lt;/code&gt; as a cheap first gate, not the validation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;validateFormat&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eu-vat-rates-data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;preCheck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawVatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// validateFormat() uppercases internally but doesn't strip separators —&lt;/span&gt;
  &lt;span class="c1"&gt;// strip spaces, dots and hyphens before calling it.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;vatId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rawVatId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\s&lt;/span&gt;&lt;span class="sr"&gt;.-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;validateFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Malformed — reject locally, no network call.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rejected-local&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;vatId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Well-formed. Still unknown whether it's registered — that needs a live call.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;passed-local&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;vatId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;preCheck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;de 123 456 789&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// { stage: 'passed-local', vatId: 'DE123456789' }&lt;/span&gt;
&lt;span class="nf"&gt;preCheck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DE123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// { stage: 'rejected-local', vatId: 'DE123' }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A local pass means ‘worth a network call,’ nothing more. For the full client-and-server flow – debounced input, a backend proxy route, error states – see the &lt;a href="https://vatnode.dev/guides/vat-validation-nodejs" rel="noopener noreferrer"&gt;Node.js VAT validation guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three responses once you call a real check
&lt;/h2&gt;

&lt;p&gt;Calling the &lt;a href="https://vatnode.dev/vat-api" rel="noopener noreferrer"&gt;EU VAT API&lt;/a&gt; splits a request into three possible responses, and they don’t line up one-to-one with the malformed/not-registered/registered split above – the middle one is new. See the full &lt;a href="https://vatnode.dev/docs/errors" rel="noopener noreferrer"&gt;error code reference&lt;/a&gt; for every code vatnode returns, not just these three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;400, no &lt;code&gt;viesCode&lt;/code&gt; – rejected before any upstream call.&lt;/strong&gt; Format is validated locally first; a malformed VAT ID never reaches VIES and never reserves a quota slot. The request ID below is illustrative – in a real response it’s a UUID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INVALID_FORMAT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Invalid VAT ID format for DE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"requestId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"req_abc123"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;viesCode&lt;/code&gt; field on this envelope – that’s the tell that it was caught locally, before any call left vatnode’s servers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;400 with &lt;code&gt;viesCode: "INVALID_INPUT"&lt;/code&gt; – VIES itself rejects the input.&lt;/strong&gt; This is rarer – the string passes the local shape check, but VIES’s own &lt;code&gt;checkVat&lt;/code&gt; call still returns &lt;code&gt;INVALID_INPUT&lt;/code&gt; for it. This is the response a wrong check digit (or anything else VIES’s own input checks catch that a shape regex can’t) may surface as, though the code shown here doesn’t prove check digits specifically are the cause. Without a requester VAT configured on the account, this also surfaces as &lt;code&gt;INVALID_FORMAT&lt;/code&gt;, HTTP 400 – but this time with a &lt;code&gt;viesCode&lt;/code&gt; field showing the raw VIES error, and the reserved quota slot is refunded, because no valid/invalid determination was actually delivered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INVALID_FORMAT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"viesCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INVALID_INPUT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"requestId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"req_abc123"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the account has a requester VAT set in dashboard Account details, the same VIES answer is reported as &lt;code&gt;INVALID_REQUESTER&lt;/code&gt;, HTTP 422, instead – when a requester is configured, the API attributes an &lt;code&gt;INVALID_INPUT&lt;/code&gt; verdict from VIES to the requester VAT, not the target number being checked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;200 with &lt;code&gt;valid: false&lt;/code&gt; – well-formed and VIES answers.&lt;/strong&gt; This is the normal case, and it’s not an error at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"valid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"countryCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checkId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"VIES"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;valid: false&lt;/code&gt; with a &lt;code&gt;200&lt;/code&gt; status is a real, billable answer – VIES looked the number up and reports it isn’t currently registered. It’s a different thing from the first &lt;code&gt;400&lt;/code&gt; above, and code that treats every non-2xx-or-&lt;code&gt;valid:true&lt;/code&gt; response the same way will log a real ‘not registered’ verdict as if it were a client bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is there one EU VAT number regex for every country?&lt;/strong&gt;&lt;br&gt;
No. Each of the 27 member states (plus Northern Ireland’s XI prefix) has its own prefix, length and character layout, so there’s no single pattern that covers all of them – only a map of per-country patterns keyed by prefix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does a VAT number passing the regex mean its check digit is correct?&lt;/strong&gt;&lt;br&gt;
Not necessarily. A shape regex only confirms the right prefix, length and character types. Some countries embed an arithmetic check digit in that string, which a shape regex doesn’t compute – it would accept a well-formed string whose check digit is wrong just as readily as one where it’s right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does vatnode’s API return for a malformed VAT ID versus a well-formed one that isn’t registered?&lt;/strong&gt;&lt;br&gt;
A malformed VAT ID – wrong prefix, wrong length, wrong characters – gets a 400 with error code &lt;code&gt;INVALID_FORMAT&lt;/code&gt; before any upstream call, so it never touches your quota. A well-formed VAT ID that VIES reports as not currently registered gets a 200 with &lt;code&gt;valid: false&lt;/code&gt; – that’s a real, billable answer, not an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I validate the regex before calling a live API?&lt;/strong&gt;&lt;br&gt;
Yes, as a cheap first gate – it rejects typos and obvious garbage for free, before you spend a network call or a quota slot. It’s not a substitute for the live call; well-formed and registered are two different questions, and only the live call answers the second one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next step
&lt;/h2&gt;

&lt;p&gt;A &lt;a href="https://vatnode.dev/blog/vat-api-test-keys" rel="noopener noreferrer"&gt;test key&lt;/a&gt; is the cheapest way to see the envelope shape for outcome 3 before you spend a live quota slot – fixture VAT numbers under the &lt;code&gt;XX&lt;/code&gt; prefix return deterministic &lt;code&gt;valid: true&lt;/code&gt; / &lt;code&gt;valid: false&lt;/code&gt; responses with no VIES call. Test mode skips the local format check entirely, though: send it anything other than an &lt;code&gt;XX&lt;/code&gt; number and you’ll get a 400 explaining that test keys only accept &lt;code&gt;XX&lt;/code&gt; fixtures, which is a different error than outcome 1 above and isn’t a verdict on that number’s real format. &lt;a href="https://vatnode.dev/register" rel="noopener noreferrer"&gt;Register&lt;/a&gt; for a free key – free plan, 100 requests/month, no card – or try a single live lookup with no signup at &lt;a href="https://vatnode.dev/check" rel="noopener noreferrer"&gt;/check&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>tax</category>
      <category>webdev</category>
    </item>
    <item>
      <title>VAT Validation vs KYB: What Each Actually Checks</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 12 Oct 2026 09:00:54 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/vat-validation-vs-kyb-what-each-actually-checks-fm7</link>
      <guid>https://dev.to/iurii_rogulia/vat-validation-vs-kyb-what-each-actually-checks-fm7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/vat-validation-vs-kyb" rel="noopener noreferrer"&gt;vatnode.dev&lt;/a&gt;. The version on vatnode.dev is the canonical source — refer to it for the latest content.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The short answer
&lt;/h2&gt;

&lt;p&gt;VAT validation checks whether a VAT ID is currently registered, and sometimes hands back a name or address from the underlying registry. KYB (Know Your Business) checks who owns and controls the business and whether it’s clean of sanctions and regulatory flags. One is an input the other can use – neither is a substitute for the other.&lt;/p&gt;

&lt;p&gt;That distinction matters because it’s easy to build an onboarding flow where ‘the VAT number checked out’ quietly becomes the whole risk decision. It isn’t one, and it was never meant to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  What VAT validation actually checks
&lt;/h2&gt;

&lt;p&gt;A VAT validation call answers one question: does this VAT ID exist in the system of record right now. Under the hood, vatnode’s &lt;a href="https://vatnode.dev/docs" rel="noopener noreferrer"&gt;&lt;code&gt;GET /v1/vat/:vatId&lt;/code&gt;&lt;/a&gt; calls VIES (the EU Commission’s lookup service) and, where VIES is unavailable, a national VAT-register fallback for the countries that have one.&lt;/p&gt;

&lt;p&gt;The response is a &lt;code&gt;valid&lt;/code&gt; boolean plus, where the source provides them, registry fields: &lt;code&gt;companyName&lt;/code&gt;, &lt;code&gt;companyAddress&lt;/code&gt;, &lt;code&gt;companyRegistrationDate&lt;/code&gt;, &lt;code&gt;companyForm&lt;/code&gt;, &lt;code&gt;industryDescription&lt;/code&gt;, &lt;code&gt;registryCode&lt;/code&gt;, &lt;code&gt;taxId&lt;/code&gt;, and a few others. None of these are guaranteed – coverage depends on what the member state’s VIES endpoint or national registry chooses to expose, and that varies by country. A &lt;code&gt;valid: true&lt;/code&gt; result with every enrichment field &lt;code&gt;null&lt;/code&gt; is a normal response.&lt;/p&gt;

&lt;p&gt;Two things &lt;code&gt;valid: true&lt;/code&gt; does &lt;em&gt;not&lt;/em&gt; mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It doesn’t mean the business is legitimate, solvent, or safe to deal with.&lt;/strong&gt; VIES confirms registration status, nothing about conduct or risk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It doesn’t mean the data is current.&lt;/strong&gt; The registration status comes from the source at check time; the enrichment fields, where present, are only as fresh as the registry behind them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the full field-by-field breakdown of what’s in the response and when each one is populated, see &lt;a href="https://vatnode.dev/blog/vat-number-company-data-enrichment" rel="noopener noreferrer"&gt;what a VAT check returns beyond valid or invalid&lt;/a&gt; – this post stays at the conceptual level: what the check is &lt;em&gt;for&lt;/em&gt;, not every field in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What KYB checks that VAT validation doesn’t
&lt;/h2&gt;

&lt;p&gt;KYB is a risk and compliance process, not a single API call. Depending on the regime a business sits in, it typically covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Beneficial ownership (UBO).&lt;/strong&gt; Who ultimately owns or controls the entity – the natural persons behind layers of holding companies, not just the registered legal name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sanctions and PEP screening.&lt;/strong&gt; Checking the entity and its known owners/directors against sanctions lists and politically-exposed-persons registers, and keeping that screening current as lists change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incorporation and supporting documents.&lt;/strong&gt; Certificates of incorporation, articles of association, proof of address, and signatory authority – verified against the document itself, not inferred from a lookup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ongoing monitoring.&lt;/strong&gt; A one-time pass at onboarding isn’t enough for most regimes; KYB programs re-screen periodically and react to triggering events.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A VAT validation response contains none of this. There’s no ownership field, no sanctions flag, no document reference – VIES and national VAT registries exist to confirm tax registration, not to run AML programs.&lt;/p&gt;

&lt;p&gt;Whether a given business needs any or all of the above depends on its own regulatory regime – this post doesn’t tell you which obligations apply to you. What it says is narrower: don’t expect a VAT check to answer that question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the two overlap
&lt;/h2&gt;

&lt;p&gt;The overlap is real, just smaller than ‘VAT check = KYB’:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Identity fields.&lt;/strong&gt; Company name, address, and sometimes registration date or legal form overlap with what a KYB process also wants to confirm about an entity – VAT validation can supply or cross-check those fields rather than asking the user to type them in. See &lt;a href="https://vatnode.dev/blog/vat-number-company-data-enrichment" rel="noopener noreferrer"&gt;the enrichment field reference&lt;/a&gt; for exactly which fields and when they’re populated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ongoing monitoring, narrowly.&lt;/strong&gt; KYB programs re-screen over time; vatnode’s own &lt;a href="https://vatnode.dev/guides/vat-monitoring-webhooks" rel="noopener noreferrer"&gt;VAT monitoring and webhooks&lt;/a&gt; (paid plans – see &lt;a href="https://vatnode.dev/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;) do the equivalent for VAT status specifically – a subscription that fires a webhook when a VAT ID becomes invalid or its registry details change. That covers the ‘did this number stop being valid’ trigger, not sanctions or ownership changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit evidence.&lt;/strong&gt; A &lt;a href="https://vatnode.dev/blog/vies-consultation-number-explained" rel="noopener noreferrer"&gt;VIES consultation number&lt;/a&gt; is evidence that a VAT check was made against VIES at a given date and time (it’s only issued when the check includes a requester VAT ID). That’s useful audit evidence for the VAT-specific part of a file, the same way a document hash or screening log is evidence for the rest of it – &lt;a href="https://vatnode.dev/blog/consultation-number-for-finance" rel="noopener noreferrer"&gt;consultation numbers for finance teams&lt;/a&gt; covers how that’s typically retained.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Past that, the two diverge. KYB owns ownership, screening and documents; VAT validation owns registration status and, where available, registry metadata.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical pattern: VAT validation as the first gate
&lt;/h2&gt;

&lt;p&gt;The combination that actually works in production is sequential, not substitutive: VAT validation runs first and cheaply, KYB runs after and only where it’s needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.vatnode.dev/v1/vat/FR12345678901 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$VATNODE_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"valid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vatId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FR12345678901"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"countryCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"countryName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"France"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"companyName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EXAMPLE SAS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"companyAddress"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1 RUE EXEMPLE 75001 PARIS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"companyRegistrationDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"companyForm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"industryDescription"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"registryCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"registryCodeName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"taxId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"taxIdName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"commercialName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"companyStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"secondaryActivities"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"companyAgeYears"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"registryPrivacy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checkId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a1b2c3d4-5678-4e9f-9a1b-2c3d4e5f6a7b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"verifiedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-05T09:00:00.000Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"VIES"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"consultationNumber"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"specialTerritory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in that response touches ownership, sanctions or document data – there’s no field for it. A workflow that uses this as the first gate typically:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Calls &lt;code&gt;/v1/vat/:vatId&lt;/code&gt; when a business enters its VAT ID at signup.&lt;/li&gt;
&lt;li&gt;Blocks or flags on &lt;code&gt;valid: false&lt;/code&gt; before any heavier check runs.&lt;/li&gt;
&lt;li&gt;Pre-fills what’s available (&lt;code&gt;companyName&lt;/code&gt;, &lt;code&gt;companyAddress&lt;/code&gt;) to cut manual entry.&lt;/li&gt;
&lt;li&gt;Routes accounts that pass into whatever UBO, sanctions and document checks your own KYB process requires – the VAT result doesn’t decide which accounts can skip them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 2 is where a VAT check earns its place: a fast, inexpensive filter that catches obviously bad input before anyone spends time on a full KYB check. It’s not a replacement for step 4.&lt;/p&gt;

&lt;p&gt;This is general information about VAT validation and KYB concepts, not legal or compliance advice – which obligations apply to a given business is a question for counsel, not an API response.&lt;/p&gt;

&lt;p&gt;Start with the &lt;a href="https://vatnode.dev/register" rel="noopener noreferrer"&gt;free plan&lt;/a&gt; – 100 requests/month, no card required – and read the &lt;a href="https://vatnode.dev/docs" rel="noopener noreferrer"&gt;full API reference&lt;/a&gt; for the exact fields the endpoint returns. For teams running this at volume, &lt;a href="https://vatnode.dev/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; covers the paid tiers.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does VAT validation satisfy KYB requirements?
&lt;/h3&gt;

&lt;p&gt;No. VAT validation confirms a VAT ID is currently registered, and sometimes returns registry fields like company name or address. It does not check beneficial ownership, sanctions or PEP status, or incorporation documents, so it cannot stand in for a KYB process on its own – it can only feed one.&lt;/p&gt;

&lt;h3&gt;
  
  
  What’s the difference between KYB and KYC?
&lt;/h3&gt;

&lt;p&gt;KYC (Know Your Customer) verifies an individual’s identity. KYB (Know Your Business) verifies a legal entity – its registration, structure, beneficial owners, and whether it or the people behind it appear on sanctions or PEP lists. Where a KYB obligation applies, it usually extends to identifying and verifying the people who own or control the business.&lt;/p&gt;

&lt;h3&gt;
  
  
  What data does a vatnode VAT check return?
&lt;/h3&gt;

&lt;p&gt;A boolean validity result plus, where the source provides them, registry fields such as company name, address, registration date, legal form and industry. It does not return ownership structure, sanctions or PEP matches, or any document data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can VAT validation be automated as part of a KYB workflow?
&lt;/h3&gt;

&lt;p&gt;Yes. Calling the validation endpoint at signup and gating or flagging on the result is a common first step in an automated KYB pipeline – before routing to UBO, sanctions and document checks.&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  Validate the VAT ID before anything heavier
&lt;/h3&gt;

&lt;p&gt;One vatnode call checks a VAT ID against VIES and national fallbacks, with registry enrichment where available. Use it as the first gate in your onboarding – not the whole decision. Start with the &lt;a href="https://vatnode.dev/vat-api" rel="noopener noreferrer"&gt;EU VAT validation API&lt;/a&gt; or &lt;a href="https://vatnode.dev/vies-api" rel="noopener noreferrer"&gt;plain VIES-backed validation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://vatnode.dev/register" rel="noopener noreferrer"&gt;Get a free API key&lt;/a&gt; – free plan, 100 requests/month.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>tax</category>
      <category>tutorial</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Validate Irish VAT Numbers in Node.js</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Fri, 09 Oct 2026 09:00:57 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/validate-irish-vat-numbers-in-nodejs-1kig</link>
      <guid>https://dev.to/iurii_rogulia/validate-irish-vat-numbers-in-nodejs-1kig</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/validate-irish-vat-number-nodejs" rel="noopener noreferrer"&gt;vatnode.dev&lt;/a&gt;. The version on vatnode.dev is the canonical source — refer to it for the latest content.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://vatnode.dev/check/ie" rel="noopener noreferrer"&gt;Ireland&lt;/a&gt; has issued VAT numbers in three different shapes over the years. The Irish VAT number format you’ll meet today is &lt;code&gt;IE&lt;/code&gt; + 7 digits + a check letter + a second letter, but a shorter version without that second letter, and an older legacy shape with a letter buried in the middle of the digits, are both still valid. Here’s how to tell them apart in code, how the mod-23 check character works across all three, and why a checksum-valid number is not the same thing as a registered one. This post covers the Node.js/JavaScript implementation; for the general EU-wide behavior of the endpoint, see the &lt;a href="https://vatnode.dev/vat-api/ie" rel="noopener noreferrer"&gt;Ireland VAT API reference&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an Irish VAT number looks like
&lt;/h2&gt;

&lt;p&gt;Three shapes are live today, all under the &lt;code&gt;IE&lt;/code&gt; prefix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Current, since 1 January 2013:&lt;/strong&gt; &lt;code&gt;IE&lt;/code&gt; + 7 digits + check letter (&lt;code&gt;A&lt;/code&gt;–&lt;code&gt;W&lt;/code&gt;) + second letter. Revenue’s practice – not a published rule – is &lt;code&gt;A&lt;/code&gt; for individuals and &lt;code&gt;H&lt;/code&gt; for non-individuals: companies, trusts, partnerships.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Older, still valid:&lt;/strong&gt; &lt;code&gt;IE&lt;/code&gt; + 7 digits + check letter, with no second letter – issued before the current format.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legacy, still valid if already held:&lt;/strong&gt; &lt;code&gt;IE&lt;/code&gt; + 1 digit + a letter, &lt;code&gt;+&lt;/code&gt;, or &lt;code&gt;*&lt;/code&gt; in the second position + 5 digits + check letter – 8 characters after &lt;code&gt;IE&lt;/code&gt;, arranged differently from the other two.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So &lt;code&gt;IE1234567WH&lt;/code&gt;, &lt;code&gt;IE1234567T&lt;/code&gt;, and &lt;code&gt;IE8D79739I&lt;/code&gt; are all legitimate shapes. Don’t reject one as malformed because it’s shorter or has a letter where you’d expect a digit – the current format is just the one you’ll see most often on new registrations. VAT number validation for Ireland also means handling that spread of shapes – for how countries differ on a single number space versus a separate VAT-specific sequence, see the &lt;a href="https://vatnode.dev/guides/eu-vat-number-formats" rel="noopener noreferrer"&gt;full EU-wide format reference&lt;/a&gt;, or the &lt;a href="https://vatnode.dev/blog/validate-portuguese-vat-number-nodejs" rel="noopener noreferrer"&gt;Portuguese&lt;/a&gt; and &lt;a href="https://vatnode.dev/blog/validate-german-vat-number-nodejs" rel="noopener noreferrer"&gt;German&lt;/a&gt; write-ups in this series for two more shapes of that split.&lt;/p&gt;

&lt;p&gt;Northern Ireland uses a separate &lt;code&gt;XI&lt;/code&gt; prefix – that’s a different number space entirely, not an Irish one, and out of scope here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Format validation in Node.js
&lt;/h2&gt;

&lt;p&gt;Reject malformed input before any network call. As with the other guides in this series, decide up front which mode you’re running in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Strict (API mode)&lt;/strong&gt; – your service documents &lt;code&gt;IE&lt;/code&gt; + one of the three shapes above. Reject anything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lenient (checkout mode)&lt;/strong&gt; – accept what customers paste. Spaces, dots, and a missing &lt;code&gt;IE&lt;/code&gt; prefix are common; normalize and log the original input.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Matches all three live shapes: current (7 digits + check letter + optional second letter)&lt;/span&gt;
&lt;span class="c1"&gt;// and legacy (digit + letter/+/* + 5 digits + check letter)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;IE_VAT_PATTERN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^IE&lt;/span&gt;&lt;span class="se"&gt;\d{7}[&lt;/span&gt;&lt;span class="sr"&gt;A-W&lt;/span&gt;&lt;span class="se"&gt;][&lt;/span&gt;&lt;span class="sr"&gt;AH&lt;/span&gt;&lt;span class="se"&gt;]?&lt;/span&gt;&lt;span class="sr"&gt;$|^IE&lt;/span&gt;&lt;span class="se"&gt;\d[&lt;/span&gt;&lt;span class="sr"&gt;A-Z+*&lt;/span&gt;&lt;span class="se"&gt;]\d{5}[&lt;/span&gt;&lt;span class="sr"&gt;A-W&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normaliseIrishVatId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;strict&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lenient&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cleaned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\s&lt;/span&gt;&lt;span class="sr"&gt;.&lt;/span&gt;&lt;span class="se"&gt;\-]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="c1"&gt;// Lenient mode: prepend IE when the user typed only the body (8 chars for old/legacy, 9 for current)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lenient&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9A-Z+*&lt;/span&gt;&lt;span class="se"&gt;]{8,9}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cleaned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`IE&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;IE_VAT_PATTERN&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;cleaned&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Strict (default) — API consumers should send a fully-qualified ID&lt;/span&gt;
&lt;span class="nf"&gt;normaliseIrishVatId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;IE 1234567 WH&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// → 'IE1234567WH'&lt;/span&gt;
&lt;span class="nf"&gt;normaliseIrishVatId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1234567WH&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// → null&lt;/span&gt;

&lt;span class="c1"&gt;// Lenient — for checkout forms&lt;/span&gt;
&lt;span class="nf"&gt;normaliseIrishVatId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1234567WH&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lenient&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="c1"&gt;// → 'IE1234567WH'&lt;/span&gt;
&lt;span class="nf"&gt;normaliseIrishVatId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;8D79739I&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lenient&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="c1"&gt;// → 'IE8D79739I' (legacy shape)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A regex match proves the shape and nothing else – not even that the check letter is arithmetically correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mod-23 check character
&lt;/h2&gt;

&lt;p&gt;All three shapes share the same modulus-23 scheme, just with different digit arrangements feeding into it. Weight the 7 digits &lt;code&gt;8, 7, 6, 5, 4, 3, 2&lt;/code&gt; left to right, sum the products, and if a second letter is present add 9 times that letter’s index in the alphabet &lt;code&gt;WABCDEFGHIJKLMNOPQRSTUV&lt;/code&gt; (&lt;code&gt;W&lt;/code&gt; = 0, &lt;code&gt;A&lt;/code&gt; = 1, … &lt;code&gt;V&lt;/code&gt; = 22). Take the total mod 23, and the check letter is the character at that index in the same alphabet.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;IE_CHECK_ALPHABET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WABCDEFGHIJKLMNOPQRSTUV&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;IE_CHECK_WEIGHTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ieCheckLetter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sevenDigits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secondLetter&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;digits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sevenDigits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;IE_CHECK_WEIGHTS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secondLetter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;IE_CHECK_ALPHABET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secondLetter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;IE_CHECK_ALPHABET&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Worked example (synthetic, current format): digits 1234567, second letter H&lt;/span&gt;
&lt;span class="c1"&gt;// products: 1×8+2×7+3×6+4×5+5×4+6×3+7×2 = 112; plus 9×8 (H is index 8) = 72 → 184&lt;/span&gt;
&lt;span class="c1"&gt;// 184 mod 23 = 0 → 'W'&lt;/span&gt;
&lt;span class="nf"&gt;ieCheckLetter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1234567&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;H&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// → 'W' → IE1234567WH&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasValidIrishCheckLetter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^IE&lt;/span&gt;&lt;span class="se"&gt;(\d{7})([&lt;/span&gt;&lt;span class="sr"&gt;A-W&lt;/span&gt;&lt;span class="se"&gt;])([&lt;/span&gt;&lt;span class="sr"&gt;AH&lt;/span&gt;&lt;span class="se"&gt;])?&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nf"&gt;ieCheckLetter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// Legacy shape: IE + digit + letter/+/* + 5 digits + check letter&lt;/span&gt;
  &lt;span class="c1"&gt;// Convert to the same 7-digit-plus-check form before checking: '0' + chars[2..6] + chars[0]&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;legacy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^IE&lt;/span&gt;&lt;span class="se"&gt;(\d)([&lt;/span&gt;&lt;span class="sr"&gt;A-Z+*&lt;/span&gt;&lt;span class="se"&gt;])(\d{5})([&lt;/span&gt;&lt;span class="sr"&gt;A-W&lt;/span&gt;&lt;span class="se"&gt;])&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;legacy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rearranged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`0&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;legacy&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]}${&lt;/span&gt;&lt;span class="nx"&gt;legacy&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;legacy&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nf"&gt;ieCheckLetter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rearranged&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Worked example (legacy format): IE8D79739I&lt;/span&gt;
&lt;span class="c1"&gt;// rearranged: '0' + '79739' + '8' = '0797398'&lt;/span&gt;
&lt;span class="c1"&gt;// products: 0×8+7×7+9×6+7×5+3×4+9×3+8×2 = 193 → 193 mod 23 = 9 → 'I'&lt;/span&gt;
&lt;span class="nf"&gt;hasValidIrishCheckLetter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;IE8D79739I&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// → true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;IE1234567WH&lt;/code&gt; above is a synthetic number built purely to demonstrate the current-format arithmetic. Neither it nor &lt;code&gt;IE8D79739I&lt;/code&gt; is an assertion that a real entity holds that number – don’t use either as a VIES test fixture.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The mod-23 check character is a &lt;strong&gt;local pre-filter, not proof of registration&lt;/strong&gt;. A checksum-valid&lt;br&gt;
  &lt;code&gt;IE&lt;/code&gt; number can still come back invalid from VIES – never issued, deregistered, or simply wrong.&lt;br&gt;
  Run the checksum to catch a mistyped character before you spend a network call; run VIES to get&lt;br&gt;
  the answer that actually matters.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  An Irish VAT number is not the CRO number
&lt;/h2&gt;

&lt;p&gt;Revenue issues a Tax Reference Number (TRN) when a business registers for tax. The CRO (Companies Registration Office) number is a different identifier, issued by a different body, at a different step: incorporation. A company gets a CRO number on incorporation, and a TRN and a VAT number separately when it registers for tax. For a sole trader, the TRN is the same as their &lt;a href="https://www.revenue.ie/en/starting-a-business/registering-for-tax/what-is-a-tax-registration-number.aspx" rel="noopener noreferrer"&gt;PPSN&lt;/a&gt;; that’s a Revenue-specific equivalence and has nothing to do with the CRO.&lt;/p&gt;

&lt;p&gt;In practice this means: don’t store a CRO number in a VAT-number field, and don’t validate a CRO number as if it were a VAT ID or vice versa. If an onboarding form asks for a ‘company registration number,’ keep it in its own field rather than folding it into whatever you pass to a VAT check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calling VIES for Ireland
&lt;/h2&gt;

&lt;p&gt;VIES routes an &lt;code&gt;IE&lt;/code&gt; query to Irish Revenue and relays back what it says. For a live check you get one of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A valid/invalid answer, currently with trader name and address attached – treat this as typically present for Ireland, not guaranteed on every request.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MS_UNAVAILABLE&lt;/code&gt; — the Irish node is temporarily unreachable.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SERVICE_UNAVAILABLE&lt;/code&gt; — VIES itself is degraded.&lt;/li&gt;
&lt;li&gt;A timeout after roughly 10 seconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is unique to Irish numbers – every member-state node can go down. The &lt;a href="https://vatnode.dev/blog/vies-downtime-guide" rel="noopener noreferrer"&gt;VIES downtime guide&lt;/a&gt; covers the failure modes worth designing around across all of them. What’s distinct about an Irish check is what happens next when the node doesn’t answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Ireland has no national fallback or enrichment
&lt;/h2&gt;

&lt;p&gt;For some member states, when the VIES node goes down, vatnode can fall back to the national VAT register to keep answering, and separately enrich a valid result with extra company data pulled from a registry. &lt;strong&gt;Ireland has neither.&lt;/strong&gt; There is no national fallback source wired into vatnode’s pipeline for &lt;code&gt;IE&lt;/code&gt; numbers, and there is no company-registry enrichment either – a result never carries a &lt;code&gt;registryCode&lt;/code&gt;, &lt;code&gt;registryCodeName&lt;/code&gt;, &lt;code&gt;taxId&lt;/code&gt;, or &lt;code&gt;taxIdName&lt;/code&gt;. Unlike some countries where the VAT body itself doubles as a published registry number that vatnode can echo back as a &lt;code&gt;registryCode&lt;/code&gt;, an Irish VAT number isn’t the CRO number, so there’s nothing to derive one from.&lt;/p&gt;

&lt;p&gt;If VIES IE is unavailable, the check simply reflects the outage – the same ‘VIES is the only authority for this country’s numbers’ reality the &lt;a href="https://vatnode.dev/blog/validate-german-vat-number-nodejs" rel="noopener noreferrer"&gt;German guide&lt;/a&gt; walks through for a different member state. Current per-country coverage – which countries have a fallback, which have enrichment, which have neither – is documented at &lt;a href="https://vatnode.dev/docs/coverage" rel="noopener noreferrer"&gt;/docs/coverage&lt;/a&gt; rather than restated here.&lt;/p&gt;

&lt;p&gt;If your business does meaningful volume with Irish counterparties, build checkout and onboarding to tolerate a &lt;code&gt;VIES_UNAVAILABLE&lt;/code&gt; response gracefully – queue and retry, don’t block – rather than assuming a fallback will quietly cover the gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full working example with the vatnode API
&lt;/h2&gt;

&lt;p&gt;vatnode normalizes the format and runs a requester-qualified VIES call to the Irish node in one request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.vatnode.dev/v1/vat/IE1234567WH&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VATNODE_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`VAT check failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="c1"&gt;// {&lt;/span&gt;
&lt;span class="c1"&gt;//   "valid": true,&lt;/span&gt;
&lt;span class="c1"&gt;//   "vatId": "IE1234567WH",&lt;/span&gt;
&lt;span class="c1"&gt;//   "countryCode": "IE",&lt;/span&gt;
&lt;span class="c1"&gt;//   "countryName": "Ireland",&lt;/span&gt;
&lt;span class="c1"&gt;//   "companyName": "Example Ltd",         // typically present, not guaranteed&lt;/span&gt;
&lt;span class="c1"&gt;//   "companyAddress": "1 Example Street, Dublin", // same — typically present, not guaranteed&lt;/span&gt;
&lt;span class="c1"&gt;//   "registryCode": null,&lt;/span&gt;
&lt;span class="c1"&gt;//   "registryCodeName": null,&lt;/span&gt;
&lt;span class="c1"&gt;//   "taxId": null,&lt;/span&gt;
&lt;span class="c1"&gt;//   "taxIdName": null,&lt;/span&gt;
&lt;span class="c1"&gt;//   "source": "VIES",&lt;/span&gt;
&lt;span class="c1"&gt;//   "consultationNumber": "WAPIAAAAX...", // only when a requester VAT is set (dashboard → Account details)&lt;/span&gt;
&lt;span class="c1"&gt;//   "checkId": "019d2a89-a5d9-7b97-b710-57b84604de2b",&lt;/span&gt;
&lt;span class="c1"&gt;//   "verifiedAt": "2026-10-02T08:30:00.000Z"&lt;/span&gt;
&lt;span class="c1"&gt;// }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify &lt;code&gt;IE1234567WH&lt;/code&gt; yourself via VIES before treating it as a live example – it’s a synthetic number chosen for the checksum walkthrough above, not an assertion that any specific entity holds it. &lt;code&gt;source&lt;/code&gt; will always read &lt;code&gt;"VIES"&lt;/code&gt; for an Irish check – there’s no &lt;code&gt;IE&lt;/code&gt; fallback source to branch on. &lt;code&gt;registryCode&lt;/code&gt; and &lt;code&gt;taxId&lt;/code&gt; stay &lt;code&gt;null&lt;/code&gt;: there’s no registry enrichment wired up for Ireland, so don’t build logic that expects them to populate.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;valid&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;, that’s VIES saying the number isn’t currently registered for intra-EU VAT – a number can be valid domestically and still come back invalid from VIES. This article is informational, not tax advice, so confirm reverse-charge treatment with a qualified adviser rather than inferring it from the check alone. The &lt;code&gt;consultationNumber&lt;/code&gt; is the VIES-issued reference covered in the &lt;a href="https://vatnode.dev/guides/vat-audit-trail" rel="noopener noreferrer"&gt;audit trail guide&lt;/a&gt;; it’s returned only when your account has a requester VAT number set in dashboard Account details – useful documentary evidence, but not something only vatnode provides.&lt;/p&gt;

&lt;p&gt;Get a free API key at &lt;a href="https://vatnode.dev/register" rel="noopener noreferrer"&gt;vatnode.dev/register&lt;/a&gt; – 100 requests/month, no card – and the full field reference is in the &lt;a href="https://vatnode.dev/docs" rel="noopener noreferrer"&gt;API docs&lt;/a&gt;. The &lt;a href="https://vatnode.dev/guides/vat-validation-nodejs" rel="noopener noreferrer"&gt;Node.js VAT validation guide&lt;/a&gt; covers the storage schema across every member state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error handling
&lt;/h2&gt;

&lt;p&gt;Treat ‘we couldn’t get an answer’ as a distinct state from ‘invalid’ – this matters more for Ireland than for a country with a fallback, because there’s no second source to absorb a VIES IE outage. The vatnode API returns explicit error codes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;INVALID_FORMAT&lt;/code&gt; (400) – the string isn’t a well-formed &lt;code&gt;IE&lt;/code&gt; VAT ID. Your input; surface it inline, don’t retry.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;INVALID_REQUESTER&lt;/code&gt; (422) – your configured requester VAT number was rejected by VIES. Fix it in dashboard Account details.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RATE_LIMITED&lt;/code&gt; (429) – you’ve spent your quota. Retryable on a longer horizon.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VIES_UNAVAILABLE&lt;/code&gt; (503) – the Irish node (or VIES itself) is down. Transient – queue a retry, never mark the number invalid.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VIES_ERROR&lt;/code&gt; (502) / &lt;code&gt;UPSTREAM_TIMEOUT&lt;/code&gt; (504) – transient upstream faults. Retry with backoff.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VIES_ERROR&lt;/code&gt; (403) – VIES reports the VAT ID or vatnode’s IP as blocked (&lt;code&gt;VAT_BLOCKED&lt;/code&gt; / &lt;code&gt;IP_BLOCKED&lt;/code&gt;). Not a verdict on the number, but not transient either – don’t retry in a loop; alert and review.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;INTERNAL_ERROR&lt;/code&gt; (500) – retry, alert if it persists.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because there’s no &lt;code&gt;IE&lt;/code&gt; fallback to fill the gap, retry with exponential backoff (500ms, 2s, 5s) and, if it still fails, queue the check for a background re-run rather than blocking checkout. The full error taxonomy and retry table is in &lt;a href="https://vatnode.dev/blog/handling-vies-errors-in-code" rel="noopener noreferrer"&gt;handling VIES error codes&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What’s the difference between the old and new Irish VAT number format?
&lt;/h3&gt;

&lt;p&gt;The new format, used since 1 January 2013, is IE followed by 7 digits, a check letter, and a second letter – Revenue’s practice, not a published rule, is to use A for individuals and H for companies, trusts and partnerships. The old format is the same but without that second letter – IE plus 7 digits and a check letter. Older still, there’s a legacy format with a letter or symbol in the second position of an 8-character body. Numbers already issued under the old or legacy formats remain valid.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does a valid mod-23 check character mean an Irish VAT number is registered?
&lt;/h3&gt;

&lt;p&gt;No. The check character only proves the digits are internally consistent – that nothing was mistyped. It’s a local pre-filter, not a registration check. Only a live VIES lookup confirms whether a number is currently registered for intra-EU VAT.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is an Irish VAT number the same as a CRO company number?
&lt;/h3&gt;

&lt;p&gt;No, and they come from two different bodies at two different steps. The Companies Registration Office issues a CRO number on incorporation. Revenue issues a Tax Reference Number when you register for tax, which is a separate step. For a sole trader, that TRN is the same as their PPSN. Don’t store a CRO number in a VAT field, and don’t validate one as if it were the other.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why doesn’t vatnode have a national fallback or enrichment for Ireland?
&lt;/h3&gt;

&lt;p&gt;Ireland is VIES-only in vatnode. There is no national tax-authority or company-registry integration wired into the fallback or enrichment path for IE numbers, so when VIES IE is unavailable there is no second source to consult – the check simply reflects the outage. See the current per-country coverage at /docs/coverage.&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  Validate Irish VAT numbers without owning the VIES IE retry logic
&lt;/h3&gt;

&lt;p&gt;vatnode normalizes the input, runs a requester-qualified VIES call to the Irish node, and returns a stable response – with the consultation number for your audit trail when a requester VAT is configured. Free plan, 100 requests/month.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://vatnode.dev/register" rel="noopener noreferrer"&gt;Get a free API key&lt;/a&gt; · &lt;a href="https://vatnode.dev/docs" rel="noopener noreferrer"&gt;API reference&lt;/a&gt; · &lt;a href="https://vatnode.dev/vat-api/ie" rel="noopener noreferrer"&gt;Ireland VAT API reference&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>tax</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Medical Claims Fraud Detection: Pharmacy and Prior-Auth PDFs</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Wed, 07 Oct 2026 10:00:38 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/medical-claims-fraud-detection-pharmacy-and-prior-auth-pdfs-3ja2</link>
      <guid>https://dev.to/iurii_rogulia/medical-claims-fraud-detection-pharmacy-and-prior-auth-pdfs-3ja2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/medical-pharmacy-document-fraud-detection" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt;. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A pharmacy benefit manager opens a desk audit on a retail pharmacy: forty claims, ninety days, one high-cost therapeutic class. The pharmacy responds the way every pharmacy responds — a zip of PDFs. Scanned hardcopy prescriptions, a signature log, wholesaler invoices, a handful of prescriber clarification notes. The audit analyst reads the documents against the claim lines: drug, strength, quantity, days supply, date written, refills authorized.&lt;/p&gt;

&lt;p&gt;On one file, the quantity on the hardcopy reads 180. The claim was billed at 180. The prescriber wrote 30.&lt;/p&gt;

&lt;p&gt;Nothing in that packet contradicts itself, because the packet is the pharmacy’s own account of what happened. The analyst is comparing a document against a claim, and both sides of that comparison came from the same submitter. The one question nobody in the workflow asks is whether the PDF itself was written once and left alone, or opened afterwards and re-saved.&lt;/p&gt;

&lt;p&gt;That question has an answer more often than the workflow assumes, and it lives in the file, not in the content.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope: payment integrity, not credentialing
&lt;/h2&gt;

&lt;p&gt;This article is about documents that arrive &lt;em&gt;after&lt;/em&gt; a service was rendered or &lt;em&gt;before&lt;/em&gt; it is approved — the paperwork that flows through pharmacy audit, prior authorization and claims adjudication at a payer, PBM or health plan. Provider-side credentialing fraud — forged licenses, altered board certificates, tampered CE records — is a different intake pipeline with a different owner, and we cover it separately in &lt;a href="https://htpbe.tech/blog/healthcare-document-fraud-pdf-verification" rel="noopener noreferrer"&gt;healthcare document fraud and medical credentials&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Two boundaries before anything else, because they are the ones that get blurred in this vertical:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Structural PDF analysis makes &lt;strong&gt;no clinical judgment&lt;/strong&gt;. It cannot tell you whether a therapy was appropriate, whether a diagnosis supports a code, or whether a prior-authorization request should be approved. It answers one narrow question about one file.&lt;/li&gt;
&lt;li&gt;It is &lt;strong&gt;not identity verification&lt;/strong&gt;. It does not confirm that a prescriber is real, that an NPI is active, or that a pharmacy is licensed. Those are registry lookups and they remain your job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it adds is a file-integrity layer running alongside the clinical and claims review you already do, on the same PDF, at intake.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three document surfaces on the payer side
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Pharmacy audit response packets
&lt;/h3&gt;

&lt;p&gt;PBM provider manuals routinely require pharmacies to retain and produce prescription hardcopies, signature logs, purchase invoices and documentation for any quantity adjustment or override, and to hand them over for &lt;a href="https://www.healthlawalliance.com/blog/mastering-pbm-audit-documentation-tips-for-pharmacies" rel="noopener noreferrer"&gt;desk and on-site audits&lt;/a&gt; on short notice. In practice that means a batch of PDFs, produced by the audited party, describing the audited party’s own conduct.&lt;/p&gt;

&lt;p&gt;The fraud pattern is mundane, not exotic forgery. A hardcopy is scanned, the scan is opened in an editor, and a digit changes — a quantity, a refill count, a date written, a directions line. Or a missing hardcopy is reconstructed after the audit letter arrives, backdated, and dropped into the packet alongside genuine ones.&lt;/p&gt;

&lt;p&gt;Both tend to leave file-level traces that have nothing to do with the visible pixels. A document that was edited after it was produced can carry structural evidence of that second write — evidence that is independent of how convincing the visible page looks. Which findings come back is reported per file as named &lt;a href="https://htpbe.tech/how" rel="noopener noreferrer"&gt;modification markers&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The biggest caveat in this article belongs here: &lt;strong&gt;most audit hardcopies are scans, and scans have a ceiling&lt;/strong&gt;. A scanned document has already lost its issuer provenance — the scanner is the producer, not the prescriber’s system. Under the published verdict rules a scan cannot return &lt;code&gt;intact&lt;/code&gt;; &lt;code&gt;inconclusive&lt;/code&gt; is the normal outcome for this class, and it means the file cannot carry the evidence either way.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Prior-authorization request packets
&lt;/h3&gt;

&lt;p&gt;Prior authorization is where the document layer is getting harder. Under the CMS Interoperability and Prior Authorization final rule (&lt;a href="https://www.cms.gov/newsroom/fact-sheets/cms-interoperability-prior-authorization-final-rule-cms-0057-f" rel="noopener noreferrer"&gt;CMS-0057-F&lt;/a&gt;), impacted payers — Medicare Advantage organizations, state Medicaid and CHIP fee-for-service programs, Medicaid and CHIP managed care plans, and QHP issuers on the federally facilitated exchanges — take on new prior-authorization obligations, and all of them except QHP issuers on the FFEs must return decisions within 72 hours for expedited requests and seven calendar days for standard ones.&lt;/p&gt;

&lt;p&gt;Two boundaries on that rule matter for anyone reading this from the pharmacy side. It covers &lt;strong&gt;medical items and services&lt;/strong&gt;; CMS &lt;a href="https://www.cms.gov/priorities/burden-reduction/overview/interoperability/frequently-asked-questions/general" rel="noopener noreferrer"&gt;excluded drugs&lt;/a&gt; from both the prior-authorization API and the process requirements, on the grounds that drug prior-authorization standards and timeframes work differently. So pharmacy-benefit prior authorization sits outside this rule’s timeframes entirely — other federal and state requirements may still apply, and it runs on plan and PBM policy alongside them. And on the medical-benefit side, faster mandated turnaround does not reduce the volume of supporting documentation. It compresses the window in which anyone looks at it.&lt;/p&gt;

&lt;p&gt;A prior-authorization packet is a document bundle: the PA form, a letter of medical necessity, chart notes, prior therapy documentation, sometimes lab values as a PDF export. A denial in this workflow is consequential, so the temptation on the submitting side is to make the record say what the criteria need it to say. In the version we care about, that is done by editing a real document rather than inventing one from nothing — a step-therapy note whose date moves, a chart excerpt where one value is patched, a letter assembled from pages of two different source files.&lt;/p&gt;

&lt;p&gt;Assembly and overlay patterns of this kind are the sort of thing a structural check is aimed at surfacing, and they come back as named &lt;a href="https://htpbe.tech/how" rel="noopener noreferrer"&gt;modification markers&lt;/a&gt; attached to the verdict. None of that says the request should be denied. It says a human should read this packet before the clock runs out on it, rather than after.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Itemized bills and appeal documentation
&lt;/h3&gt;

&lt;p&gt;Itemized bills, treatment summaries and appeal attachments follow the same inflation pattern as any altered invoice: a genuine document from a genuine provider, with a number changed. We have covered that pattern and its structural signature in detail under &lt;a href="https://htpbe.tech/use-cases/insurance-claims-fraud-detection/medical-bill-tamper-detection" rel="noopener noreferrer"&gt;medical bill tamper detection&lt;/a&gt; and in &lt;a href="https://htpbe.tech/blog/insurance-claims-fraud-altered-pdfs" rel="noopener noreferrer"&gt;insurance claims fraud and altered PDFs&lt;/a&gt;, and there is no reason to repeat it here. The only payer-specific note worth adding is sequencing — on the medical side the itemized bill usually shows up in an &lt;em&gt;appeal&lt;/em&gt;, after an initial adjudication, which is exactly the moment a plan is least likely to route a document to fresh review and most likely to read only the disputed line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the file layer is separate from the claim layer
&lt;/h2&gt;

&lt;p&gt;Every fraud, waste and abuse control a plan runs today reads &lt;em&gt;claim data&lt;/em&gt;. Edits, aberrancy models, peer-group outliers, prescriber pattern analysis, network links — all of it operates on structured fields that the claim already contains. None of it opens the attached PDF as a file.&lt;/p&gt;

&lt;p&gt;A PDF does not overwrite itself when it is edited. It appends, and it keeps a record of having appended, along with a record of which software did the appending and when. That is the mechanism, and the &lt;a href="https://htpbe.tech/blog/pdf-xref-table-forensics" rel="noopener noreferrer"&gt;xref table forensics&lt;/a&gt; article walks the byte-level detail if you want it. The operational point for a payment-integrity team is narrower: the evidence that a supporting document was rewritten sits in a place your adjudication engine has never looked, and it survives regardless of how plausible the claim line looks.&lt;/p&gt;

&lt;p&gt;It also cuts the other way, which is why this is a layer and not an oracle. A perfectly clean file can support a completely fraudulent claim. Structural integrity is a statement about the file, not about the truth of what is printed on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the three verdicts in a payer workflow
&lt;/h2&gt;

&lt;p&gt;The API returns one of three verdicts, and in this vertical the middle one carries most of the operational weight.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;modified&lt;/code&gt; — the file was written, then written again. Named &lt;a href="https://htpbe.tech/how" rel="noopener noreferrer"&gt;modification markers&lt;/a&gt; come back with it. This is a routing signal: pull the document into human review, and where the document is a copy of something an issuer holds, request the original from the issuer — the prescriber’s system, the dispensing system, the clinic.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;inconclusive&lt;/code&gt; — the file’s origin is consumer software, an online editor, a scan, or another class with no institutional pipeline behind it, so there is no provenance to check against. This does &lt;strong&gt;not&lt;/strong&gt; mean the analysis failed, and in pharmacy audit work you will see a lot of it. It means the file cannot carry the evidence either way. A scanned hardcopy is the normal case. A prior-authorization letter of medical necessity that a clinic swears came out of its EHR but returns &lt;code&gt;inconclusive&lt;/code&gt; is a different conversation — not an accusation, a question. We have written a fuller treatment of &lt;a href="https://htpbe.tech/blog/what-inconclusive-means-pdf-verification-results" rel="noopener noreferrer"&gt;what inconclusive actually means&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;intact&lt;/code&gt; — no evidence of post-creation modification, and the file’s origin is consistent with &lt;code&gt;institutional&lt;/code&gt; generation. This is the verdict most likely to be over-read. It is not proof that the document is authentic, that the content is true, or that the claim is payable. It is the absence of one specific kind of evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Routing, never automatic denial
&lt;/h2&gt;

&lt;p&gt;This matters more in healthcare than in any other vertical we write about, so it goes here and not in a limitations section at the end.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;modified&lt;/code&gt; verdict is evidence that a file changed after it was created. It is not a finding of fraud, and it is not a basis for denying a prior-authorization request, rejecting a claim, recouping from a pharmacy or terminating a network contract on its own. Documents get re-saved for entirely ordinary reasons — a front-counter workflow that reprints and re-scans, a practice management system that rewrites a PDF on upload, a staff member who rotates a crooked scan and saves it.&lt;/p&gt;

&lt;p&gt;The API ships that constraint in the payload rather than leaving the integrator to infer it. Every result carries a &lt;code&gt;usage_caution&lt;/code&gt; object with &lt;code&gt;safe_for_automated_adverse_decision: false&lt;/code&gt; and a &lt;code&gt;recommended_action&lt;/code&gt; of &lt;code&gt;route_to_human_review&lt;/code&gt;, &lt;code&gt;request_from_issuer&lt;/code&gt; or &lt;code&gt;no_action&lt;/code&gt;. Read that field and route on it. In a benefit-decision workflow the correct downstream action is a human reviewing the document, a request for the original from the issuing system, or a normal audit follow-up under your existing provider-manual process — with the appeal rights and turnaround obligations that already apply to that process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integration
&lt;/h2&gt;

&lt;p&gt;One POST with a URL to the document, one GET for the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.htpbe.tech/v1/analyze &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$HTPBE_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{ "url": "https://plan.example.com/pa/packet-88213.pdf" }'&lt;/span&gt;
&lt;span class="c"&gt;# → { "id": "3f9c8b7a-2e1d-4c5f-9b8e-7a6d5c4b3a21" }&lt;/span&gt;

curl https://api.htpbe.tech/v1/result/3f9c8b7a-2e1d-4c5f-9b8e-7a6d5c4b3a21 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$HTPBE_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result endpoint is where the verdict lives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3f9c8b7a-2e1d-4c5f-9b8e-7a6d5c4b3a21"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"modified"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"modification_confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"creator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Clinical Document System"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"producer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PDF Editor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"xref_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"has_incremental_updates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"has_digital_signature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"modification_markers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"HTPBE_EDITING_TOOL_FINGERPRINT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HTPBE_MULTIPLE_REVISION_LAYERS"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"usage_caution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"safe_for_automated_adverse_decision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"recommended_action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"route_to_human_review"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The routing logic on your side is a switch on &lt;code&gt;status&lt;/code&gt;, honoring &lt;code&gt;usage_caution.safe_for_automated_adverse_decision&lt;/code&gt; as the constant &lt;code&gt;false&lt;/code&gt; it always ships as. &lt;code&gt;modified&lt;/code&gt; goes to a payment-integrity queue with the marker list attached so the reviewer knows what to look at. &lt;code&gt;inconclusive&lt;/code&gt; on a document class you expect to be institutional goes to the same queue at lower priority. &lt;code&gt;inconclusive&lt;/code&gt; on a scanned audit hardcopy is the expected baseline for the class — route it through your existing audit sampling.&lt;/p&gt;

&lt;p&gt;Every account gets a test key that returns deterministic synthetic results for a fixed set of scenarios, so you can build and test the routing branches before a single real document moves. Plans, limits and key generation are on the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;API page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Documents in this vertical carry patient information, so where the analysis runs is a question your security and privacy review will ask before your engineering team does. The hosted API and a self-hosted, on-premise deployment are both available. Which one you can use against your own HIPAA obligations — including whether a Business Associate Agreement is required and available for the hosted path — is a determination for your own compliance function, and we’ll confirm our current BAA position directly if you ask; we are not in a position to draw that conclusion for you here. The &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;API page&lt;/a&gt; links the on-premise deployment documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not solve
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Content truth.&lt;/strong&gt; A prescription PDF that was generated once, never touched, and describes a drug the patient never received will come back &lt;code&gt;intact&lt;/code&gt;. Nothing about file structure speaks to whether the underlying event happened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fabricated-from-scratch documents.&lt;/strong&gt; A letter of medical necessity typed in Word and exported once has no editing history to find. It returns &lt;code&gt;inconclusive&lt;/code&gt; because its origin is &lt;code&gt;consumer software&lt;/code&gt; — a useful signal when the document claims to come from a clinical system, and no signal at all when it does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scans, in general.&lt;/strong&gt; Covered above. The scan ceiling is real and it is the dominant document form in pharmacy audit response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything about people.&lt;/strong&gt; Prescriber identity, patient eligibility, pharmacy licensure, NPI validity. Different problem, different tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Improper payment in the regulatory sense.&lt;/strong&gt; CMS is explicit that its improper-payment measurement is &lt;a href="https://www.cms.gov/newsroom/fact-sheets/fiscal-year-2025-improper-payments-fact-sheet" rel="noopener noreferrer"&gt;not a measure of fraud&lt;/a&gt; — in FY2025, 77.17% of Medicaid improper payments were the result of insufficient documentation, which CMS notes is generally not indicative of fraud or abuse. Structural PDF analysis speaks to whether a file was edited, which is a narrower question than either.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it fits
&lt;/h2&gt;

&lt;p&gt;If you run pharmacy audit, prior-authorization intake or claims payment integrity, the honest pitch is small: you already collect these PDFs, a structural check is one API call per file, and it surfaces a category of evidence that no part of your current stack examines. It will not adjudicate anything for you, and in a workflow where the majority of documents are scans, a large share of results will be &lt;code&gt;inconclusive&lt;/code&gt; by design.&lt;/p&gt;

&lt;p&gt;What it changes is which documents a human looks at first, and whether the packet that arrived in response to an audit letter gets examined as a set of files rather than only as a set of claims.&lt;/p&gt;

&lt;p&gt;Start with the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;API documentation and pricing&lt;/a&gt; — test keys work before you commit to anything, and the analyze call is a single POST.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>fintech</category>
      <category>fraud</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Real Cost of an Invalid VAT Number</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Wed, 07 Oct 2026 09:00:57 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/the-real-cost-of-an-invalid-vat-number-20n0</link>
      <guid>https://dev.to/iurii_rogulia/the-real-cost-of-an-invalid-vat-number-20n0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/cost-of-invalid-vat-number" rel="noopener noreferrer"&gt;vatnode.dev&lt;/a&gt;. The version on vatnode.dev is the canonical source — refer to it for the latest content.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;An invalid VAT number on a zero-rated or reverse-charged intra-EU B2B invoice is not a data-quality problem. It’s VAT you didn’t charge, now assessed against you as the supplier – plus interest, plus a penalty, plus whatever it costs to reconstruct the file and fix it. The cost stack below is scoped to the EU-27 plus Northern Ireland (XI), which is the coverage VIES supports.&lt;/p&gt;

&lt;p&gt;One distinction before anything else: what a tax authority assesses against you and what you can later recover from the customer are two different questions. The assessment is public law – you owe the state. Recovering it from the customer, if you try, is a contractual and national civil-law question, and it usually doesn’t work for the interest or the penalty, since those generally can’t be shifted onto anyone without an explicit indemnity clause in the contract. Keep those separate – the number in the worked example below is what gets assessed, not automatically what you lose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Goods and services fail differently – this matters for the whole cost story
&lt;/h2&gt;

&lt;p&gt;The mechanism that breaks determines who assesses you, in which state, and at what rate. They are not the same story, and treating them as one ‘reverse charge’ narrative is where most of the confusion starts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Goods.&lt;/strong&gt; An intra-Community supply of goods is exempt under Article 138 of the VAT Directive. Since the 2020 Quick Fixes, the customer holding a valid VAT number is a substantive condition of that exemption under Article 138(1)(b) – not a formality you can fix after the fact. Correctly reporting the supply on the recapitulative statement (Article 138(1a)) is a separate condition, and it does carry a ‘duly justify’ cure clause for genuine, justified errors – but that cure doesn’t extend to the VAT-number condition itself. If the exemption fails because the number was never valid, the supply becomes a domestic taxable supply, assessed in the member state where dispatch began, at that state’s rate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Services.&lt;/strong&gt; The reverse-charge mechanism for services runs on Article 44 (place of supply is the customer’s establishment) plus Article 196 (the customer accounts for the VAT). The legal test is whether the customer is a taxable person acting as such – a valid VAT number is strong evidence of that, but it isn’t the substantive condition the way it is for goods. An invalid number doesn’t automatically mean ‘not a taxable person.’ What it does mean is that if the Article 44 test can’t be satisfied, place of supply falls back to Article 45, and the supplier owes VAT in its own member state, at its own rate.&lt;/p&gt;

&lt;p&gt;So: goods failure → assessed where you dispatched from. Services failure → assessed where you’re established. Different tax authority, different rate, different remediation path. A single ‘the reverse charge broke’ narrative flattens a distinction that changes which number you’re computing against. The mechanics of the services case specifically – including the CJEU good-faith line – are covered in more depth in &lt;a href="https://vatnode.dev/blog/reverse-charge-invalid-vat-number-risk" rel="noopener noreferrer"&gt;what happens when you reverse-charge an invalid VAT number&lt;/a&gt; – that one covers the liability mechanics for services; this one is the cost stack across both mechanisms.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost stack
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The VAT you never charged
&lt;/h3&gt;

&lt;p&gt;This is the main line. Worth making concrete once, with every assumption stated – it’s easy to underestimate how directly the exempted amount converts into an assessed liability.&lt;/p&gt;

&lt;p&gt;Illustrative only – assumptions inline, not a real case: a supplier dispatches €50,000 of goods from &lt;a href="https://vatnode.dev/check/de" rel="noopener noreferrer"&gt;Germany&lt;/a&gt; under what it believed was a valid Article 138 exemption. The customer’s VAT number turns out to have been invalid at the time of dispatch, the exemption fails, and the supply is reassessed as domestic at Germany’s standard rate of 19% (current rate per &lt;a href="https://vatnode.dev/docs/rates" rel="noopener noreferrer"&gt;/docs/rates&lt;/a&gt;; rates and the countries they apply to change, so don’t hardcode this figure anywhere that isn’t checked against the live source). That’s €9,500 assessed – not lost, assessed. It’s a public-law liability to the German tax authority, and it exists independently of whether the supplier can later persuade the customer to cover any part of it.&lt;/p&gt;

&lt;p&gt;Run the same shape for a services failure and the numbers move to the supplier’s own established rate instead of the customer’s country, because that’s where Article 45 puts the liability once Article 44 doesn’t apply. A &lt;a href="https://vatnode.dev/check/fi" rel="noopener noreferrer"&gt;Finland&lt;/a&gt;-established SaaS vendor reassessed on a services supply is assessed at Finland’s standard rate, not the customer’s, however far away the customer is.&lt;/p&gt;

&lt;p&gt;Nothing here is invented beyond that: the rate is real and sourced, the base is a stated assumption, and ‘illustrative’ applies to the whole example, not just the base amount.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Interest
&lt;/h3&gt;

&lt;p&gt;Interest accrues from the point the VAT should have been paid – the original due date of the return that should have included it, not the date of the audit finding. How that’s calculated – the rate, the compounding, whether it’s simple or tiered – is set by each member state’s own law; the VAT Directive doesn’t harmonize it. There’s no single EU-wide interest figure to quote, and a source that gives you one flat number for ‘VAT interest’ is wrong by construction. Check &lt;a href="https://vatnode.dev/docs/coverage" rel="noopener noreferrer"&gt;/docs/coverage&lt;/a&gt; for which countries’ rules apply to a given transaction rather than assuming a fixed list.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Penalties
&lt;/h3&gt;

&lt;p&gt;Same story. Penalty regimes – whether a fixed amount, a percentage of the unpaid VAT, or something scaled to culpability – are set by member-state law under the general enabling basis of Article 273, not by the Directive itself. They vary by country, by how the failure is characterized (error versus negligence versus fraud), and by whether the supplier self-corrects before an audit finds it. This article won’t give you a percentage or a per-country table, because there isn’t one that’s accurate across 27 member states plus XI, and stating one would be worse than saying nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Audit adjustment and back-taxes
&lt;/h3&gt;

&lt;p&gt;An audit that finds one invalid number rarely stops at one invoice. Once a tax authority is reviewing your intra-EU exemption or reverse-charge treatment, the review typically spans the filing periods and the customer relationships that used the same validation practice – which means the same failure mode, if it exists, tends to show up more than once. The adjustment isn’t ‘recompute this invoice,’ it’s ‘recompute the pattern,’ and that’s a materially larger exposure than the illustrative single-invoice example above suggests. This is also where checked-then-later-deregistered and never-checked-at-all diverge sharply – the former has a documented, dated basis for the treatment applied at the time; the latter doesn’t, and an auditor treats them very differently across the whole sample they pull.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Remediation
&lt;/h3&gt;

&lt;p&gt;Even before any assessment lands, fixing the underlying problem costs something: reconstructing which invoices used which VAT number and when, attempting to re-verify or re-bill customers where the contract allows it, and advisor time to work through the jurisdiction-specific interest and penalty exposure once the pattern is understood. None of that is optional once a systemic gap is found – it’s the price of closing the file, separate from whatever the assessment itself comes to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the evidence changes the story, without promising an outcome
&lt;/h2&gt;

&lt;p&gt;Everything above assumes the exemption or reverse-charge condition actually failed. Whether a supplier is exposed at all, and how the good-faith question plays out, depends heavily on what was checked and when.&lt;/p&gt;

&lt;p&gt;CJEU case law – &lt;a href="https://curia.europa.eu/juris/liste.jsf?num=C-409/04" rel="noopener noreferrer"&gt;Teleos (C-409/04)&lt;/a&gt;, &lt;a href="https://curia.europa.eu/juris/liste.jsf?num=C-439/04" rel="noopener noreferrer"&gt;Kittel (C-439/04 and C-440/04)&lt;/a&gt;, and &lt;a href="https://curia.europa.eu/juris/liste.jsf?num=C-271/06" rel="noopener noreferrer"&gt;Netto Supermarkt (C-271/06)&lt;/a&gt; – establishes a general good-faith principle: a supplier who took every reasonable measure available and had no part in fraud can generally keep the benefit of an exemption, even where a fact later turns out to be wrong. Read that carefully, though. These cases predate the 2020 Quick Fixes and turned on falsified transport and export documentation, not on today’s Article 138(1)(b) VAT-number condition specifically. Cite them for the general principle, hedged, not as a ruling on the exact scenario of a validated-then-deregistered number.&lt;/p&gt;

&lt;p&gt;What the principle points toward, without guaranteeing anything, is that a supplier who ran a genuine, dated, requester-qualified check at the time of supply is in a materially different position than one who never checked. That’s exactly what the VIES consultation number is for – contemporaneous evidence, issued by VIES itself, that a specific check happened at a specific time. It’s not ‘unique,’ other providers can return it too, and it doesn’t adjudicate the transaction on its own – it’s evidence you did your part of the diligence. The full case for keeping it is in &lt;a href="https://vatnode.dev/blog/consultation-number-for-finance" rel="noopener noreferrer"&gt;the VIES consultation number for finance teams&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If this is describing a real supply you’ve already made, the next step isn’t reading further – it’s a qualified tax adviser in the relevant jurisdiction. Nothing here promises that a diligent check means no assessment; it means you have something to argue from.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually reduces this exposure
&lt;/h2&gt;

&lt;p&gt;The cost stack above is the downside case. The mitigation is comparatively simple and cheap relative to any of the five items above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check before you rely on the number&lt;/strong&gt;, not after. A &lt;a href="https://vatnode.dev/vat-api" rel="noopener noreferrer"&gt;&lt;code&gt;GET /v1/vat/:vatId&lt;/code&gt;&lt;/a&gt; call returns &lt;code&gt;valid&lt;/code&gt;, the trader details where available, &lt;code&gt;checkId&lt;/code&gt;, &lt;code&gt;verifiedAt&lt;/code&gt;, &lt;code&gt;source&lt;/code&gt;, and &lt;code&gt;consultationNumber&lt;/code&gt; for a requester-qualified lookup:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.vatnode.dev/v1/vat/DE123456789 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$VATNODE_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep the evidence next to the invoice&lt;/strong&gt;, not in a separate system nobody checks during an audit. &lt;a href="https://vatnode.dev/guides/vat-audit-trail" rel="noopener noreferrer"&gt;Build the full audit trail&lt;/a&gt; covers what to store and how.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know when a check is actually required&lt;/strong&gt; – not every transaction needs one, and over-checking has its own cost. See &lt;a href="https://vatnode.dev/blog/when-to-validate-customer-vat-numbers" rel="noopener noreferrer"&gt;when to validate a customer’s VAT number&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Understand build-vs-buy before you commit engineering time&lt;/strong&gt; to a SOAP client and a national-fallback layer of your own – &lt;a href="https://vatnode.dev/blog/build-vs-buy-vat-validation" rel="noopener noreferrer"&gt;that breakdown is here&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is about avoiding every possible assessment – some fact patterns will still result in one. It’s about not being the supplier with no dated check, no consultation number, and no answer when the auditor asks how you knew.&lt;/p&gt;

&lt;p&gt;This is general information about EU VAT rules and CJEU case law, not tax or legal advice for a specific transaction. If you’re dealing with an actual invoice raised against a number that turned out to be invalid, talk to a qualified adviser in the relevant jurisdiction before deciding how to fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Who actually pays when a customer’s VAT number turns out to be invalid?
&lt;/h3&gt;

&lt;p&gt;The supplier does, at least at the assessment stage. A tax authority reassesses the supplier for the VAT that should have been charged, not the customer – whether the supplier can then recover that amount from the customer is a separate contractual question under national civil law, and penalties and interest generally can’t be shifted onto the customer without an explicit indemnity clause.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is this the same cost for goods and services?
&lt;/h3&gt;

&lt;p&gt;No. For goods, an invalid customer VAT number can break the Article 138 exemption outright, and the supply gets reassessed in the member state where dispatch began, at that state’s rate. For services under the reverse-charge mechanism, a valid number is evidence rather than the substantive condition – if the place-of-supply test still fails, the supplier is assessed in its own member state, at its own rate. Different authority, different rate, different remediation path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does a diligent VIES check protect us if the number goes bad later?
&lt;/h3&gt;

&lt;p&gt;A genuine, dated, requester-qualified check at the time of supply puts a supplier in a materially different position than never checking at all – CJEU case law has generally protected suppliers who took reasonable measures and had no part in fraud. That said, this is general information, not a guarantee of outcome, and it isn’t tax advice for your specific transaction – talk to a qualified adviser if this applies to a real supply you’ve made.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long do we need to keep the evidence of a VAT check?
&lt;/h3&gt;

&lt;p&gt;Align it with your invoice retention period, since the check is only useful for as long as the invoice it supports is open to review. Retention periods vary by jurisdiction, so the exact number of years is a question for your adviser, not something this article can answer generically.&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  Check before the treatment gets decided, not after an audit
&lt;/h3&gt;

&lt;p&gt;vatnode runs requester-qualified VIES checks – set your EU VAT number once in Account details and every live VIES check then returns the consultation number, source, and timestamp alongside the result, so a bad number shows up before you zero-rate or reverse-charge the invoice. Free plan, 100 requests/month, no card.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://vatnode.dev/register" rel="noopener noreferrer"&gt;Get a free API key&lt;/a&gt; · &lt;a href="https://vatnode.dev/vat-api" rel="noopener noreferrer"&gt;VAT API&lt;/a&gt; · &lt;a href="https://vatnode.dev/pricing" rel="noopener noreferrer"&gt;Pricing&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>tax</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Is Headless Commerce Worth It for a Mid-Size Store?</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 05 Oct 2026 10:00:56 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/is-headless-commerce-worth-it-for-a-mid-size-store-31l6</link>
      <guid>https://dev.to/iurii_rogulia/is-headless-commerce-worth-it-for-a-mid-size-store-31l6</guid>
      <description>&lt;p&gt;‘Headless’ is one of those words that sounds like a decision you’re supposed to have an opinion about. A developer or an agency mentions it, and suddenly you’re weighing an architecture choice you didn’t ask for, for a store that’s currently doing fine.&lt;/p&gt;

&lt;p&gt;So let’s strip the term of its mystique. On a normal Shopify store, the part your customer sees – the storefront – and the part that runs the shop – the cart, checkout, catalogue, orders – come welded together. Going headless means splitting them: you keep a commerce engine for payments and orders, but you build the storefront yourself, from scratch, as a custom website that talks to that engine over an API.&lt;/p&gt;

&lt;p&gt;That’s it. Headless means you own the front of the shop and rent the back. The question isn’t whether headless is modern or impressive. It’s whether owning that front half is worth what it costs you to build and keep it running.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Owning the Storefront Actually Buys You
&lt;/h2&gt;

&lt;p&gt;You go headless for one reason: the standard storefront can’t do something your business genuinely needs, and no plugin fixes it. Not ‘would be nice.’ Needs.&lt;/p&gt;

&lt;p&gt;In practice, that comes down to a short list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom product configurators.&lt;/strong&gt; If a customer designs the product before buying – picks dimensions, materials, mounting options, and the price recalculates live as they choose – a template storefront fights you the whole way. I built exactly this for a manufacturer of made-to-measure products: a real-time configurator where the customer sets custom dimensions and sees the result and the price before ordering. Shopify was ruled out early, specifically because it couldn’t handle that. Not ‘was hard with.’ Couldn’t.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Complex pricing and VAT.&lt;/strong&gt; Selling across Europe means a different VAT rate for every market, multiple currencies, and – for business buyers – validating a VAT number and removing tax on the invoice with the correct reverse-charge wording. Off-the-shelf platforms treat this as an edge case. For a store built for cross-border B2B, it’s the main case. I’ve built platforms where VAT correctness across 32 markets is a core part of the architecture, not something bolted on at the end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;B2B flows.&lt;/strong&gt; Business buyers don’t shop like consumers. They expect their company VAT number validated against the EU registry, their address auto-filled from it, their local payment method available, and a compliant invoice generated automatically the moment they pay – no sales call. That’s a different checkout, not a skinned version of the consumer one.&lt;/p&gt;

&lt;p&gt;If your business lives in one of those three worlds, the standard storefront isn’t a limitation you can configure around. It’s a wall. That’s when headless earns its keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Costs – And This Is the Part Vendors Skip
&lt;/h2&gt;

&lt;p&gt;Owning the storefront means owning it forever. That’s the honest trade.&lt;/p&gt;

&lt;p&gt;A template store is maintained by the platform. Something in the browser changes, a security patch is needed, the checkout gets an upgrade – Shopify does that for you, invisibly, as part of what you pay them. A custom storefront is yours. Every one of those updates is now your responsibility, or your developer’s. The build is a one-time cost you can see coming. The maintenance is a permanent line item that’s easy to forget when you’re excited about the launch.&lt;/p&gt;

&lt;p&gt;So the real comparison isn’t ‘template price vs build price.’ It’s the platform’s monthly fee against the build &lt;strong&gt;plus&lt;/strong&gt; the ongoing cost of keeping a custom system healthy for as long as you run the shop. If the only thing you gain is a storefront that looks a bit more bespoke, that trade is a bad one. A theme and some custom CSS get you most of the way for a fraction of the cost and none of the maintenance.&lt;/p&gt;

&lt;p&gt;Headless only makes sense when the thing you can’t otherwise do is worth carrying that cost indefinitely.&lt;/p&gt;

&lt;p&gt;
  slug="e-commerce"&lt;br&gt;
  text="I build custom and headless storefronts, but I’ll tell you honestly when Shopify or an off-the-shelf platform is the better call for your store – because carrying a custom build you didn’t need is a cost you feel every month, not just at launch."&lt;br&gt;
/&amp;gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When Shopify Is Genuinely Enough
&lt;/h2&gt;

&lt;p&gt;Here’s the part most articles on this topic won’t tell you, because most are written by people who sell builds: for a large share of mid-size stores, going headless is over-engineering. You’d be paying to solve a problem you don’t have.&lt;/p&gt;

&lt;p&gt;Stay on Shopify or an off-the-shelf platform if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You sell fixed products from a catalogue.&lt;/strong&gt; No live configuration, no design-before-you-buy. A theme handles this beautifully.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your pricing is straightforward.&lt;/strong&gt; One or two currencies, standard tax handling, no VAT-number validation, no reverse charge. The platform already does this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your customers are consumers, not businesses.&lt;/strong&gt; Consumer checkout is exactly what these platforms are built for – you’d be rebuilding their strongest feature worse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your storefront wishlist is cosmetic.&lt;/strong&gt; ‘I want it to look more premium’ is a design job, not an architecture one. You can get a long way with a good theme before the platform is what’s holding you back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If that’s your store, headless doesn’t make you more money. It makes you responsible for a system you didn’t need, and it moves budget away from the things that actually grow a shop – better product photography, faster shipping, ads that work.&lt;/p&gt;

&lt;p&gt;The tell is simple. If you can list the specific thing the platform won’t let you do – and it’s a real business requirement, not a preference – you have a candidate for headless. If you’re reaching for reasons, you have your answer already: stay put.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Question Worth Answering First
&lt;/h2&gt;

&lt;p&gt;Before anyone quotes you a build, answer one thing honestly: &lt;strong&gt;is the platform blocking me, or have I just not pushed it as far as it goes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Shopify and the rest are more capable than they look. Plenty of ‘we need custom’ moments dissolve once someone who knows the platform properly configures it, adds the right app, or fixes the theme. Paying for a headless build to solve a problem a €30 app already handles is the most expensive mistake in this whole decision.&lt;/p&gt;

&lt;p&gt;But when the requirement is real – a configurator the platform can’t render, cross-border VAT it treats as an afterthought, a B2B checkout that doesn’t exist off the shelf – no amount of configuration gets you there. That’s the line. On one side, headless is a costly answer to a question you don’t have. On the other, it’s the only architecture that lets the business work the way it actually needs to.&lt;/p&gt;

&lt;p&gt;The skill isn’t knowing how to build headless. It’s telling those two situations apart before you spend the money – and being willing to say ‘your platform is fine, keep it’ when that’s the true answer.&lt;/p&gt;




&lt;p&gt;If you can name the exact thing your store can’t do today, you’re most of the way to knowing whether headless is worth it. &lt;a href="https://iurii.rogulia.fi/contact" rel="noopener noreferrer"&gt;Let’s figure out which side of the line you’re on&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>business</category>
      <category>ecommerce</category>
      <category>headless</category>
      <category>decision</category>
    </item>
    <item>
      <title>Self-Employed Income Fraud: Altered 1099s &amp; P&amp;L Statements</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 05 Oct 2026 10:00:42 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/self-employed-income-fraud-altered-1099s-pl-statements-5b3g</link>
      <guid>https://dev.to/iurii_rogulia/self-employed-income-fraud-altered-1099s-pl-statements-5b3g</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/self-employed-income-fraud-1099-profit-loss" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt;. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A W-2 borrower’s income file has a spine running through it. There is an employer who can be phoned, a payroll platform that issued the paystub, a Verification of Employment the employer fills in, and an IRS record of the same wages filed by somebody other than the applicant.&lt;/p&gt;

&lt;p&gt;A self-employed borrower’s file has none of that spine. There is no employer to call because the applicant is the employer. There is no payroll platform because the pay is a transfer between accounts. And the central income document – the profit and loss statement – is one the applicant wrote themselves, which is the documented, guideline-sanctioned way the file is supposed to work.&lt;/p&gt;

&lt;p&gt;That is an awkward problem for anyone adding a document-integrity layer to underwriting: self-employed files are where document evidence carries the most weight, and where a naive file check produces the worst answers. Below: which documents a structural check can speak to, which ones it cannot, and how to route the results without treating every sole trader as a suspect.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Self-Employed Income File Is Made Of
&lt;/h2&gt;

&lt;p&gt;Start with what the guidelines require, because the received wisdom that self-employed borrowers get less scrutiny is wrong. They get more.&lt;/p&gt;

&lt;p&gt;Under Fannie Mae’s self-employed borrower standards (Selling Guide B3-3.2-01), the lender generally obtains a two-year history of prior earnings. A single year of returns is permitted only in a narrow case – the business has existed for five years, the borrower has held 25% or more ownership throughout, and the lender completes a cash flow analysis on Form 1084 or equivalent, with the five-year existence documented from an independent source such as an EIN confirmation letter, a business licence or articles of incorporation.&lt;/p&gt;

&lt;p&gt;Other controls sit alongside the returns. The lender must verify, within 120 calendar days prior to the note date (B3-3.1-07), that the business exists – from a third party such as a CPA, a regulatory agency or the applicable licensing bureau, or by verifying a phone listing and address – and document the source. Where business assets fund the down payment or reserves, a business cash flow analysis is required. And the income analysis has to be written up using Form 1084; the lender may support the year-over-year trend with Form 1088, the Income Calculator, or any other method applying the same principles.&lt;/p&gt;

&lt;p&gt;Then there is the profit and loss statement. Fannie Mae’s guidance on analysing P&amp;amp;L statements (B3-3.7-04) is explicit that the lender may use a P&amp;amp;L ‘audited or unaudited,’ and that a typical one has a format similar to Schedule C. A year-to-date statement is not universally required; it becomes a live question when the loan application is dated more than 120 days after the end of the business’s tax year, where the lender may choose to require one.&lt;/p&gt;

&lt;p&gt;The whole routing problem hangs on that sentence. &lt;strong&gt;The guideline expressly contemplates an unaudited P&amp;amp;L.&lt;/strong&gt; In practice that means a document the borrower produced in a spreadsheet, exported from bookkeeping software, or typed into a word processor. That is the sanctioned artefact.&lt;/p&gt;

&lt;p&gt;The non-agency world leans harder still. Bank-statement loan programmes underwrite self-employed applicants on twelve or twenty-four months of deposits precisely because taxable income after allowable business deductions often understates the cash flow available to service a loan. There the borrower-supplied statement is not a supporting exhibit – it is the qualifying evidence.&lt;/p&gt;

&lt;p&gt;Guideline citations here reflect the Fannie Mae Selling Guide and IRS guidance as published in August 2026; confirm against the current edition before relying on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Documents, Two Completely Different Provenances
&lt;/h2&gt;

&lt;p&gt;A self-employed income file mixes two categories of document that look similar on screen and are forensically nothing alike.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third-party issued.&lt;/strong&gt; A 1099-NEC, 1099-MISC or 1099-K is produced by the payer, not the applicant. A platform, a client or a payment processor generated it, filed a copy with the IRS and sent one to the recipient. Business bank statements are the same class: a bank produced them. So are the CPA-prepared returns, the EIN letter, the invoices a client issued. Each has an issuing party with its own document pipeline, and therefore a baseline of what its files normally look like.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-authored.&lt;/strong&gt; A P&amp;amp;L statement. A borrower-prepared income summary. A bookkeeping export. An engagement letter the applicant drafted. There is no issuer other than the applicant, and no external pipeline to compare against – because the applicant’s own laptop &lt;em&gt;is&lt;/em&gt; the pipeline.&lt;/p&gt;

&lt;p&gt;The distinction matters because a document-integrity check is an argument about provenance: whether a file bears structural evidence of having been written to after it was generated, and whether its origin matches what the document claims to be. On the second category, half of that collapses: a document with no issuer cannot deviate from an issuer’s baseline.&lt;/p&gt;

&lt;p&gt;Any framing that skips this distinction and treats ‘self-employed income documents’ as one bucket will be wrong about roughly half the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the IRS Cross-Check Runs Out
&lt;/h2&gt;

&lt;p&gt;The strongest control on the 1099 side is the one that goes around the applicant entirely. Form 4506-C authorises an Income Verification Express Service participant to pull transcripts straight from the IRS, and the wage and income transcript it produces shows data from information returns including the 1099 series. When that transcript comes back showing the same totals the applicant supplied, the income is corroborated by a party the applicant never touched. Where the path is open, use it. That path has a hard boundary, and on self-employed files it bites unusually deep.&lt;/p&gt;

&lt;p&gt;Information return data for a tax year generally does not appear on the IRS record until the following calendar year, once payers have filed, and the record is not complete the moment filing season opens – it fills in over the months that follow. A 1099 issued for the year in progress is not on the record at all, and neither is the return that would report it. For a salaried applicant that matters less: a VOE reaches the employer directly and the pay is regular enough to project. For an applicant whose income is a stack of client payments across the current year, the documents describing that income are the only evidence of it.&lt;/p&gt;

&lt;p&gt;The transcript has a second ceiling: the IRS caps how many income documents it carries and warns that it may not reflect every information return issued to a taxpayer – a real limit for someone with many payers, the ordinary shape of gig income. An IVES request also takes time to come back, and pre-approvals get issued in that window on the documents already in hand.&lt;/p&gt;

&lt;p&gt;So the third-party side has a strong control with a coverage hole over current-year income. The self-authored side has no equivalent control at all, because there is no third party to ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Structural Check Actually Reads
&lt;/h2&gt;

&lt;p&gt;A PDF carries an internal account of its own assembly – which software wrote it, how many times it has been written to, whether a signature covers the bytes, whether pages arrived from different sources. Editing a genuine file and saving it generally leaves traces there, independent of whether the visible numbers reconcile.&lt;/p&gt;

&lt;p&gt;Reconciliation is what a careful forger optimises for. Someone editing a 1099 knows the file will be read against the P&amp;amp;L, the bank deposits and the application, and can make the arithmetic agree across all of it. What they are not editing is the file’s own record of its assembly – that record is not on the page.&lt;/p&gt;

&lt;p&gt;HTPBE reads that structure and returns one of three verdicts.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;intact&lt;/code&gt; — no structural evidence of post-creation modification, with an origin consistent with an &lt;code&gt;institutional&lt;/code&gt; export. This is the absence of evidence of change.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;modified&lt;/code&gt; — the file carries structural evidence of having been written to after it was first generated. Named &lt;code&gt;modification markers&lt;/code&gt; describe what was found. On income documents the ones that matter most are &lt;code&gt;HTPBE_EDITING_TOOL_FINGERPRINT&lt;/code&gt; (traces of a known PDF editing tool on a document that should not have passed through one), &lt;code&gt;HTPBE_MULTIPLE_REVISION_LAYERS&lt;/code&gt; (the document carries revision layers added after it was created), &lt;code&gt;HTPBE_DATES_DISAGREE&lt;/code&gt; (the file’s own timestamps do not agree) and &lt;code&gt;HTPBE_CHARACTER_OVERLAY_EDIT&lt;/code&gt; (targeted character-level overlays added by a desktop editor).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;inconclusive&lt;/code&gt; — the file’s origin is &lt;code&gt;consumer software&lt;/code&gt;, an online editor or a scanner, leaving no &lt;code&gt;institutional&lt;/code&gt; baseline to check integrity against. This is a statement about origin, not a failure to analyse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Routing Problem, Stated Honestly
&lt;/h2&gt;

&lt;p&gt;Here is where a self-employed file breaks a workflow designed around W-2 documents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A self-prepared P&amp;amp;L should typically come back &lt;code&gt;inconclusive&lt;/code&gt;, and that is the correct answer.&lt;/strong&gt; It was written in a spreadsheet or a bookkeeping tool by the applicant, and &lt;code&gt;consumer software&lt;/code&gt; is what the guideline permits. An &lt;code&gt;inconclusive&lt;/code&gt; verdict there tells you nothing about the applicant – it restates a format you already knew about.&lt;/p&gt;

&lt;p&gt;If you route &lt;code&gt;inconclusive&lt;/code&gt; to enhanced review, and self-employed files generate &lt;code&gt;inconclusive&lt;/code&gt; more often than W-2 files purely because of their document mix, you have built a system that flags people for being self-employed. That is a bad outcome on every axis: it is unfair, it buries reviewers in noise that resolves to nothing, and after a few weeks of empty escalations the team stops trusting the signal for the files where it does mean something.&lt;/p&gt;

&lt;p&gt;The rule that makes this work is that a verdict is only interpretable against the document’s &lt;strong&gt;claimed source&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Document&lt;/th&gt;
&lt;th&gt;Claimed source&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;inconclusive&lt;/code&gt; means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Borrower-prepared P&amp;amp;L&lt;/td&gt;
&lt;td&gt;The applicant&lt;/td&gt;
&lt;td&gt;Expected. The format, restated. No signal.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bookkeeping-software income summary&lt;/td&gt;
&lt;td&gt;The applicant’s tool&lt;/td&gt;
&lt;td&gt;Expected. No signal.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;P&amp;amp;L presented as CPA-prepared&lt;/td&gt;
&lt;td&gt;A firm, but no pipeline&lt;/td&gt;
&lt;td&gt;Expected. Confirm the preparer by other means; the verdict cannot speak to it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1099 issued by a platform or payer&lt;/td&gt;
&lt;td&gt;A third-party payer&lt;/td&gt;
&lt;td&gt;Worth a question. It should have an issuer’s pipeline behind it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Business bank statement&lt;/td&gt;
&lt;td&gt;A bank&lt;/td&gt;
&lt;td&gt;Worth a question. Resolve it with an issuer-direct download.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPA-prepared return, transcript copy&lt;/td&gt;
&lt;td&gt;A firm or the IRS&lt;/td&gt;
&lt;td&gt;Worth a question.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the bottom three rows, an &lt;code&gt;inconclusive&lt;/code&gt; verdict is a reason to ask for a copy pulled directly from the issuer’s portal, or to order the 4506-C the document was standing in for. Note the CPA-prepared P&amp;amp;L row: a statement prepared by an accountant has a claimed third-party preparer but no institutional pipeline behind it, because the accountant’s desktop is a desktop. Scanning is likewise a common innocent explanation for an &lt;code&gt;inconclusive&lt;/code&gt; verdict on a bank statement – resolve it with the issuer-direct file rather than the applicant’s account of how they handled it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;modified&lt;/code&gt; behaves differently. Structural evidence that a file was written to after it was generated is meaningful on a third-party document – a 1099 edited after the payer produced it is the same finding it would be on any file. On a self-authored P&amp;amp;L it is weaker: an author editing and re-saving their own statement is a normal thing to do.&lt;/p&gt;

&lt;p&gt;One thing worth saying plainly, because the vertical invites the opposite framing. Nothing here says self-employed applicants are more likely to commit fraud, and we have no data that would support such a claim. What is true is narrower and purely mechanical: the automated verification paths that remove the document from the process cover self-employed income badly, so more of these files come down to what the applicant uploaded. That is a statement about the coverage of verification infrastructure, not about the people it fails to cover.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Layer Cannot Do
&lt;/h2&gt;

&lt;p&gt;Three limits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It cannot see a fabricated P&amp;amp;L.&lt;/strong&gt; A P&amp;amp;L typed from scratch with invented revenue has no modification event to find, because structurally nothing happened to it. It is an honestly produced file containing dishonest numbers. That case belongs to the controls built for it: the business bank statements the revenue should appear in, the deposit analysis, the Form 1084 cash flow work, the verification that the business exists at all. A structural check contributes nothing there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It says nothing about whether numbers are true.&lt;/strong&gt; An &lt;code&gt;intact&lt;/code&gt; verdict on a 1099 means only that the file shows no evidence of post-creation change. The payer could have issued it for the wrong amount, or a friendly client could have issued a real 1099 for work never done. Content review and source verification stay in the process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is not a decision about a person.&lt;/strong&gt; A verdict is a reason to go back to the source – request the document from the issuing platform, order the transcript, escalate to someone who will. It should not by itself decline an application, and it does not verify identity, confirm that a business is real, or satisfy any agency documentation requirement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring It Into a Self-Employed File Review
&lt;/h2&gt;

&lt;p&gt;For a one-off document, the file is all that is needed. Register and upload a 1099 or a business statement for a verdict in a few seconds; new accounts come with five checks, after which it is pay-per-check.&lt;/p&gt;

&lt;p&gt;At origination volume it belongs at intake, before a human reads anything. The integration is two calls against &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;the API&lt;/a&gt;: one to submit the document URL, one to fetch the verdict and named &lt;code&gt;modification markers&lt;/code&gt; as structured data. A &lt;code&gt;test key&lt;/code&gt; returns deterministic synthetic results for a fixed set of test URLs, so you can build and regression-test the routing logic below before spending a credit.&lt;/p&gt;

&lt;p&gt;Route on document type, not on verdict alone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tag each document on the way in&lt;/strong&gt; as third-party-issued or self-authored. This is the field that makes the rest work, and your document taxonomy usually implies it already.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;modified&lt;/code&gt; on a third-party document&lt;/strong&gt; → manual review, plus a request for a fresh copy from the issuing platform or bank. This is the case the layer exists for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;modified&lt;/code&gt; on a self-authored document&lt;/strong&gt; → note it against the file; do not escalate on it alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;inconclusive&lt;/code&gt; on a self-authored document&lt;/strong&gt; → record it and move on. Do not surface it. This single decision determines whether the layer is useful or hated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;inconclusive&lt;/code&gt; on a third-party document&lt;/strong&gt; → ask for the issuer-direct copy, or order the 4506-C.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;intact&lt;/code&gt;&lt;/strong&gt; → continue to the cash flow analysis, deposit review and business verification, which are doing the work this layer does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A human makes the credit decision.&lt;/strong&gt; Where a document finding contributes to adverse action, the reasons given must be the lender’s own verified findings, not a verdict string – and the applicant must be able to supply a replacement document from the issuer and have it re-reviewed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lender, not a vendor, is the creditor for ECOA and Regulation B purposes. Any routing scheme built on document-type tagging should be tested and monitored by the lender for disparate impact on a prohibited basis; nothing here substitutes for that testing.&lt;/p&gt;

&lt;p&gt;Keep the check identifier against the loan record so the outcome is retrievable if the decision is ever reviewed. The document-type walkthrough for the 1099 itself is on the &lt;a href="https://htpbe.tech/use-cases/fake-tax-document-detection/fake-1099-detection" rel="noopener noreferrer"&gt;fake 1099 detection&lt;/a&gt; page, and the adjacent income-document cases are covered in &lt;a href="https://htpbe.tech/blog/altered-paystub-w2-mortgage-underwriting" rel="noopener noreferrer"&gt;altered paystubs and W-2s&lt;/a&gt; and &lt;a href="https://htpbe.tech/blog/fake-tax-return-transcript-fraud-lending" rel="noopener noreferrer"&gt;fake tax return and transcript fraud&lt;/a&gt;. What the third verdict means in general is unpacked in &lt;a href="https://htpbe.tech/blog/what-inconclusive-means-pdf-verification-results" rel="noopener noreferrer"&gt;what &lt;code&gt;inconclusive&lt;/code&gt; actually means&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Self-employed underwriting is not short of diligence – it has more required documents, more analysis forms and more independent verification steps than a salaried file. What it is short of is any control reaching past the applicant’s uploaded PDF to the party that issued it – for the current tax year that party either cannot be reached yet, or does not exist separately from the applicant. On the documents that do have an issuer, asking whether the file is the one that issuer produced is a cheap question with a real answer. On the ones that do not, the honest thing is to say so and leave them alone.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>fintech</category>
      <category>fraud</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Validate Belgian VAT Numbers (BTW/TVA) in Node.js</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 05 Oct 2026 09:01:04 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/validate-belgian-vat-numbers-btwtva-in-nodejs-3l8h</link>
      <guid>https://dev.to/iurii_rogulia/validate-belgian-vat-numbers-btwtva-in-nodejs-3l8h</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/validate-belgian-vat-number-nodejs" rel="noopener noreferrer"&gt;vatnode.dev&lt;/a&gt;. The version on vatnode.dev is the canonical source — refer to it for the latest content.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://vatnode.dev/check/be" rel="noopener noreferrer"&gt;Belgium&lt;/a&gt; sits at the center of a lot of EU B2B trade, and its VAT number is simpler than most: there is no separate VAT-specific sequence to confuse with a company number, no sole-trader carve-out like the &lt;a href="https://vatnode.dev/blog/validate-dutch-vat-number-nodejs" rel="noopener noreferrer"&gt;Dutch btw-id&lt;/a&gt;, and only one register to reason about. The format check is trivial. The part worth getting right is what the Belgian company register can and cannot tell you when VIES is unavailable. Here is the full pipeline in Node.js – this post targets the Node.js/JavaScript implementation specifically; for the general EU-wide behavior of the &lt;a href="https://vatnode.dev/vat-api/be" rel="noopener noreferrer"&gt;&lt;code&gt;/vat-api/be&lt;/code&gt;&lt;/a&gt; endpoint, see the country reference.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Belgian VAT number looks like
&lt;/h2&gt;

&lt;p&gt;A Belgian VAT ID has a short, fixed shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;BE&lt;/code&gt; — country code&lt;/li&gt;
&lt;li&gt;10 digits – the body always starts with &lt;code&gt;0&lt;/code&gt; or &lt;code&gt;1&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So &lt;code&gt;BE0123456794&lt;/code&gt; is &lt;code&gt;BE&lt;/code&gt; followed by a 10-digit body whose first digit is &lt;code&gt;0&lt;/code&gt; or &lt;code&gt;1&lt;/code&gt;. You will also see it written as &lt;strong&gt;BTW&lt;/strong&gt; (the Dutch acronym, &lt;em&gt;Belasting over de Toegevoegde Waarde&lt;/em&gt;) or &lt;strong&gt;TVA&lt;/strong&gt; (the French acronym, &lt;em&gt;Taxe sur la Valeur Ajoutée&lt;/em&gt;) – same number, different label depending on which language the business operates in.&lt;/p&gt;

&lt;p&gt;The detail that actually matters for how you build the validator: &lt;strong&gt;the Belgian VAT number is the KBO/CBE enterprise number itself, with a &lt;code&gt;BE&lt;/code&gt; prefix.&lt;/strong&gt; KBO (Kruispuntbank van Ondernemingen, Dutch) and CBE (Banque-Carrefour des Entreprises, French) are the two names for the same national enterprise register. Every registered Belgian entity gets one enterprise number; VAT registration does not create a second identifier – it ‘activates’ the existing one for VAT purposes. That is a meaningfully simpler shape than a country that runs the VAT number and the company-registry number as two different sequences: in Belgium, one number does both jobs, and you never have to bridge between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Format validation in Node.js
&lt;/h2&gt;

&lt;p&gt;Reject malformed input before any network call. As with the sibling guides in this series, decide up front which mode you’re running in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Strict (API mode)&lt;/strong&gt; – your service exposes a documented &lt;code&gt;BE&lt;/code&gt; + 10-digit format. Reject anything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lenient (checkout mode)&lt;/strong&gt; – accept what customers type and try to make it work. Spaces, dots, and a missing &lt;code&gt;BE&lt;/code&gt; prefix are common; auto-correct them and log the original input.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BE_VAT_PATTERN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^BE&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;01&lt;/span&gt;&lt;span class="se"&gt;]\d{9}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normaliseBelgianVatId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;strict&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lenient&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cleaned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\s&lt;/span&gt;&lt;span class="sr"&gt;.&lt;/span&gt;&lt;span class="se"&gt;\-]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="c1"&gt;// Lenient mode: prepend BE when the user typed only the 10-digit body&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lenient&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;01&lt;/span&gt;&lt;span class="se"&gt;]\d{9}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cleaned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`BE&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;BE_VAT_PATTERN&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;cleaned&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Strict (default) — API consumers should send a fully-qualified ID&lt;/span&gt;
&lt;span class="nf"&gt;normaliseBelgianVatId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BE 0123.456.794&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// → 'BE0123456794'&lt;/span&gt;
&lt;span class="nf"&gt;normaliseBelgianVatId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0123456794&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// → null&lt;/span&gt;

&lt;span class="c1"&gt;// Lenient — for checkout forms&lt;/span&gt;
&lt;span class="nf"&gt;normaliseBelgianVatId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0123456794&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lenient&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="c1"&gt;// → 'BE0123456794'&lt;/span&gt;
&lt;span class="nf"&gt;normaliseBelgianVatId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BE212345679&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lenient&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="c1"&gt;// → null (11 digits, wrong length)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Belgium also has a real check digit, unlike the Netherlands’ post-2020 sole-trader numbers, which is worth using as a soft pre-filter. The rule: the last two digits of the 10-digit body equal &lt;code&gt;97 − (first eight digits mod 97)&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Soft signal only — a pass does NOT mean the number is registered.&lt;/span&gt;
&lt;span class="c1"&gt;// Worked example: body 12345678xx → 12345678 % 97 = 3 → 97 - 3 = 94 → BE0123456794.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasValidBelgianCheckDigits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^BE&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;01&lt;/span&gt;&lt;span class="se"&gt;]\d{9})&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;first8&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;check&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;remainder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;first8&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;97&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;remainder&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;97&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;97&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;remainder&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;check&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;hasValidBelgianCheckDigits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BE0123456794&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// → true&lt;/span&gt;
&lt;span class="nf"&gt;hasValidBelgianCheckDigits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BE0123456700&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// → false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;remainder === 0&lt;/code&gt; branch above (expecting &lt;code&gt;97&lt;/code&gt; rather than &lt;code&gt;00&lt;/code&gt;) is the conventional treatment described in most references on this checksum, but sources are not unanimous on that edge case – do not hard-fail a lookup on it locally. Treat the whole check-digit function the same way you’d treat any local checksum: a cheap way to catch a fat-fingered digit before you spend a network call, never a substitute for asking VIES.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A regex and a check digit prove the string is well-formed. Neither proves the entity is currently&lt;br&gt;
  VAT-registered – that answer only comes from VIES.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Calling VIES for Belgium
&lt;/h2&gt;

&lt;p&gt;VIES routes a &lt;code&gt;BE&lt;/code&gt; query to the Belgian tax administration and reports back what it says. For a live check you get back one of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A valid/invalid answer, with trader name and address when the number is valid and the trader has not opted out of disclosure – treat name/address as commonly present for Belgium, not guaranteed on every request.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MS_UNAVAILABLE&lt;/code&gt; — the Belgian node is temporarily unreachable.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SERVICE_UNAVAILABLE&lt;/code&gt; — VIES itself is degraded.&lt;/li&gt;
&lt;li&gt;A timeout after roughly 10 seconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No national node is online 100% of the time. The &lt;a href="https://vatnode.dev/blog/vies-downtime-guide" rel="noopener noreferrer"&gt;VIES downtime guide&lt;/a&gt; covers the failure modes worth designing for across every member state, and the pattern for Belgium is the same as anywhere else: &lt;code&gt;MS_UNAVAILABLE&lt;/code&gt; is not ‘invalid,’ and blocking checkout on it is a self-inflicted wound.&lt;/p&gt;

&lt;h2&gt;
  
  
  CBE/KBO enrichment: what it adds, and what it doesn’t
&lt;/h2&gt;

&lt;p&gt;vatnode runs a live integration against the CBE/KBO public register, and it is worth being precise about what that integration does and does not do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it adds.&lt;/strong&gt; When VIES confirms a Belgian number is valid, vatnode enriches the result with data VIES itself does not return: the company’s legal form (e.g. &lt;code&gt;Public limited company (NV)&lt;/code&gt;), its registration date, a NACE-based activity description, and the registry code under the label &lt;code&gt;Ondernemingsnummer&lt;/code&gt; – the enterprise number by its Belgian name. This is the same kind of enrichment covered in &lt;a href="https://vatnode.dev/blog/vat-number-company-data-enrichment" rel="noopener noreferrer"&gt;what a VAT check returns beyond valid or invalid&lt;/a&gt;: a richer profile riding on top of a validity answer VIES already gave you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it explicitly does not do: answer valid or invalid when VIES BE is unavailable.&lt;/strong&gt; Presence in the enterprise register is not the same claim as VAT registration. A Belgian entity can be listed and active in CBE/KBO without being registered for VAT – below a registration threshold, exempt, or deregistered for VAT while remaining active as a business. Deriving a valid/invalid verdict from register presence during a VIES outage would produce false positives: entities that look fine in the company register but are not actually VAT-registered. So when VIES BE is down, vatnode surfaces the VIES error rather than guessing from CBE data – the enrichment fields simply stay empty until VIES answers again. This mirrors how vatnode treats company/enterprise registers generally across the countries where it has one: useful for enrichment, never substituted for the VAT register itself. See the &lt;a href="https://vatnode.dev/docs/coverage" rel="noopener noreferrer"&gt;full country coverage&lt;/a&gt; for which sources back which country.&lt;/p&gt;

&lt;p&gt;That distinction is the one thing to get right in your own integration too: build enrichment as an optional, best-effort layer on a VIES-confirmed result, not as a fallback path for the yes/no question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full working example with the vatnode API
&lt;/h2&gt;

&lt;p&gt;vatnode runs format normalization and the requester-qualified VIES call to the Belgian node, then layers CBE enrichment on top when VIES answers. One call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.vatnode.dev/v1/vat/BE0123456794&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VATNODE_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Branch on the error code — see the error table below.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`VAT check failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="c1"&gt;// {&lt;/span&gt;
&lt;span class="c1"&gt;//   "valid": true,&lt;/span&gt;
&lt;span class="c1"&gt;//   "vatId": "BE0123456794",&lt;/span&gt;
&lt;span class="c1"&gt;//   "countryCode": "BE",&lt;/span&gt;
&lt;span class="c1"&gt;//   "countryName": "Belgium",&lt;/span&gt;
&lt;span class="c1"&gt;//   "companyName": "Example NV",&lt;/span&gt;
&lt;span class="c1"&gt;//   "companyAddress": "Voorbeeldstraat 1, 1000 Brussel",&lt;/span&gt;
&lt;span class="c1"&gt;//   "companyRegistrationDate": "2009-06-12",&lt;/span&gt;
&lt;span class="c1"&gt;//   "companyForm": "Public limited company (NV)",&lt;/span&gt;
&lt;span class="c1"&gt;//   "industryDescription": "Computer programming activities",&lt;/span&gt;
&lt;span class="c1"&gt;//   "registryCode": "0123.456.794",&lt;/span&gt;
&lt;span class="c1"&gt;//   "registryCodeName": "ondernemingsnummer",&lt;/span&gt;
&lt;span class="c1"&gt;//   "taxId": "0123.456.794",&lt;/span&gt;
&lt;span class="c1"&gt;//   "taxIdName": "ondernemingsnummer",&lt;/span&gt;
&lt;span class="c1"&gt;//   "source": "VIES",&lt;/span&gt;
&lt;span class="c1"&gt;//   "consultationNumber": "WAPIAAAAX9999999",&lt;/span&gt;
&lt;span class="c1"&gt;//   "checkId": "019d2a89-a5d9-7b97-b710-57b84604de2b",&lt;/span&gt;
&lt;span class="c1"&gt;//   "verifiedAt": "2026-08-28T08:30:00.000Z"&lt;/span&gt;
&lt;span class="c1"&gt;// }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify &lt;code&gt;BE0123456794&lt;/code&gt; yourself via VIES before treating it as a live example – this article is not asserting it belongs to any specific company. Notice &lt;code&gt;source&lt;/code&gt; stays &lt;code&gt;"VIES"&lt;/code&gt; here: unlike a country with an active national fallback, a Belgian response never carries a different &lt;code&gt;source&lt;/code&gt; value for CBE data – the enterprise-register fields are enrichment riding on the VIES verdict, not an alternate source of truth. If &lt;code&gt;companyForm&lt;/code&gt;, &lt;code&gt;industryDescription&lt;/code&gt;, or &lt;code&gt;registryCode&lt;/code&gt; come back &lt;code&gt;null&lt;/code&gt;, VIES answered but CBE enrichment did not – treat that as a gap in the extra data, not a problem with the validation itself. Field-by-field documentation lives in the &lt;a href="https://vatnode.dev/docs" rel="noopener noreferrer"&gt;API reference&lt;/a&gt;, and the &lt;a href="https://vatnode.dev/guides/vat-validation-nodejs" rel="noopener noreferrer"&gt;Node.js VAT validation guide&lt;/a&gt; covers the storage schema vatnode customers use across countries. If you’re weighing building this pipeline yourself against using a provider, &lt;a href="https://vatnode.dev/blog/build-vs-buy-vat-validation" rel="noopener noreferrer"&gt;build vs buy: VAT validation via VIES or an API&lt;/a&gt; walks through the trade-off in detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error handling
&lt;/h2&gt;

&lt;p&gt;Treat ‘we could not get an answer’ as a distinct state from ‘invalid.’ The vatnode API returns explicit, machine-readable error codes so you can branch correctly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;INVALID_FORMAT&lt;/code&gt; (400) – the string is not a well-formed VAT ID. Client mistake; surface it inline.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;INVALID_REQUESTER&lt;/code&gt; (422) – the requester VAT ID used for the qualified lookup was rejected.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RATE_LIMITED&lt;/code&gt; (429) – back off and retry.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VIES_UNAVAILABLE&lt;/code&gt; (503) – the Belgian node is down. Transient; queue a retry, do not treat as invalid.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VIES_ERROR&lt;/code&gt; (502) – VIES answered with an error. Transient.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UPSTREAM_TIMEOUT&lt;/code&gt; (504) – the upstream took too long. Transient.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;INTERNAL_ERROR&lt;/code&gt; (500) – retry, then alert.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because Belgium has no validity fallback – CBE is enrichment-only, as covered above – a &lt;code&gt;VIES_UNAVAILABLE&lt;/code&gt; response for a &lt;code&gt;BE&lt;/code&gt; number is a genuine dead end until VIES recovers, not a signal to check somewhere else and guess. Queue it for retry rather than blocking checkout: the customer almost certainly gave you a real number, and you simply cannot confirm it yet. The &lt;a href="https://vatnode.dev/blog/handling-vies-errors-in-code" rel="noopener noreferrer"&gt;VIES error codes guide&lt;/a&gt; covers the full retry strategy for every code, and the &lt;a href="https://vatnode.dev/guides/vat-audit-trail" rel="noopener noreferrer"&gt;audit trail guide&lt;/a&gt; covers what to persist once VIES does answer – &lt;code&gt;consultationNumber&lt;/code&gt; in particular, since that is the evidence an auditor may ask for on an intra-EU reverse-charge supply.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is a Belgian VAT number?
&lt;/h3&gt;

&lt;p&gt;It is the country code &lt;code&gt;BE&lt;/code&gt; followed by the 10-digit KBO/CBE enterprise number (&lt;em&gt;Ondernemingsnummer&lt;/em&gt; in Dutch, &lt;em&gt;numéro d’entreprise&lt;/em&gt; in French). Belgium does not issue a separate VAT-specific sequence – the enterprise number itself is ‘activated’ for VAT when the entity registers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the Belgian VAT number the same as the KBO/CBE enterprise number?
&lt;/h3&gt;

&lt;p&gt;Yes. Unlike some member states that issue a distinct VAT identifier alongside a company number, Belgium’s BTW/TVA number is the enterprise number with a &lt;code&gt;BE&lt;/code&gt; prefix – there is no second number to keep track of.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does vatnode use the CBE register to validate a Belgian VAT number when VIES is down?
&lt;/h3&gt;

&lt;p&gt;No. CBE/KBO enrichment adds company data – name, legal form, registration date, activity – to a VIES-confirmed result. It is never used to answer valid or invalid on its own, because a company can be listed and active in the enterprise register without being VAT-registered.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the check-digit rule for a Belgian VAT number?
&lt;/h3&gt;

&lt;p&gt;The last two digits of the 10-digit body equal 97 minus the remainder of the first eight digits divided by 97. It is a cheap local pre-filter for typos, not proof that the number is registered – only VIES confirms that.&lt;/p&gt;

&lt;p&gt;This is general information about EU VAT and VIES, not tax or compliance advice – confirm the treatment of your own case with a qualified adviser.&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  Validate BE VAT numbers with company data in one call
&lt;/h3&gt;

&lt;p&gt;vatnode runs the requester-qualified VIES check against the Belgian node and layers CBE/KBO company data on top when VIES confirms a match, with a stable response shape and the consultation number for your audit trail. Free plan, 100 requests/month.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://vatnode.dev/register" rel="noopener noreferrer"&gt;Get a free API key&lt;/a&gt; · &lt;a href="https://vatnode.dev/docs" rel="noopener noreferrer"&gt;API reference&lt;/a&gt; · &lt;a href="https://vatnode.dev/vat-api/be" rel="noopener noreferrer"&gt;Belgian VAT API reference&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>tax</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Multi-Region Next.js Without Breaking the Budget</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Fri, 02 Oct 2026 10:00:56 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/multi-region-nextjs-without-breaking-the-budget-4idp</link>
      <guid>https://dev.to/iurii_rogulia/multi-region-nextjs-without-breaking-the-budget-4idp</guid>
      <description>&lt;p&gt;A client asks a version of the same question every few months, usually right after a customer in the US or Australia complains that the site feels sluggish: ‘should we go multi-region?’ It’s a reasonable question and almost never the right one to answer first. The right first question is where their users actually are, and whether the business can tell the difference between 80ms and 280ms. Most of the time it can’t, and the conversation ends there. Sometimes it can, and then the real question starts: pay a managed platform to multiply your bill per region, or take on the ops work of running your own infrastructure in more than one place. Both are legitimate answers. Neither is free.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Single-Region Actually Costs You
&lt;/h2&gt;

&lt;p&gt;A Next.js app deployed to one region – one Vercel region, one VPS, doesn’t matter – serves every request from that single physical location. A user near it gets a fast response. A user on the other side of the planet pays for the distance: every request round-trips through however many hundred milliseconds of speed-of-light-plus-routing delay that separate them from your server, on top of whatever your app itself takes to respond. As a rough order of magnitude, cross-continental round trips (say, Europe to US West, or Europe to Australia) commonly add somewhere in the 100–300ms range before your code runs at all. That’s not a number I measured for any specific deployment – it’s the general physics of distance and network hops, and it’s why CDNs exist for static assets in the first place. For a page that fires off several sequential API calls or waits on server-side rendering, that per-request tax compounds.&lt;/p&gt;

&lt;p&gt;The other cost of single-region isn’t latency, it’s fragility. One region means one point of failure. If that region’s data center, network, or your host’s account there has a bad day, the whole app goes down for everyone, not just for people near it. For most businesses that’s an acceptable, rare risk they’ve implicitly accepted by hosting anywhere at all. For a small number, an hour of downtime has a real, calculable cost, and that changes the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Single-Region Is the Right Answer
&lt;/h2&gt;

&lt;p&gt;If your audience is regional – a Finnish business serving Finnish customers, an internal tool used by one company’s staff in one office, a SaaS product still validating whether anyone wants it before it has users outside its founder’s own time zone – multi-region hosting solves a problem you don’t have. The added complexity buys you milliseconds nobody will notice and infrastructure nobody on the team is set up to operate. I’ve watched early-stage teams spend a sprint on cross-region deployment before they’d spent a week finding out whether the product itself works. That’s infrastructure investment ahead of product-market fit, and it’s close to the most common form of premature optimization I see in this kind of work.&lt;/p&gt;

&lt;p&gt;Even for a genuinely international audience, single-region with a CDN in front of it covers more ground than people assume. Static assets, images, and anything cacheable already get served from edge locations near the user regardless of where your app server lives – that’s what a CDN is for, and it’s a much smaller lift than duplicating the app itself. What a CDN can’t fix is the latency of a dynamic, server-rendered request or an API call that has to reach your one origin server no matter where the visitor is. That’s the actual thing multi-region hosting buys you: faster dynamic responses and redundancy against a single region’s outage, nothing else. If your app is mostly static pages with light dynamic bits, you’re paying for a problem the CDN mostly already solved.&lt;/p&gt;

&lt;p&gt;The honest litmus test: can you name the customer segment that’s affected, and can you say what it costs the business when they wait an extra 150ms or when the one region goes down for an hour? If the answer is ‘I’m not sure’ or ‘it would be annoying but nothing breaks,’ you don’t need multi-region yet. Build the case with real numbers before you build the infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Managed Platforms Charge You Per Region
&lt;/h2&gt;

&lt;p&gt;If the case is real, the first thing to understand is that a platform like Vercel doesn’t give you multi-region for free just because you flip a setting. The billing mechanism, generally, multiplies with region count: serverless/edge function execution is billed per invocation, and running the same function in three regions instead of one means three times the invocations for the same traffic, not the same total split three ways. Bandwidth is typically metered per region too. I’m not going to quote a specific current dollar figure here – pricing pages change and I haven’t verified today’s numbers against a real multi-region Vercel bill – but the mechanism itself is well documented and worth understanding before you commit: you’re not paying more to reach more users, you’re paying a multiplier on the same users because the same work now runs in more places.&lt;/p&gt;

&lt;p&gt;Pikkuna, an e-commerce platform I built that ships to 32 countries, &lt;a href="https://iurii.rogulia.fi/projects/pikkuna-ecommerce-platform" rel="noopener noreferrer"&gt;runs on Vercel across three regions&lt;/a&gt; – Frankfurt, Stockholm, and Cleveland, per the project’s own numbers. The specific routing and cost mechanics of that setup are Vercel’s, not something I’ve documented in code, and I don’t have data on why three was picked or how it performs against a different count, so I won’t infer either. What I will say as my own general heuristic, not a conclusion drawn from Pikkuna: multi-region rarely needs to mean many regions. For most businesses that actually need it, two or three, chosen to bracket where the customers are, is usually enough – not a global mesh.&lt;/p&gt;

&lt;p&gt;The convenience you’re buying at that price is real: automatic geo-routing sends each user to the nearest region without you writing any routing logic, and the platform’s operators – not you – own keeping every region’s build in sync, healthy, and patched.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Self-Hosting 2–3 Regions Actually Costs
&lt;/h2&gt;

&lt;p&gt;The instinct once you’ve seen a per-region multiplier on a platform bill is to reach for self-hosting instead. &lt;a href="https://iurii.rogulia.fi/blog/self-hosting-caddy-docker-vps" rel="noopener noreferrer"&gt;The vatnode.dev API runs on a €6/month Vultr instance&lt;/a&gt;, one region, one box – but that box runs a lightweight Hono API, a BullMQ worker, and Redis, not a Next.js SSR app, and it’s not a fair stand-in for what a Next.js app needs. This site’s own Next.js deployment runs under a 1 GiB memory ceiling on Coolify – tight enough that CV PDF generation was moved out of runtime and into the build step specifically to stay under it. A self-hosted Next.js SSR box needs real headroom in a way a small API worker doesn’t. So multiplying €6/month by two or three regions to get a tidy €12–18/month figure would understate the real cost; budget for a Next.js-sized VPS per region, not an API-sized one. The raw compute line is still the cheapest part of this decision either way. It’s also the easy part to see. The ops cost below is the harder one.&lt;/p&gt;

&lt;p&gt;Running your own app in N regions instead of one region means you now own, in full, everything the managed platform used to do for you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keeping deploys in sync.&lt;/strong&gt; A release has to land on every region’s server, in order, without one region silently running an older build for an hour because a deploy step failed on it and nobody noticed. That’s either a deploy script written to fan out to N hosts and verify each one, or N manual deploys, and the second option is the one where a self-hosted multi-region setup is most likely to fail quietly – the cause is a deploy someone forgot to run on the second box, not the architecture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets and environment management across N hosts.&lt;/strong&gt; Every API key, database credential, and signing secret now needs to exist correctly on every server, and stay in sync when it rotates. One region’s stale environment variable file is the kind of bug that’s invisible until the region that has it handles a request that needs the new value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring N hosts instead of one.&lt;/strong&gt; &lt;a href="https://iurii.rogulia.fi/blog/structured-logging-pino-nextjs" rel="noopener noreferrer"&gt;Structured logging and error tracking&lt;/a&gt; that made sense as ‘check the one dashboard’ now needs to distinguish which region a request or error came from, and someone has to actually watch all N dashboards, not just the one they’re used to checking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database strategy.&lt;/strong&gt; This is the part that’s easy to underestimate. An app in three regions still needs its data to be consistent somewhere. A single-writer database with regional read replicas keeps writes simple but means every write from a far-away region pays the same cross-region latency the whole exercise was meant to avoid. A multi-writer or regional-shard setup avoids that but is real distributed-systems work – conflict resolution, replication lag, and a much larger set of failure modes than a single Postgres instance ever had. There is no version of this that’s ‘just point the app at three databases’ and walk away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routing users to the nearest region.&lt;/strong&gt; A managed platform’s edge network does this invisibly. Self-hosted, it means GeoDNS or an anycast setup in front of your regions, configured and maintained by you, plus a plan for what happens when the geo-routing layer sends a user to a region that’s currently down – which is exactly the failure mode multi-region was supposed to protect against in the first place.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is a reason not to self-host multi-region. It’s a reason to price it honestly. The monthly VPS bill for three boxes is the smallest number in this decision and the easiest one to put in a proposal, which is exactly why proposals get the total wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trade-Off, Stated Plainly
&lt;/h2&gt;

&lt;p&gt;Neither path is the objectively correct one. A managed platform’s per-region pricing buys you geo-routing, region health, and deploy consistency that someone else operates and gets paged for, not you. Self-hosting trades that convenience for a lower recurring infrastructure bill and a real, ongoing commitment of engineering time to keep N regions in sync, monitored, and correctly routed – work that never shows up on an invoice, only in someone’s calendar every time there’s a deploy, an incident, or a database question a single-region setup never had to ask.&lt;/p&gt;

&lt;p&gt;The businesses that get this right are the ones that make the choice deliberately, with the actual ops capacity to operate whichever side they pick, instead of defaulting to ‘self-hosted is always cheaper’ or ‘managed is always safer.’ Both defaults are wrong often enough to be worth checking against the specific case in front of you: who’s on call, how often you deploy, how much a bad region-sync bug actually costs versus how much the platform’s multiplier costs, and whether the audience genuinely needs multi-region at all – the question this article opened with, and the one worth answering again before signing up for either bill.&lt;/p&gt;

&lt;p&gt;That’s the decision worth working through deliberately: an honest accounting of what each path actually costs, before either one gets built.&lt;/p&gt;




&lt;p&gt;Not sure whether the ops burden of running your own regions would outweigh the platform’s markup, or the other way round? &lt;a href="https://iurii.rogulia.fi/contact" rel="noopener noreferrer"&gt;Get in touch&lt;/a&gt; – I’m available for &lt;a href="https://iurii.rogulia.fi/services/technical-consultation" rel="noopener noreferrer"&gt;technical consultation&lt;/a&gt; on infrastructure and deployment decisions like this one.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Further reading:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://iurii.rogulia.fi/blog/self-hosting-caddy-docker-vps" rel="noopener noreferrer"&gt;Self-Hosting Node.js API: Caddy, Docker Compose, VPS&lt;/a&gt; – the single-region self-hosted pattern this article’s cost anchor is drawn from&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://iurii.rogulia.fi/blog/self-hosted-next-image-optimization" rel="noopener noreferrer"&gt;next/image Optimization on a Self-Hosted VPS&lt;/a&gt; – another self-hosted-vs-managed trade-off, at the image pipeline level instead of the region level&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://iurii.rogulia.fi/blog/structured-logging-pino-nextjs" rel="noopener noreferrer"&gt;Structured Logging with Pino in Next.js&lt;/a&gt; – the monitoring foundation you need before you’re watching N regions instead of one&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://iurii.rogulia.fi/projects/pikkuna-ecommerce-platform" rel="noopener noreferrer"&gt;Pikkuna – E-commerce for Vinyl Curtains &amp;amp; PVC Products&lt;/a&gt; – the three-region Vercel deployment referenced above&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nextjs</category>
      <category>vercel</category>
      <category>infrastructure</category>
      <category>performance</category>
    </item>
    <item>
      <title>How to Spot a Fake ADP Pay Stub</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Fri, 02 Oct 2026 10:00:38 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/how-to-spot-a-fake-adp-pay-stub-320a</link>
      <guid>https://dev.to/iurii_rogulia/how-to-spot-a-fake-adp-pay-stub-320a</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/how-to-spot-fake-adp-pay-stub" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt;. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A pay stub with ADP across the top is harder to review than an unbranded one, not easier. A stub from a small employer’s in-house payroll gives you nothing to anchor on, so you treat it with appropriate suspicion. A stub carrying the layout of a large payroll platform arrives pre-anchored: it looks like something a real system produced, and it usually is — because the fastest route to a convincing fake ADP pay stub is to start from a real one.&lt;/p&gt;

&lt;p&gt;The general visual checklist — the math, the fonts, the deductions, the alignment — already has a home in &lt;a href="https://htpbe.tech/blog/how-to-spot-fake-pay-stubs-guide" rel="noopener noreferrer"&gt;how to spot a fake pay stub&lt;/a&gt;, and it applies here unchanged. Run it. It just cannot tell you anything about how this particular file reached your inbox, and with a document from a hosted payroll platform, the delivery path is where most of the usable information sits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why “It Says ADP” Answers Almost Nothing
&lt;/h2&gt;

&lt;p&gt;The employer block, the earnings and deductions grid, the footer text, the general shape of the page — that is the visible layer, and all of it is inherited when a forger works from a genuine document. Nobody rebuilds an ADP-style layout from scratch when they can download the real thing and retype one number in it, in ordinary consumer software. The parts a reviewer is trained to recognize are precisely the parts a careful edit never touches.&lt;/p&gt;

&lt;p&gt;So the branding is not evidence. It tells you which system the document claims to have come from. That claim is useful — it gives you an expectation to test — but it is the start of the question, not the answer.&lt;/p&gt;

&lt;p&gt;None of this is a criticism of ADP, and it is not a claim that anything on ADP’s side went wrong. It is a description of where custody ends. A payroll provider controls the document while it is inside the portal. The moment an employee clicks download, the file is on a personal device, and no payroll platform — ADP or any other — has any further reach over it. Everything a reviewer worries about happens in that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Delivery Path Is the Part Worth Interrogating
&lt;/h2&gt;

&lt;p&gt;So ask the question that separates cases: how did this file get from the payroll portal to you? A pay stub starts life as an institutional document, but employees and employers have several legitimate ways to get one to you. Knowing which route the document took tells you more than knowing what the page looks like.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Direct download from the portal.&lt;/strong&gt; The employee signs in to ADP’s employee self-service, opens the statement, and saves the file the platform serves them. Shortest path: one system produced the document, and it reached you with nothing in between. If the applicant says they pulled it from MyADP last Tuesday, this is the route they are describing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An image-format statement.&lt;/strong&gt; ADP’s own documentation notes that the format depends on how the employee’s company is configured, and that, where the statement is served as an image, saving it means right-clicking and choosing “Save Picture As” rather than saving a PDF (&lt;a href="https://portal.adp.com/static/help/enterprise/en/ess/pay_statements/pay_statement_saving.htm" rel="noopener noreferrer"&gt;ADP portal help&lt;/a&gt;, accessed August 2026). An employee on that configuration who needs to send you a PDF has to wrap the image in one, using whatever tool is at hand. The result is a genuine statement inside a file the payroll platform never produced. An applicant on that configuration may genuinely not have a portal-generated PDF to give you — a reason to ask, not a reason to assume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Employer-forwarded.&lt;/strong&gt; HR pulls the statement on the employee’s behalf and emails it. A normal path, and one more step between the payroll system and you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Printed and scanned.&lt;/strong&gt; The employee prints the statement and scans or photographs it, often because that is what a form asked for. What you receive is an image of a page rather than the file the payroll system produced. Printing is not suspicious in itself; it does mean you are reviewing a derivative, so if the original matters, ask for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phone screenshot.&lt;/strong&gt; The employee opens the statement in the ADP mobile app, screenshots it, and shares the picture, sometimes converted to PDF by the phone itself. Same consequence as the scan: what reaches you is a picture of the statement, not the export.&lt;/p&gt;

&lt;p&gt;These are not equivalent. A direct download and an employer forward can both put the original file in your hands — the forward just adds a custodian between the payroll system and you, which is a chain-of-custody question rather than a different document. The other three routes produce something else: an image wrap, a scan or a screenshot all hand you a picture of the statement, not the file the payroll platform produced. All five are normal behavior, and none of them is an accusation. They are simply not the same object, and it is worth knowing which one is in front of you.&lt;/p&gt;

&lt;p&gt;Which is a reason to ask one question you probably are not asking today: &lt;em&gt;how did you get this file?&lt;/em&gt; If the answer and the document disagree, that gap is the thing to follow up on. And if the direct export cannot be produced on request, keep the request open rather than drawing a conclusion from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Structural Check Adds
&lt;/h2&gt;

&lt;p&gt;A PDF is not a picture of a page. It is a structured file that records how it was assembled and, when it is opened and saved again, what happened to it afterwards. That record is separate from the layout, which is why retyping a figure convincingly does not touch it. It is the one part of the document a forger working from a genuine original does not inherit cleanly.&lt;/p&gt;

&lt;p&gt;HTPBE reads that structure and returns one of three verdicts, along with a list of named modification markers describing what it found. For a stub claimed to be an ADP export, they mean roughly this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;modified&lt;/code&gt;&lt;/strong&gt; — the file carries structural evidence that it was changed after it was first generated. This is the case the visual checklist is blind to: the page can look entirely normal while the file itself records that it was written to again. It is not a finding about a person, and it is not proof of intent — a benign step in the delivery chain can produce it too. It is a reason to ask for the document again, from its source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;inconclusive&lt;/code&gt;&lt;/strong&gt; — the file does not give enough of a basis to assess integrity either way. Treat it as “not answered,” not as “answered no.” The next step is the same one a missing answer always calls for: ask for the document again from its source, rather than reading anything further into the result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;intact&lt;/code&gt;&lt;/strong&gt; — no structural evidence that the file was changed after it was created. This is the absence of evidence of alteration. It is not a statement that the employment is real or that the figures are true.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One boundary worth being precise about, because it is the difference between a useful tool and an overclaim: HTPBE does not hold a reference copy of what an ADP pay statement is supposed to look like, and it does not check the document against ADP’s records. It has no relationship with ADP and no access to any payroll system. It answers a narrower question — whether this file shows signs of having been changed after whatever system produced it — and that question is answerable without knowing anything about the employer, the employee or the payroll provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Verification Route ADP Actually Provides
&lt;/h2&gt;

&lt;p&gt;Structural analysis tells you about the file. It cannot tell you whether the person works there or earns that amount, and no amount of PDF forensics will. For that there is a purpose-built route, documented for ADP-run payroll: ADP offers an employment and income verification service that answers verification requests from payroll-linked data, on the employer’s behalf, through a secure portal (&lt;a href="https://www.adp.com/what-we-offer/products/adp-smartcompliance/employment-verification.aspx" rel="noopener noreferrer"&gt;ADP SmartCompliance Employment Verification&lt;/a&gt;, accessed August 2026).&lt;/p&gt;

&lt;p&gt;The conditions attached to that route are the point, not an obstacle. ADP describes the service as supporting applicable US Fair Credit Reporting Act requirements by releasing reports only to credentialed verifiers who certify a permissible purpose, with income verifications additionally requiring certification that the employee consented to the release. Whether you have a permissible purpose, and what consent you need, are your own determination under the rules that apply to you — not something a PDF check can answer. Where those conditions are met, that route answers the income question directly, which no inference drawn from a file can.&lt;/p&gt;

&lt;p&gt;The two checks answer different questions and sit in different places. The structural check runs on arrival, in seconds, on every document, and costs nothing in applicant friction. The verification request is slower, involves credentialing, and is what you escalate to. Using the first to decide when to spend the second is a reasonable way to run a queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Does Not Solve
&lt;/h2&gt;

&lt;p&gt;Two limits, because a review that misunderstands them is worse than one that skips the check entirely.&lt;/p&gt;

&lt;p&gt;A structurally unremarkable file can still be false. If a stub was generated inside any payroll system with figures that were wrong before the PDF existed — a complicit employer, an employee with the access to run payroll — the file is clean because the fraud happened upstream of it. Structural analysis has nothing to say there. The control for that case is verification against the employer or the payroll provider, not forensics.&lt;/p&gt;

&lt;p&gt;And the absence of structural evidence is not the presence of proof. &lt;code&gt;intact&lt;/code&gt; means the file does not show signs of post-creation modification. It does not mean the employment exists, the salary is accurate, or the document is what it claims to be. Treat it as one input into a review, never as a clearance.&lt;/p&gt;

&lt;p&gt;The caution runs the other direction too. A &lt;code&gt;modified&lt;/code&gt; verdict is a routing signal for a human, not a decision. It says the file was written to after it was created; it does not say who did it or why, and there are ordinary reasons a file gets rewritten in transit. Wiring any structural verdict directly to an adverse decision about an applicant is a misuse of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting It Into a Review Queue
&lt;/h2&gt;

&lt;p&gt;For a single document, the sequence is: run the visual checks, ask how the file was obtained, and check the file itself if the answer and the document disagree — or if the decision is large enough that “looks right” is not a good enough bar. Registering on this site gives you five checks to try that on real documents, with pay-per-check after that.&lt;/p&gt;

&lt;p&gt;At volume the order inverts, because the structural check is the one that scales. It is a single integration: submit the document as it arrives, then read back the verdict and its named modification markers as structured data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.htpbe.tech/v1/analyze &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_API_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"url": "https://your-storage.example.com/paystubs/applicant-4417.pdf"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The routing is the part to design deliberately. &lt;code&gt;modified&lt;/code&gt; goes to a human, along with a request for a fresh copy pulled directly from the portal — which, for a hosted payroll platform, is usually a request the applicant can satisfy in a couple of minutes. &lt;code&gt;inconclusive&lt;/code&gt; goes to a human too: an unanswered question is not a pass, and the same request for the document from its source is the cheapest way to resolve it. &lt;code&gt;intact&lt;/code&gt; continues into the content review, which is where the visual checklist earns its place. Endpoints, the response shape and test keys for building against deterministic sample scenarios before spending a credit are in &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;the API reference&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Short Version
&lt;/h2&gt;

&lt;p&gt;Looking harder at the page will not spot a fake ADP pay stub, because the page is usually genuine and only one field is not. What you have is a claim — this came from a payroll portal on a specific date — and two independent ways to test it. Ask how the file was obtained, then check whether the file’s own construction is consistent with the answer. When the income itself is the question, go to the verification service built for it. The PDF check is fast and runs on everything; the verification request goes to the source and runs on what the PDF check flags.&lt;/p&gt;

&lt;p&gt;For adjacent document types and workflows, see &lt;a href="https://htpbe.tech/blog/how-to-spot-fake-pay-stubs-guide" rel="noopener noreferrer"&gt;how to spot a fake pay stub&lt;/a&gt; for the general visual checklist, &lt;a href="https://htpbe.tech/blog/altered-paystub-w2-mortgage-underwriting" rel="noopener noreferrer"&gt;altered paystubs and W-2s&lt;/a&gt; for mortgage underwriting, &lt;a href="https://htpbe.tech/blog/payslip-fraud-hr-detection" rel="noopener noreferrer"&gt;payslip fraud in HR&lt;/a&gt; for hiring and background verification, and the &lt;a href="https://htpbe.tech/use-cases/fake-pay-stub-detection" rel="noopener noreferrer"&gt;fake pay stub detection use case&lt;/a&gt; for the product view.&lt;/p&gt;

&lt;p&gt;ADP, MyADP and ADP SmartCompliance are trademarks of ADP, Inc. and are used here only nominatively, to describe a widely used payroll platform and the documents and services it produces. HTPBE is not affiliated with, endorsed by, sponsored by or connected to ADP in any way.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>fintech</category>
      <category>fraud</category>
      <category>webdev</category>
    </item>
    <item>
      <title>GDPR and Customer VAT Data: What You're Actually Storing</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Fri, 02 Oct 2026 09:00:50 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/gdpr-and-customer-vat-data-what-youre-actually-storing-12l0</link>
      <guid>https://dev.to/iurii_rogulia/gdpr-and-customer-vat-data-what-youre-actually-storing-12l0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/gdpr-vat-data-storage" rel="noopener noreferrer"&gt;vatnode.dev&lt;/a&gt;. The version on vatnode.dev is the canonical source — refer to it for the latest content.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You called a VAT validation API, got back a JSON object with a dozen-odd fields, and wrote the whole thing to a row in your database. That’s the normal path. This post is about a narrower question: which of those fields are personal data under GDPR, and how do you think about minimizing and retaining them without either over-collecting or deleting evidence you need.&lt;/p&gt;

&lt;p&gt;This is general information about GDPR and VAT data, not legal advice. How data-protection law applies to your specific storage and retention is a question for qualified counsel – nothing here is a substitute for that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a check actually returns
&lt;/h2&gt;

&lt;p&gt;A VAT validation response carries more than a valid/invalid flag. Depending on the source that answered, a typical response includes, among other fields: &lt;code&gt;valid&lt;/code&gt;, &lt;code&gt;vatId&lt;/code&gt;, &lt;code&gt;countryCode&lt;/code&gt;, &lt;code&gt;countryName&lt;/code&gt;, &lt;code&gt;companyName&lt;/code&gt;, &lt;code&gt;companyAddress&lt;/code&gt;, &lt;code&gt;checkId&lt;/code&gt;, &lt;code&gt;verifiedAt&lt;/code&gt;, &lt;code&gt;source&lt;/code&gt;, and a nullable &lt;code&gt;consultationNumber&lt;/code&gt; – &lt;a href="https://vatnode.dev/blog/consultation-number-for-finance" rel="noopener noreferrer"&gt;what that field is and why it’s nullable&lt;/a&gt; is its own topic; here it matters only as one more field in the row you’re storing. National fallback lookups – &lt;a href="https://vatnode.dev/vat-api" rel="noopener noreferrer"&gt;tax authority and company registry APIs&lt;/a&gt;, not VIES – can add registration date, legal form, activity description, registry codes, a commercial or trade name distinct from the legal name, and an operational status.&lt;/p&gt;

&lt;p&gt;One field is easy to miss and worth calling out: &lt;code&gt;registryPrivacy&lt;/code&gt;, a boolean some national registers set when the record itself has been flagged as restricted from publication at the registrant’s own request – the &lt;a href="https://vatnode.dev/check/fr" rel="noopener noreferrer"&gt;French&lt;/a&gt; &lt;em&gt;non-diffusible&lt;/em&gt; marker and the Dutch &lt;em&gt;afscherming&lt;/em&gt; setting are the two concrete examples of this mechanism. vatnode’s API returns the full record to the caller even when &lt;code&gt;registryPrivacy&lt;/code&gt; is true; only vatnode’s own public &lt;code&gt;/check&lt;/code&gt; pages mask it. That distinction matters for what comes next: the registry itself has already decided this record deserves handling, and what you do with it downstream is a decision you own, not one the API makes for you.&lt;/p&gt;

&lt;p&gt;None of this is a certification. Format validity doesn’t mean a company is registered or active, and VIES doesn’t certify or guarantee anything about the counterparty – it answers the specific question it was asked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which fields can be personal data
&lt;/h2&gt;

&lt;p&gt;A VAT number tied to an incorporated company is generally not personal data – it identifies a legal entity, not a natural person. The line moves when the number identifies an individual. In several EU member states, a self-employed person or sole trader holds a VAT number linked to them as a natural person, not to a separate corporate entity.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://vatnode.dev/check/nl" rel="noopener noreferrer"&gt;The Netherlands&lt;/a&gt; is a concrete, citable example of how directly that link can run. Until 1 January 2020, Dutch sole-trader VAT numbers embedded the holder’s BSN (the citizen service number) – the Dutch data protection authority found no legal basis for that construction, and the Dutch tax authority moved sole traders to a separate randomized &lt;em&gt;btw-id&lt;/em&gt; instead. That’s one jurisdiction’s history, not a rule that generalizes to all 27 member states, and this post doesn’t attempt to list which ones structure sole-trader VAT numbers the same way.&lt;/p&gt;

&lt;p&gt;The practical takeaway: where a VAT number, name, or address relates to an identifiable natural person – a sole trader or other individual – that data may constitute personal data. The &lt;code&gt;companyName&lt;/code&gt; and &lt;code&gt;companyAddress&lt;/code&gt; fields your check returns are exactly where this shows up. For an incorporated company those fields describe the entity. For a sole trader, they can be the person’s own name and address, returned to you by a registry or VIES rather than provided directly by the individual – which raises a transparency question (what the individual was told, and by whom, about that data reaching you) that this post flags without resolving.&lt;/p&gt;

&lt;p&gt;Whether the counterparty in front of you falls on one side of that line or the other is part of your own analysis, case by case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data minimization
&lt;/h2&gt;

&lt;p&gt;Minimizing what you store doesn’t conflict with keeping an audit trail – the two apply to different things. Minimize what you &lt;em&gt;collect and expose&lt;/em&gt;; retain what your invoice and audit trail actually needs. An append-only check log, as covered in &lt;a href="https://vatnode.dev/blog/storing-vat-ids-postgres" rel="noopener noreferrer"&gt;storing VAT IDs in PostgreSQL&lt;/a&gt;, is immutable &lt;em&gt;within&lt;/em&gt; a bounded retention window – that’s a durability property, not a promise to keep every field forever. A cached result is a different layer again: &lt;a href="https://vatnode.dev/blog/caching-vies-responses-compliance" rel="noopener noreferrer"&gt;caching VIES responses without losing compliance evidence&lt;/a&gt; walks through why a fast Redis-layer cache and the durable evidence row are not the same thing and shouldn’t be minimized the same way.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;registryPrivacy&lt;/code&gt; is the concrete case where this earns its keep. If a national register has flagged a record as restricted from publication, that’s a signal worth acting on in your own exposure surface – who can see &lt;code&gt;companyName&lt;/code&gt; and &lt;code&gt;companyAddress&lt;/code&gt; in your dashboard, your customer-facing pages, your support tooling – even though the API itself still returns the full record to you as the caller. What you store because you need it for evidence and what you display or forward to other systems are two separate decisions.&lt;/p&gt;

&lt;p&gt;The same logic applies to fields you don’t need at all. If your business process only ever checks that a number is currently valid and doesn’t use registration date or activity description, there’s no minimization reason to persist those fields just because the API returned them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lawful basis
&lt;/h2&gt;

&lt;p&gt;Storing personal data under GDPR requires a lawful basis – &lt;a href="https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A32016R0679" rel="noopener noreferrer"&gt;Article 6&lt;/a&gt; sets out the closed list. Which basis applies to a given case – a B2B onboarding check, an invoice you’re required to keep, a &lt;a href="https://vatnode.dev/docs/vat-monitoring" rel="noopener noreferrer"&gt;recurring subscription revalidation&lt;/a&gt; – is part of your own analysis of your specific processing, not something a blog post can assign on your behalf. The basis you land on can also shape how long you can justify holding the data, which is the next question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retention: floor and ceiling
&lt;/h2&gt;

&lt;p&gt;Two obligations pull in different directions, and reconciling them is jurisdiction-specific.&lt;/p&gt;

&lt;p&gt;Tax and invoice record-keeping law sets a floor. &lt;a href="https://eur-lex.europa.eu/eli/dir/2006/112/oj" rel="noopener noreferrer"&gt;Council Directive 2006/112/EC&lt;/a&gt; Article 247(1) leaves the storage period to each EU member state rather than fixing one EU-wide number – there is no single figure to cite. As a schema-level default, &lt;a href="https://vatnode.dev/blog/storing-vat-ids-postgres" rel="noopener noreferrer"&gt;storing VAT IDs in PostgreSQL&lt;/a&gt; notes that invoice retention commonly runs around 6–10 years, jurisdiction-dependent; confirm the period that applies to you before writing a deletion policy.&lt;/p&gt;

&lt;p&gt;GDPR’s storage-limitation principle (Article 5(1)(e)) sets a ceiling &lt;em&gt;principle&lt;/em&gt; – data shouldn’t be kept longer than necessary for the purpose it was collected for – without attaching a number of its own. In practice, that ceiling is usually satisfied by aligning your retention period with the invoice obligation that gave you the floor: you keep the record for as long as the tax law requires it as evidence, and no explicit further period beyond that. How exactly the floor and ceiling reconcile in a given member state, for a given record type, is a question for qualified counsel, not a formula this post can supply.&lt;/p&gt;

&lt;h2&gt;
  
  
  Erasure vs. immutable evidence
&lt;/h2&gt;

&lt;p&gt;A sole trader may ask you to erase their data while a record-keeping or evidence obligation may point the other way. GDPR anticipates that tension – erasure rights under Article 17 are not absolute, and record-keeping obligations are one of the recognized grounds that can limit them. How that resolves for a specific record, in a specific country, for a specific request, is a question for qualified counsel, not something this post can tell you to refuse or to honour. What you can control ahead of time is the design: an &lt;a href="https://vatnode.dev/guides/vat-audit-trail" rel="noopener noreferrer"&gt;audit trail built from append-only check records&lt;/a&gt; makes it possible to identify exactly which record an erasure request touches, rather than facing an undifferentiated pile of data when the request arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Controller and processor
&lt;/h2&gt;

&lt;p&gt;Working out who is the controller and who, if anyone, is a processor for a given piece of stored VAT data is a question tied to your own setup – your contracts, your data flows, who determines the purpose and means of processing. It’s not something this post resolves in the abstract, and it’s not something to assume applies uniformly to every vendor in your stack.&lt;/p&gt;

&lt;p&gt;Cross-border transfers are a related but separate question, out of scope here.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means in practice
&lt;/h2&gt;

&lt;p&gt;A short, informational checklist – not a compliance procedure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Know which fields in your check response can carry personal data for a sole trader (&lt;code&gt;companyName&lt;/code&gt;, &lt;code&gt;companyAddress&lt;/code&gt;, the VAT number itself) versus fields that only ever describe an incorporated entity.&lt;/li&gt;
&lt;li&gt;Restrict who sees a record flagged &lt;code&gt;registryPrivacy&lt;/code&gt; in your own systems, even though the API returns it in full.&lt;/li&gt;
&lt;li&gt;Store only the fields your invoice and audit process actually uses; don’t persist the rest by default.&lt;/li&gt;
&lt;li&gt;Align your check-record retention with your invoice retention obligation for your jurisdiction, and revisit it if either the tax rule or your legal read of the GDPR ceiling changes.&lt;/li&gt;
&lt;li&gt;Treat an erasure request against an evidence row as a case to route to counsel, not one to resolve unilaterally either way.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is a substitute for advice specific to your data, your jurisdiction, and your contracts. This is general information about GDPR and VAT data, not legal advice – how it applies to your storage and retention is a question for qualified counsel.&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  What vatnode returns, structurally
&lt;/h3&gt;

&lt;p&gt;A validation call returns structured fields – &lt;code&gt;companyName&lt;/code&gt;, &lt;code&gt;companyAddress&lt;/code&gt;, &lt;code&gt;source&lt;/code&gt;, &lt;code&gt;verifiedAt&lt;/code&gt;, and a &lt;code&gt;consultationNumber&lt;/code&gt; on requester-qualified VIES checks – backed by &lt;a href="https://vatnode.dev/vat-api" rel="noopener noreferrer"&gt;VIES with national fallback&lt;/a&gt; where a member state supports it. Every call is logged, which is what makes an &lt;a href="https://vatnode.dev/guides/vat-audit-trail" rel="noopener noreferrer"&gt;audit trail&lt;/a&gt; possible in the first place. Free plan, 100 requests/month.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://vatnode.dev/register" rel="noopener noreferrer"&gt;Get a free API key&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is a VAT number personal data under GDPR?
&lt;/h3&gt;

&lt;p&gt;It depends on who it identifies. A VAT number tied to an incorporated company is generally not personal data, but where the number identifies an individual – a sole trader or freelancer whose VAT ID is linked to their own name – it may constitute personal data under GDPR Article 4(1). Treat the classification as conditional, not a blanket yes or no. This is general information, not legal advice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the company name and address a VIES check returns personal data?
&lt;/h3&gt;

&lt;p&gt;The same distinction applies. A registered company’s name and address are typically not personal data, but a sole trader’s returned name and address can be their own, in which case they may qualify. A safe default is to treat those fields as potentially personal until you know the counterparty is an incorporated entity. This is general information, not legal advice.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long should we keep VAT check records?
&lt;/h3&gt;

&lt;p&gt;A common approach is to align retention with your invoice record-keeping obligation, which each EU member state sets itself rather than a single EU-wide figure – commonly several years, jurisdiction-dependent. GDPR’s storage-limitation principle points the other way, so the period is one to confirm with qualified counsel for your jurisdiction, not a number to copy from a blog post. This is general information, not legal advice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do a checkId, verifiedAt timestamp, and consultation number add GDPR exposure?
&lt;/h3&gt;

&lt;p&gt;On their own these are usually low-risk metadata rather than personal data, but the same minimization principle applies – keep only what your audit trail actually needs, and store it next to the transaction it supports. This is general information, not legal advice.&lt;/p&gt;

</description>
      <category>tax</category>
      <category>tutorial</category>
      <category>europe</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
