<?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>The Typography Details Your Visitors Notice Without Knowing Why</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 21 Sep 2026 10:00:55 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/the-typography-details-your-visitors-notice-without-knowing-why-2h19</link>
      <guid>https://dev.to/iurii_rogulia/the-typography-details-your-visitors-notice-without-knowing-why-2h19</guid>
      <description>&lt;p&gt;Nobody has ever emailed me to complain about a straight quote where a curly one belongs. Nobody has ever pointed out that a page used an em dash in one paragraph and a hyphen doing the job of a dash three paragraphs later. I doubt more than a handful of visitors to any site could tell you, if you asked directly, what the correct dash even is.&lt;/p&gt;

&lt;p&gt;And yet they notice. Not consciously – nobody reads a landing page thinking ‘hm, inconsistent punctuation.’ But somewhere below that, a site that gets these details right reads as made with care. A site that doesn’t reads as assembled, quickly, by whoever was cheapest or fastest. Same words, same layout, same offer – different trust, for a reason the reader couldn’t name if you asked them to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ‘Right’ Actually Looks Like
&lt;/h2&gt;

&lt;p&gt;Here’s the same sentence, once with the typography most software leaves you with by default, and once with it corrected for a British-English reader – curly quotes, proper dashes, a real ellipsis character instead of three dots, spacing that respects the punctuation around it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;She asked, "Isn't this the shop they call 'round the corner'?" ... We'd
walked - nearly 3 km - just to find it closed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;She asked, ‘Isn’t this the shop they call “round the corner”?’ … We’d
walked – nearly 3 km – just to find it closed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the first version. It’s fine. Perfectly legible. Nothing about it screams ‘wrong’. Now read the second one right after it. It just sits better – the quotes nest properly instead of fighting each other, the dash reads as a pause instead of a stray minus sign, the ellipsis is one calm character instead of three periods bumping into the next word.&lt;/p&gt;

&lt;p&gt;That difference is the whole article. It’s not dramatic. That’s the point: it’s not supposed to be dramatic. It’s supposed to be invisible when it’s right and only faintly, unaccountably ‘off’ when it’s wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Even a Careful Site Gets This Wrong
&lt;/h2&gt;

&lt;p&gt;I didn’t write that before/after pair by hand – it’s real output from a locale-correct typography engine I built and shipped this year, &lt;a href="https://iurii.rogulia.fi/projects/polytypo" rel="noopener noreferrer"&gt;polytypo&lt;/a&gt;, which you can run on your own text at &lt;a href="https://polytypo.dev/" rel="noopener noreferrer"&gt;polytypo.dev&lt;/a&gt;. This week I ran it across every public page of this site, built up over more than a year, one article and one service page at a time.&lt;/p&gt;

&lt;p&gt;It caught real inconsistencies I hadn’t noticed while writing any single page: an em dash on one article where the rest of the site used a spaced en dash, straight apostrophes sitting two paragraphs from curly ones, the odd stray hyphen standing in for a proper dash. None of it was wrong on any one page in isolation. It only became visible once something checked every page against the same rule at once. I’d been maintaining this site carefully for over a year and it still drifted – which is exactly the point: this isn’t a mistake careless people make. It’s a category of work nobody notices they’ve skipped until something goes and checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bigger Pattern This Is Standing In For
&lt;/h2&gt;

&lt;p&gt;Typography is a convenient example because you can see it in one sentence. But it’s a stand-in for a whole category of work that shares its shape: correct, invisible when done, and never on anyone’s feature list. A checkout that recomputes the total instead of trusting the number the browser sent. A webhook handler that doesn’t create a second invoice when the retry arrives. VAT applied correctly for the buyer’s actual country instead of whatever the developer tested with. None of these earn a line in a pitch deck. All of them are the difference between software that was finished and software that was shipped once it looked finished.&lt;/p&gt;

&lt;p&gt;I wrote about the bigger, costlier end of that same pattern in &lt;a href="https://iurii.rogulia.fi/blog/what-an-mvp-actually-costs" rel="noopener noreferrer"&gt;what an MVP actually costs&lt;/a&gt; – the ‘unglamorous 30%’ that a cheap quote quietly cuts because it doesn’t show up in a five-minute demo. Typography is the smallest, cheapest, most harmless-looking member of that family. Which makes it a useful test: if a team cut corners here, on something this visible and this low-stakes, what are the odds they cut corners on the parts you can’t see at all?&lt;/p&gt;

&lt;p&gt;
  slug="mvp-development"&lt;br&gt;
  text="Correctness that doesn’t show up when you’re clicking through a preview – typography, checkout maths, retry safety – is part of how I build a product from the first commit, not a pass added at the end."&lt;br&gt;
/&amp;gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Who This Actually Matters To
&lt;/h2&gt;

&lt;p&gt;This isn’t a pitch to sell you typography fixes. It’s genuinely not worth commissioning on its own – whoever builds your site can run this kind of pass for free in minutes, and I’d tell you that even if I sold typography audits, which I don’t.&lt;/p&gt;

&lt;p&gt;It matters if you’re trying to read a signal you can’t put your finger on: why one option in a shortlist just feels more trustworthy than the others, on paper equally qualified. It’s rarely one dramatic thing. It’s usually a dozen small things like this one, each individually shrug-worthy, adding up to a read on how much the people building it actually cared about the parts nobody was going to grade them on.&lt;/p&gt;

&lt;p&gt;It doesn’t matter if you’re optimising for speed to a rough first version and you know that’s what you’re doing – ship the plain quotes, fix it later, that’s a legitimate call for a prototype nobody’s judging yet. The problem is only when ‘later’ is the plan for the parts a customer’s trust actually rests on, and nobody ever says so out loud.&lt;/p&gt;




&lt;p&gt;If you’re trying to tell a genuinely careful build from one that just looks finished on the surface, that’s worth a real conversation before you sign anything. &lt;a href="https://iurii.rogulia.fi/contact" rel="noopener noreferrer"&gt;Let’s talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>business</category>
      <category>webdev</category>
      <category>codequality</category>
      <category>trust</category>
    </item>
    <item>
      <title>How to Spot a Fake Pay Stub: A Reviewer’s Guide</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 21 Sep 2026 10:00:44 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/how-to-spot-a-fake-pay-stub-a-reviewers-guide-3698</link>
      <guid>https://dev.to/iurii_rogulia/how-to-spot-a-fake-pay-stub-a-reviewers-guide-3698</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/how-to-spot-fake-pay-stubs-guide" 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;If you review pay stubs — screening tenants, underwriting a loan, checking a job applicant’s stated salary — you already have habits for spotting a bad one: you check the math, you look at the fonts, you squint at the alignment. Most of the time those habits work: a rushed forgery has a tell, and after a few hundred stubs the fake ones start to look wrong before you can say exactly why.&lt;/p&gt;

&lt;p&gt;This guide collects that visual checklist in one place — the concrete things a reviewer can look for without any special tooling. It also says where that checklist stops working. A well-executed edit to an otherwise genuine pay stub can pass most of this list. That’s not a reason to skip the checklist — most fakes are still sloppy, and catching the sloppy ones by eye saves everyone time. It’s a reason to know what the checklist can’t see, and what does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Visual Checklist
&lt;/h2&gt;

&lt;p&gt;Work through these in order. Each one takes seconds, and together they catch a meaningful share of low-effort forgeries.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Does the math actually add up?
&lt;/h3&gt;

&lt;p&gt;Gross pay minus deductions should equal net pay, every time, on every line. Add up the year-to-date figures against the stub’s own pay-period math — if the stub shows a bi-weekly pay date, does the YTD gross look like a plausible multiple of the per-period gross for that point in the year? Fabricated stubs are often built by someone typing numbers into a template rather than running them through a real payroll calculation, and the arithmetic doesn’t always reconcile once you check it against itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Do the deductions make sense for the numbers shown?
&lt;/h3&gt;

&lt;p&gt;Tax withholding, and other statutory deductions where applicable, should sit in a plausible range for the stated gross and the jurisdiction. A stub with a suspiciously round federal withholding, or a state tax line that doesn’t match the address on the document, is worth a second look. So is a stub with no deductions at all beyond taxes — real payroll runs almost always carry at least one benefit or retirement deduction line for a salaried employee.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Fonts and alignment
&lt;/h3&gt;

&lt;p&gt;Open the stub at full zoom and look at every number field individually. A genuine payroll export is typographically uniform — a field that was retyped often sits slightly apart from its neighbors: a pixel or two off the shared baseline, a very slightly different font weight, or different character spacing. This is the single most useful thing a careful human eye can catch on a low-effort forgery.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Employer details that don’t line up
&lt;/h3&gt;

&lt;p&gt;Cross-check the employer name, address, and any logo against what you can find publicly — a company website, a business registry, a LinkedIn page. A pay stub claiming to be from a company whose registered address doesn’t match, or whose logo is a stretched or low-resolution copy of the real one, is a red flag independent of anything on the numbers side.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Formatting that doesn’t match the claimed provider
&lt;/h3&gt;

&lt;p&gt;Most employees are paid through recognizable payroll platforms — ADP, Paychex, Gusto, Workday, or an in-house system — each with a distinctive layout, header style, and footer disclaimer text. If you review pay stubs regularly, you’ll start to recognize these templates. A stub that claims to come from a well-known payroll provider but doesn’t match that provider’s actual layout is worth flagging. Generic “pay stub template” formatting — the kind sold on template marketplaces — has a look of its own once you’ve seen a few.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Inconsistent or implausible dates
&lt;/h3&gt;

&lt;p&gt;Check that the pay period, pay date, and year-to-date figures are internally consistent with each other and with the calendar. A pay date that falls on a weekend for a company that pays biweekly on Fridays, or a YTD figure that doesn’t grow correctly across consecutive stubs from the same person, is a signal worth pursuing.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. File metadata, if you can see it
&lt;/h3&gt;

&lt;p&gt;Some PDF viewers expose basic document properties — right-click and look for “Document Properties” or similar. A stub whose stated creator or producer tool contradicts the claimed payroll provider (for example, a document that claims to be issued by a major payroll platform but whose metadata says it was produced by a generic PDF editor) is worth a second look. This is a coarse signal — plenty of legitimate documents pass through a compression or conversion step for unrelated reasons — but it’s a free check if your viewer shows it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Checklist Runs Out
&lt;/h2&gt;

&lt;p&gt;Every item above is a &lt;strong&gt;content check&lt;/strong&gt; — it asks whether what’s printed on the page looks right. A well-executed edit to an otherwise genuine pay stub can pass most of these checks.&lt;/p&gt;

&lt;p&gt;When a forger works from a genuine document instead of a blank template, the visible layer is inherited rather than recreated — the typography, the employer block, the provider styling, and the surrounding dates were never the part that changed. Nothing on the page has to be rebuilt convincingly, so there’s little for the eye to compare against. A visual review is scoring the parts of the document a careful edit tends to leave alone.&lt;/p&gt;

&lt;p&gt;This is the gap between “does this look right” and “was this file changed after it was issued.” Those are two different questions, and a visual review — however careful — is mostly answering the first one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Layer That Answers the Other Question
&lt;/h2&gt;

&lt;p&gt;A PDF isn’t just the page you see. It’s a structured file that records how it was built and what happened to it after it was produced. Opening a genuine pay stub and saving an edit usually leaves traces in that internal structure, even when the visible numbers look convincing afterward.&lt;/p&gt;

&lt;p&gt;HTPBE is a self-serve API (and a free web check, for a one-off) that reads that structure and returns one of three verdicts, plus a list of named findings describing what it found:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;intact&lt;/code&gt;&lt;/strong&gt; — no structural evidence the file was changed after it was created, and nothing in its origin blocks that reading. This isn’t proof the numbers on it are true — it’s the absence of structural evidence that the file was altered.&lt;/li&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 a visual check can’t see: the visible page can look untouched while the file itself records that it was not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;inconclusive&lt;/code&gt;&lt;/strong&gt; — the file’s origin doesn’t give us an institutional baseline to check integrity against, which happens with consumer software, online editors, and scanners among others. This isn’t a pass and isn’t an automatic reject — it’s a routing signal. A stub claiming to be from a major payroll platform that comes back &lt;code&gt;inconclusive&lt;/code&gt; is worth escalating with an employer confirmation regardless of the reason; a smaller employer running their own payroll often produces this result as a matter of course.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It never asks whether the salary figure is believable — that’s the content-review job the checklist above already does well. It asks whether the file’s own construction shows signs of change after issuance. That makes it additive: it sits alongside employer callbacks, income cross-checks, and the visual review above, covering the one case none of those were built to see — a well-executed edit to an otherwise genuine document.&lt;/p&gt;

&lt;p&gt;
  You can try this directly: drop a pay stub into the free check on this site and get a verdict in a
  few seconds, no account needed — an &lt;code&gt;intact&lt;/code&gt; or &lt;code&gt;inconclusive&lt;/code&gt; result
  comes back as a full report; a &lt;code&gt;modified&lt;/code&gt; result unlocks its detected-signal detail
  once you register. It’s the same engine behind the API, and it’s a fast way to check a
  single document before deciding whether to escalate it.
&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Doesn’t Solve Either
&lt;/h2&gt;

&lt;p&gt;Two honest limits are worth naming. First, no structural check speaks to whether the numbers on the page are true. Structural analysis answers “was this file changed after it was issued,” not “is this claim real” — a document can be structurally unremarkable and still describe employment or income that never existed. That’s exactly why the visual checklist above stays in the process: content review and structural review answer different questions, and each is blind where the other isn’t.&lt;/p&gt;

&lt;p&gt;Second, structural analysis can’t confirm employment or income independently — it can’t tell you the person actually works there or actually earns that amount. It tells you whether the file in front of you was changed after it left whatever system produced it. Employer verification, income cross-checks against bank records, and the visual review above all still matter. The point of adding a structural check isn’t to replace any of that — it’s to close the one gap none of it covers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building This Into a Review Process
&lt;/h2&gt;

&lt;p&gt;For teams that want to add this as a routine step rather than a one-off check, the integration is small. Send the stub’s file location to the analyze endpoint:&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-3391.pdf"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fetch the result by the returned ID:&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.htpbe.tech/v1/result/&amp;lt;check-id&amp;gt; &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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Route on the verdict: &lt;code&gt;modified&lt;/code&gt; goes to manual review and a request for a fresh copy pulled directly from the source system; &lt;code&gt;inconclusive&lt;/code&gt; branches on the claimed provider, exactly as described above; &lt;code&gt;intact&lt;/code&gt; proceeds to your normal content review — the checklist at the top of this guide.&lt;/p&gt;

&lt;p&gt;Full documentation, including test keys you can try against sample documents before spending a credit, is at &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;the API reference&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Reading
&lt;/h2&gt;

&lt;p&gt;This checklist is deliberately general — for a US payroll platform still on file, see the &lt;a href="https://htpbe.tech/use-cases/fake-pay-stub-detection" rel="noopener noreferrer"&gt;dedicated fake pay stub use case&lt;/a&gt;; for mortgage underwriting specifically, see &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 hiring and background-verification workflows, see &lt;a href="https://htpbe.tech/blog/payslip-fraud-hr-detection" rel="noopener noreferrer"&gt;payslip fraud in HR&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;Run the visual checklist first — it’s fast, and it catches most low-effort fakes. Check the math, check the fonts and alignment, check the employer details, check the provider formatting, check the dates. When a stub passes all of that and something still feels off, or when the document matters enough that “looks right” isn’t a good enough bar, add a structural check on top. The two layers catch different things, and neither one replaces the other.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>fintech</category>
      <category>webdev</category>
    </item>
    <item>
      <title>VAT Vendor Verification for Marketplaces</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 21 Sep 2026 09:00:46 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/vat-vendor-verification-for-marketplaces-4k2a</link>
      <guid>https://dev.to/iurii_rogulia/vat-vendor-verification-for-marketplaces-4k2a</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/vat-validation-marketplaces" 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;h1&gt;
  
  
  VAT Validation for Multi-Vendor Marketplaces: Who Checks Whom
&lt;/h1&gt;

&lt;p&gt;A marketplace has at least three parties in every transaction – the platform, the vendor, and the buyer – and VAT validation applies differently to each. The short answer to ‘who checks whom’: the platform validates the vendor’s VAT number before onboarding them as a business seller, and separately validates the buyer’s VAT number when a transaction’s tax treatment depends on the buyer being VAT-registered (typically B2B). Same lookup, same underlying source, two different reasons to run it.&lt;/p&gt;

&lt;p&gt;The rest covers both sides, where deemed-supplier rules fit without turning this into a tax memo, and how to run the checks at marketplace scale rather than one vendor at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validating Vendors at Onboarding
&lt;/h2&gt;

&lt;p&gt;Before you list a business as a seller, you want to know their VAT number is real. That means running it through &lt;a href="https://vatnode.dev/vat-api" rel="noopener noreferrer"&gt;VIES&lt;/a&gt; – the EU’s VAT number lookup – and checking two separate things that people conflate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Format validity&lt;/strong&gt; – does the string match the country’s checksum pattern? This catches typos instantly and needs no network call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VIES registration status&lt;/strong&gt; – is that number actually registered in the issuing member state’s system right now? A number can pass format validation and still not be registered – format is a syntax check, not proof of registration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A VIES check is evidence a VAT number was registered at the time of the query – not a certification of the vendor’s business or a guarantee it stays valid. It doesn’t tell you the vendor is solvent, trustworthy, or who they claim to be. It tells you a specific VAT number was on the books in a specific country’s system at a specific moment. Treat it as one input to onboarding, not the whole decision.&lt;/p&gt;

&lt;p&gt;Company name and address detail in the response varies by country – some national databases return a full trader name and address alongside the valid/invalid flag, others return only the flag. Don’t build a vendor-matching workflow that assumes every country hands back a name to compare against your KYB records; for &lt;a href="https://vatnode.dev/blog/vat-number-company-data-enrichment" rel="noopener noreferrer"&gt;enriching a VAT number with company data&lt;/a&gt; beyond what VIES itself returns, you may need a country-specific source on top.&lt;/p&gt;

&lt;p&gt;Two prefixes worth getting right before they cost you a support ticket: &lt;a href="https://vatnode.dev/check/gr" rel="noopener noreferrer"&gt;Greece&lt;/a&gt; uses &lt;strong&gt;EL&lt;/strong&gt;, not GR, and Northern Ireland uses &lt;strong&gt;XI&lt;/strong&gt; – goods only, since VIES doesn’t cover Northern Ireland services.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validating Business Buyers
&lt;/h2&gt;

&lt;p&gt;The other side of the same endpoint: when a buyer claims to be a VAT-registered business and the transaction’s treatment depends on that, you check their number too. What that treatment actually is depends on what’s being sold.&lt;/p&gt;

&lt;p&gt;For cross-border B2B &lt;strong&gt;services&lt;/strong&gt;, the customer typically self-accounts for VAT under reverse charge (Article 44 place-of-supply, Article 196) when the seller isn’t established in the buyer’s country. For cross-border B2B &lt;strong&gt;goods&lt;/strong&gt;, the mechanism is legally different even though people also call it ‘reverse charge’ colloquially – it’s an exempt intra-Community supply on the seller’s side (Article 138) paired with the buyer’s intra-Community acquisition (Articles 2(1)(b) and 20). The two aren’t interchangeable, and a marketplace selling both goods and services shouldn’t blur them into one workflow.&lt;/p&gt;

&lt;p&gt;Either way, the buyer’s VAT number being valid is necessary but not sufficient. The transaction facts – what’s sold, to whom, under what terms – have to actually support the treatment. Validation alone never proves eligibility for reverse charge or zero-rating; it’s one condition among several. For a fuller breakdown of when a check is actually load-bearing for a transaction, see &lt;a href="https://vatnode.dev/blog/when-to-validate-customer-vat-numbers" rel="noopener noreferrer"&gt;when to validate a VAT number&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deemed Supplier: Where the Platform Itself Gets Pulled In
&lt;/h2&gt;

&lt;p&gt;There’s a separate wrinkle specific to marketplaces, worth naming even though it’s outside VAT number validation itself. In some fact patterns, EU VAT law shifts VAT liability onto the platform – the ‘deemed supplier’ rules under &lt;a href="https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:02011R0282-20220701" rel="noopener noreferrer"&gt;Article 14a of the VAT Directive&lt;/a&gt;. They apply to specific scenarios: certain imported low-value goods, and goods sold within the EU by a non-EU-established seller through an electronic interface. It’s goods, not services, and the scope isn’t static – the rules have evolved and may again.&lt;/p&gt;

&lt;p&gt;This is not tax advice – whether deemed-supplier rules apply to your marketplace depends on your specific goods, seller locations, and transaction flow. Talk to a tax adviser before relying on this for compliance decisions. Vendor VAT validation and deemed-supplier liability are two different questions; solving the first doesn’t answer the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audit Evidence, at Marketplace Scale
&lt;/h2&gt;

&lt;p&gt;A marketplace doesn’t run one VAT check – it runs one per vendor onboarded and, depending on volume, one per qualifying transaction. When an auditor asks you to justify why a vendor was treated as a valid business seller, or why reverse charge was applied to a specific buyer, ‘we looked at VIES once’ isn’t a record – a stored, timestamped result is.&lt;/p&gt;

&lt;p&gt;VIES can return a &lt;a href="https://vatnode.dev/guides/vat-audit-trail" rel="noopener noreferrer"&gt;VIES consultation number&lt;/a&gt; on a requester-qualified lookup: a timestamped reference showing a specific check happened at a specific moment. Some tax authorities (French and German practice, notably) look for it on audit. It is not a tax certification and it doesn’t adjudicate any specific transaction – it’s contemporaneous proof the lookup occurred, nothing more. It’s also not unique to any one provider; several VAT APIs return it. What actually differs between providers is how much friction it takes to get one on every check.&lt;/p&gt;

&lt;p&gt;On vatnode, that friction is one-time: set your requester VAT once in dashboard Account details, and every vendor or buyer check – single or bulk – automatically gets a consultation number when VIES issues one, with no per-call parameter to wire into your integration. A requester-authenticated call uses VIES directly rather than falling back to a national source, because the consultation number is VIES-specific evidence; a national registry can’t produce it.&lt;/p&gt;

&lt;p&gt;Every response also carries a &lt;code&gt;source&lt;/code&gt; field – &lt;code&gt;VIES&lt;/code&gt;, &lt;code&gt;CACHE&lt;/code&gt;, or a national code – so you always know which system actually answered, and a &lt;code&gt;consultationNumber&lt;/code&gt; field that’s always present as a key even when its value is &lt;code&gt;null&lt;/code&gt; because VIES didn’t issue one for that check. That’s the accurate version of ‘never-null’: the field is always there for your code to read, not that every check magically succeeds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ongoing Monitoring Across Many Vendors
&lt;/h2&gt;

&lt;p&gt;A VAT number valid at onboarding can lapse or deregister at any point after. That’s true for a single supplier relationship, but a marketplace with hundreds of vendors has hundreds of independent places where it can happen without anyone noticing – no vendor is going to volunteer that their VAT registration was cancelled.&lt;/p&gt;

&lt;p&gt;There’s no legally mandated re-check interval; this is operational risk management, not a compliance clock. The practical pattern is to validate at onboarding and then monitor vendor VAT IDs on a schedule that matches your exposure, rather than treating the onboarding check as permanent proof. &lt;a href="https://vatnode.dev/guides/vat-monitoring-webhooks" rel="noopener noreferrer"&gt;VAT monitoring&lt;/a&gt; that pushes a webhook when a previously-valid number goes invalid means you find out when it happens instead of during your next audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing This in Code
&lt;/h2&gt;

&lt;p&gt;The endpoint is the same for a vendor onboarding check and a buyer check:&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/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;p&gt;The response includes &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 &lt;code&gt;consultationNumber&lt;/code&gt;. If the call fails upstream, you get a typed error instead – &lt;code&gt;VIES_UNAVAILABLE&lt;/code&gt; (503), &lt;code&gt;VIES_ERROR&lt;/code&gt; (502), &lt;code&gt;UPSTREAM_TIMEOUT&lt;/code&gt; (504), &lt;code&gt;RATE_LIMITED&lt;/code&gt; (429), or &lt;code&gt;INVALID_FORMAT&lt;/code&gt; (400) if the string never had a shot at being a real VAT number. A marketplace onboarding flow should treat those as ‘try again’ states, not silently reject a vendor because VIES had a bad moment.&lt;/p&gt;

&lt;p&gt;A vendor roster or a buyer list at any real marketplace volume is a &lt;a href="https://vatnode.dev/docs/bulk-vat-validation" rel="noopener noreferrer"&gt;bulk VAT validation&lt;/a&gt; workload, not a loop of single calls – submit the whole roster as one job and poll it. The job paces its own concurrency against VIES, so you’re not the one deciding how hard to hit the endpoint. Vendors and buyers also span a lot of countries fast for any marketplace with real reach; check &lt;a href="https://vatnode.dev/docs/coverage" rel="noopener noreferrer"&gt;country-by-country coverage&lt;/a&gt; for where national fallback applies. For a subset of member states, if VIES itself is temporarily down, vatnode falls back to that country’s own tax authority or company-registry API so you still get an answer – except on a requester-authenticated call, where fallback is disabled because the consultation number is VIES-only evidence.&lt;/p&gt;

&lt;p&gt;If your marketplace is itself a SaaS platform selling to EU business buyers, &lt;a href="https://vatnode.dev/guides/eu-vat-for-b2b-saas" rel="noopener noreferrer"&gt;EU VAT for B2B SaaS&lt;/a&gt; covers your own reverse-charge obligations separately from what you check on your vendors. And if you’re weighing whether to query VIES directly instead of through an API, &lt;a href="https://vatnode.dev/guides/vies-api-alternative" rel="noopener noreferrer"&gt;a production VIES alternative&lt;/a&gt; covers what ‘just call VIES’ leaves out once you’re doing it for hundreds of vendors instead of one.&lt;/p&gt;

&lt;p&gt;Coverage spans the EU-27 plus XI for goods, including &lt;a href="https://vatnode.dev/vat-api/de" rel="noopener noreferrer"&gt;German VAT numbers&lt;/a&gt;, &lt;a href="https://vatnode.dev/vat-api/fr" rel="noopener noreferrer"&gt;French VAT numbers&lt;/a&gt;, and &lt;a href="https://vatnode.dev/vat-api/nl" rel="noopener noreferrer"&gt;Dutch VAT numbers&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This is general information about EU VAT and VIES, not tax advice. Whether reverse charge, deemed-supplier rules, or any specific tax treatment applies to your marketplace depends on facts we can’t assess here – confirm your own obligations with a qualified tax adviser.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does a marketplace have to validate seller VAT numbers?
&lt;/h3&gt;

&lt;p&gt;VIES itself doesn’t mandate anyone check anything – it’s a lookup service, not a legal requirement. But validating a business vendor’s VAT number is the standard due-diligence step before onboarding them or applying reverse charge to their transactions, and it’s what an auditor will expect to see. This isn’t legal advice – confirm your own obligations with a tax adviser.&lt;/p&gt;

&lt;h3&gt;
  
  
  How often should a platform re-check vendor VAT IDs?
&lt;/h3&gt;

&lt;p&gt;At onboarding, at minimum. A number valid at signup can lapse or be deregistered later, and a marketplace with hundreds of vendors has hundreds of independent points where that can happen unnoticed. There’s no legally fixed re-check interval – treat periodic re-validation as operational risk management, not a compliance deadline, and consider monitoring vendor VAT IDs on an ongoing basis rather than only at onboarding.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does vatnode validate both vendors and buyers on a marketplace?
&lt;/h3&gt;

&lt;p&gt;Yes – the same GET /v1/vat/:vatId endpoint checks any EU VAT number, vendor or buyer. vatnode doesn’t distinguish between roles; the marketplace decides which party’s number it’s checking for a given transaction and calls the endpoint accordingly.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about deemed-supplier VAT rules for marketplaces?
&lt;/h3&gt;

&lt;p&gt;In some fact patterns, EU VAT law shifts VAT liability onto the platform itself – the ‘deemed supplier’ rules under Article 14a of the VAT Directive. Whether they apply depends on your specific goods, seller locations, and transaction flow, and the rules only cover certain scenarios (not every marketplace transaction). This isn’t tax advice; talk to a tax adviser before relying on this for compliance decisions.&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  Validate vendors and buyers from one API
&lt;/h3&gt;

&lt;p&gt;Check a vendor or buyer VAT number free right now with the &lt;a href="https://vatnode.dev/check" rel="noopener noreferrer"&gt;VAT number checker&lt;/a&gt; – no account needed. Running it across your vendor roster? &lt;a href="https://vatnode.dev/register" rel="noopener noreferrer"&gt;Get a free API key&lt;/a&gt; and validate through the &lt;a href="https://vatnode.dev/vat-api" rel="noopener noreferrer"&gt;EU VAT validation API&lt;/a&gt; – the free plan (100 requests/month, no card) is enough to wire up the integration and test it against real vendors. A live marketplace roster runs past that fast, so &lt;a href="https://vatnode.dev/pricing" rel="noopener noreferrer"&gt;see pricing&lt;/a&gt; for where that volume actually lands.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>tax</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Caching VAT and FX Rates at Build Time, Not on the Request Path</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Fri, 18 Sep 2026 10:00:58 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/caching-vat-and-fx-rates-at-build-time-not-on-the-request-path-4jpa</link>
      <guid>https://dev.to/iurii_rogulia/caching-vat-and-fx-rates-at-build-time-not-on-the-request-path-4jpa</guid>
      <description>&lt;p&gt;Two ways to get exchange rates or VAT tables into an application, and both are wrong in a familiar way. Call a rates API on every request or every checkout, and you’ve added latency plus a new third-party dependency to a path where a customer is trying to pay you money. Or hardcode a &lt;code&gt;{ GBP: 0.86, CHF: 0.94 }&lt;/code&gt; object in source once, and it drifts from reality the moment the ECB updates its reference rates – silently, because nothing breaks when a number is merely wrong.&lt;/p&gt;

&lt;p&gt;A third option avoids both failure modes. It’s the one I used on a B2B e-commerce project I built – a Next.js storefront selling across 32 European markets (the &lt;a href="https://iurii.rogulia.fi/projects/pi-pi-b2b-ecommerce" rel="noopener noreferrer"&gt;project card&lt;/a&gt; covers the broader VAT and payments architecture). Fetch the rates once, at build time, cache them to a committed JSON file, and let the running application read that file instead of the network. Here’s the script and the reasoning behind each of its decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Prebuild Script
&lt;/h2&gt;

&lt;p&gt;The relevant file is &lt;code&gt;scripts/fetch-fx-rates.js&lt;/code&gt;, a &lt;code&gt;prebuild&lt;/code&gt; step that runs before &lt;code&gt;next build&lt;/code&gt; and nowhere else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/fetch-fx-rates.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fs&lt;/span&gt;&lt;span class="dl"&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;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;path&lt;/span&gt;&lt;span class="dl"&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;OUTPUT_FILE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;__dirname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../src/data/fx-rates.json&lt;/span&gt;&lt;span class="dl"&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;TARGET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GBP&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="s2"&gt;CHF&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="s2"&gt;NOK&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="s2"&gt;PLN&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="s2"&gt;CZK&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="s2"&gt;DKK&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="s2"&gt;SEK&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="s2"&gt;RON&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="s2"&gt;HUF&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="s2"&gt;TRY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchRates&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://api.frankfurter.app/latest?from=EUR&amp;amp;to=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TARGET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&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="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="nx"&gt;url&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="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;`HTTP &lt;/span&gt;&lt;span class="p"&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;status&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="k"&gt;for &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;cur&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;TARGET&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;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cur&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&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;`missing rate for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cur&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;rates&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&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;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;generatedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EUR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ECB via frankfurter.app&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Approximate reference only. Checkout charges in EUR.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;OUTPUT_FILE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;output&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="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="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&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;fetchRates&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;❌ FX fetch failed:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;   Keeping the committed fx-rates.json as-is.&lt;/span&gt;&lt;span class="dl"&gt;"&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="nf"&gt;exit&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="c1"&gt;// Don't fail the build&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://frankfurter.app" rel="noopener noreferrer"&gt;frankfurter.app&lt;/a&gt; mirrors the European Central Bank’s daily reference rates, needs no API key, and returns exactly the ten non-euro currencies this storefront ships to. The script fetches EUR-to-X for all ten in one request and writes the result to &lt;code&gt;src/data/fx-rates.json&lt;/code&gt;, which is committed to the repo like any other source file.&lt;/p&gt;

&lt;p&gt;That JSON feeds one thing: a small, clearly labeled UI hint next to a EUR price for a non-euro shopper – ‘≈ £25’ next to ‘€29’. It is never the transactional amount. The checkout itself still charges in EUR regardless of which country the buyer is in; the FX number is a courtesy conversion so a UK buyer has some sense of what €29 means without doing the math themselves. That sets how fresh this data actually needs to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  ‘Never Fails the Build’ Is the Load-Bearing Line
&lt;/h2&gt;

&lt;p&gt;Read the &lt;code&gt;.catch()&lt;/code&gt; block again. On any failure – frankfurter.app is down, rate-limited, returns a malformed payload, or the network blips in CI – the script logs the error, leaves the already-committed &lt;code&gt;fx-rates.json&lt;/code&gt; untouched, and calls &lt;code&gt;process.exit(0)&lt;/code&gt;. Exit code zero. As far as the shell and the CI pipeline are concerned, the prebuild step succeeded.&lt;/p&gt;

&lt;p&gt;The alternative is to &lt;code&gt;throw&lt;/code&gt; and let a failed fetch fail the whole build. That sounds more correct – if the data is stale, don’t ship – until you look at what actually triggers this build. Every deploy does: a copy fix, a new blog post if content lives in the same repo, an unrelated bug fix in checkout logic that has nothing to do with currency conversion. Tie all of that to the uptime of a third-party FX API, and a transient 503 from frankfurter.app on a Tuesday afternoon blocks a hotfix that has nothing to do with FX rates.&lt;/p&gt;

&lt;p&gt;Shipping a build with FX numbers a day, or a week, old is the cheaper failure. The fallback here isn’t ‘no data,’ it’s yesterday’s real data instead of today’s real data. The committed JSON was itself fetched from the same source at some previous build, so it degrades to slightly stale rather than to zero. A hint that’s approximate by design tolerates that easily. A deploy pipeline tolerates a new external dependency on its critical path much worse.&lt;/p&gt;

&lt;p&gt;Same shape of decision as retrying a webhook call versus failing it outright. The useful question is what failure actually costs here, and whether blocking the whole pipeline is proportionate to that cost. For a display-only currency hint, it isn’t.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dev Mode Never Touches the Network for This
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;prebuild&lt;/code&gt; hook only runs on &lt;code&gt;npm run build&lt;/code&gt;. &lt;code&gt;npm run dev&lt;/code&gt; never invokes &lt;code&gt;fetch-fx-rates.js&lt;/code&gt; at all – it just reads whatever &lt;code&gt;fx-rates.json&lt;/code&gt; is already sitting in the repo, committed with sane values. The omission is deliberate. A local dev session doesn’t need today’s GBP rate to be accurate to the fourth decimal place to build a checkout page. It needs a plausible number so the UI renders correctly, and it needs that without a network call that can fail, rate-limit, or simply add a few hundred milliseconds to every cold start.&lt;/p&gt;

&lt;p&gt;The stronger reason is one most caching write-ups skip: a build script that hits an external API gives local development a network dependency that has nothing to do with the feature you’re working on. Committing the cache file removes that dependency entirely for the vast majority of work sessions that aren’t specifically about the FX integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  VAT Rates: Same Instinct, Different Failure Direction
&lt;/h2&gt;

&lt;p&gt;The VAT side of the same project makes the identical build-time trade – but pairs it with the opposite default on unknown input. VAT rates come from &lt;code&gt;getAllRates()&lt;/code&gt; in &lt;a href="https://github.com/vatnode/eu-vat-rates-data" rel="noopener noreferrer"&gt;&lt;code&gt;eu-vat-rates-data&lt;/code&gt;&lt;/a&gt;, a package I publish that mirrors the European Commission’s VAT data (the pipeline that keeps it current is its own story, covered in &lt;a href="https://iurii.rogulia.fi/blog/open-source-package-five-registries" rel="noopener noreferrer"&gt;Publishing One Package to Five Registries with GitHub Actions&lt;/a&gt;). No live call to a tax authority on checkout – the rate for every EU member is bundled into the deployed app at build time, versioned through the package’s &lt;code&gt;dataVersion&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;// src/lib/pricing/config.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;packageRates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getAllRates&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VAT_RATES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;VatRateInfo&lt;/span&gt;&lt;span class="o"&gt;&amp;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="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;packageRates&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="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&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;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eu_member&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;standard&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&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="p"&gt;])&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;US&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;United States&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rate&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="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RATES_UPDATED_AT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dataVersion&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getVatRate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;countryCode&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;number&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;rateInfo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;VAT_RATES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;countryCode&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;rateInfo&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;return&lt;/span&gt; &lt;span class="nx"&gt;rateInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rate&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;countryCode&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;ZERO_VAT_TERRITORIES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Fail fast on a fully unknown code rather than silently charging 0% VAT.&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;`getVatRate: unknown country code "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;countryCode&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here the design intentionally does &lt;em&gt;not&lt;/em&gt; fall back the way the FX script does. &lt;code&gt;getVatRate&lt;/code&gt; throws on a country code that isn’t in the bundled table and isn’t in an explicit, named &lt;code&gt;ZERO_VAT_TERRITORIES&lt;/code&gt; exception list (Åland, a served country that’s simply absent from the VAT-rates package’s data). That asymmetry is the point, not an inconsistency: an approximate FX hint failing over to ‘yesterday’s number’ costs nothing but precision. A VAT calculation silently failing over to 0% costs money – either undercharged tax that the seller owes, or, worse, a shipped default that nobody notices until a filing is wrong. Same build-time caching on both sides of this codebase, opposite failure behavior, because the two data points have opposite blast radii when they’re wrong. This checkout flow’s actual VAT and currency math at request time – how the cached numbers get applied and re-verified server-side – is a separate story from the caching mechanism itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a Build-Old Number Is Fine, and Where It Isn’t
&lt;/h2&gt;

&lt;p&gt;The cost is staleness. The rates in &lt;code&gt;fx-rates.json&lt;/code&gt; are as fresh as the last successful &lt;code&gt;npm run build&lt;/code&gt;, not as fresh as the last minute. For this project that’s a non-issue twice over: the FX number is explicitly a display-only hint, never the charged amount, and standard VAT rates change on the order of once a year through a legislated, announced process, not intraday.&lt;/p&gt;

&lt;p&gt;In a different context it stops being fine. A system that actually settles in the converted currency – an FX trading desk, a multi-currency ledger reconciling against real bank movements – cannot tolerate a build-old rate. That’s a request-time, ideally streaming, problem, and treating it as a build artifact would be a genuine bug rather than a trade-off. VAT has its own version of this: a country raises or lowers its standard rate on a known effective date, the last build ran the evening before, and the deploy lands after midnight. Then ‘rebuild before the rate changes’ becomes an actual operational task, and this pattern can’t paper over it. This project doesn’t have that problem, because the number it caches is a hint rather than a settlement and the tax table it caches changes on a calendar that a normal deploy cadence easily outpaces. That’s a property of this specific system, not a property of build-time caching in general.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Alternative Nobody Admits to Shipping
&lt;/h2&gt;

&lt;p&gt;The pattern this replaces is usually not ‘call the API live.’ More often it’s a &lt;code&gt;const rates = { GBP: 0.86, CHF: 0.94, ... }&lt;/code&gt; committed once during initial development and never touched again, because nothing forces anyone to touch it. No build step fails, no test catches it, no dashboard flags it – the numbers are just quietly wrong, more wrong every month, until a customer complains that the displayed conversion looks off, or a VAT filing gets flagged. A committed JSON that a script refreshes on every build looks identical to a hardcoded object at rest – same file, same shape, no runtime cost – but it doesn’t rot, because refreshing it is a side effect of the thing you were going to do anyway: ship.&lt;/p&gt;

&lt;p&gt;The same move – take a slow-changing computation out of the request path and into the build, so the running app never pays for it and nobody has to remember to redo it by hand – shows up elsewhere on this stack, for a completely different kind of data. This site’s own &lt;a href="https://iurii.rogulia.fi/blog/typed-mdx-velite-pipeline" rel="noopener noreferrer"&gt;related-posts graph&lt;/a&gt; is computed by a &lt;code&gt;generate-relations.ts&lt;/code&gt; script that runs as a codegen step in the same &lt;code&gt;next build&lt;/code&gt; chain, before the framework build starts. Different problem – internal content relationships, not external API data – but the same reasoning: if a value doesn’t need to be correct to the second, compute it once when you’re already deploying, not on every page load.&lt;/p&gt;




&lt;p&gt;If you’ve got a rates table, a tax lookup, or any other slow-moving external data sitting in your request path out of habit rather than necessity, &lt;a href="https://iurii.rogulia.fi/contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt; and let’s find out whether it belongs at build time instead. That’s the kind of workflow automation covered on the &lt;a href="https://iurii.rogulia.fi/services/automation-workflows" rel="noopener noreferrer"&gt;Automation &amp;amp; Workflows&lt;/a&gt; page.&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/open-source-package-five-registries" rel="noopener noreferrer"&gt;Publishing One Package to Five Registries with GitHub Actions&lt;/a&gt; – how &lt;code&gt;eu-vat-rates-data&lt;/code&gt;, the package behind the VAT table in this article, stays current with zero manual steps&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://iurii.rogulia.fi/blog/typed-mdx-velite-pipeline" rel="noopener noreferrer"&gt;A Typed MDX Content Pipeline with Velite (Next.js Tutorial)&lt;/a&gt; – this site’s own build-time codegen step, a different kind of ‘compute it once, not per request’&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://iurii.rogulia.fi/projects/pi-pi-b2b-ecommerce" rel="noopener noreferrer"&gt;pi-pi.ee – B2B E-commerce for Waterless Urinal Systems&lt;/a&gt; – the project this VAT and FX caching pattern is drawn from&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>node</category>
      <category>typescript</category>
      <category>ecb</category>
      <category>npm</category>
    </item>
    <item>
      <title>Do Mortgage Underwriters Actually Verify Bank Statements?</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Fri, 18 Sep 2026 10:00:44 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/do-mortgage-underwriters-actually-verify-bank-statements-3gfk</link>
      <guid>https://dev.to/iurii_rogulia/do-mortgage-underwriters-actually-verify-bank-statements-3gfk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/do-mortgage-underwriters-verify-bank-statements" 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;Yes – and more thoroughly than most borrowers expect. A mortgage file is one of the most heavily documented things in consumer finance, and the asset side of it has its own apparatus: sourcing rules for unexplained deposits, a form the bank itself fills in, and an automated path where a vendor pulls account data straight from the institution. Underwriters are not glancing at a balance and moving on.&lt;/p&gt;

&lt;p&gt;But ‘verify’ in mortgage underwriting means something specific, and it is worth being precise about it. The apparatus verifies whether the money is real, where it came from, and how long it has been there. On the path where the borrower uploads a PDF, none of it asks whether that PDF is the file the bank produced. Those are separate questions, and the second one sits outside the checklist entirely.&lt;/p&gt;

&lt;p&gt;This is a walk through what each asset-verification control actually establishes, which ones bypass the borrower’s document altogether, and what remains unverified on the path where the document is all you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Asset File Is Actually Made Of
&lt;/h2&gt;

&lt;p&gt;Asset verification in a conforming mortgage is not one check. It is a small stack of them, and which ones appear in a given file depends on how the assets were documented.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bank statements, read for sourcing and seasoning
&lt;/h3&gt;

&lt;p&gt;The default is the statement itself, usually the most recent two months for each account being used toward down payment, closing costs or reserves. The underwriter is not reading it for the closing balance alone. The work is in the deposits.&lt;/p&gt;

&lt;p&gt;Under Fannie Mae’s depository-account guidance, a &lt;strong&gt;large deposit&lt;/strong&gt; is a single deposit exceeding 50% of the total monthly qualifying income for the loan, and large deposits on a personal account have to be evaluated. If the money cannot be sourced – traced to a documented, acceptable origin – it does not count toward the funds the borrower is required to have. The point is not that a big deposit is suspicious in itself; it is that an undocumented lump sum might be a borrowed loan the file does not know about, which would change the debt-to-income ratio the approval rests on.&lt;/p&gt;

&lt;p&gt;Alongside sourcing sits &lt;em&gt;seasoning&lt;/em&gt;: money that has been in the account across the documented period needs no explanation, which is why ‘two months of statements’ is the unit. A borrower with funds already sitting there for the full window has nothing to source.&lt;/p&gt;

&lt;p&gt;This is genuine verification work. What it verifies is the plausibility and provenance of the &lt;strong&gt;money&lt;/strong&gt;. It reads the numbers on the page and asks whether a real account could have produced them.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Verification of Deposit
&lt;/h3&gt;

&lt;p&gt;Where statements are thin, contested, or simply not obtainable, the lender can go to the bank directly with a Verification of Deposit – Fannie Mae Form 1006 – which the depository institution completes and returns.&lt;/p&gt;

&lt;p&gt;This one is different in kind, and it matters for the argument here: a VOD is issuer-side evidence. The lender is no longer reading the borrower’s copy of anything. It is asking the bank what the balance is.&lt;/p&gt;

&lt;p&gt;It also has a documented weakness that shows how carefully these controls are actually specified. A VOD reports balances, not necessarily depository activity. When activity is not included, the guidance still requires the lender to verify the source of funds for accounts opened within 90 days of the application and for balances considerably above the average the VOD reports – because a snapshot of a balance cannot tell you how the balance got there. The control knows its own limits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Employment and income, cross-referenced
&lt;/h3&gt;

&lt;p&gt;The asset file does not stand alone. A Verification of Employment confirms the borrower’s job and income with the employer, and the 4506-C authorises the lender to pull tax transcripts from the IRS through the IVES programme. Those are income controls rather than asset controls, but they cross-reference the statement: deposits that are supposed to be payroll should match what the employer and the transcripts say the borrower earns.&lt;/p&gt;

&lt;p&gt;The tax side has its own timing characteristics – the transcript comes back days after the file has already moved – and we have written that up separately in &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;. The income-document side, where the paystub or W-2 is the artefact being altered, is 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;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The automated path, where the document disappears
&lt;/h3&gt;

&lt;p&gt;The most interesting control is the one that removes the PDF from the process.&lt;/p&gt;

&lt;p&gt;Both GSEs run automated asset assessment. Fannie Mae’s Day 1 Certainty uses the Desktop Underwriter validation service; Freddie Mac’s Asset and Income Modeler works inside Loan Product Advisor. In both, the lender does not receive a borrower-supplied statement at all. A third-party service provider retrieves account data directly from the financial institution and delivers it as a verification report, and the automated underwriting system assesses that data.&lt;/p&gt;

&lt;p&gt;The reason lenders adopt this is commercial rather than forensic: eligible loans receive relief from certain representations and warranties on the validated data, which means the lender is not on the hook for a repurchase if that component later proves wrong. Freddie Mac has extended the account-data path across income, assets and, more recently, additional asset types and sources of funds.&lt;/p&gt;

&lt;p&gt;For the loans that run this way, the question this article is about does not arise. There is no borrower-supplied file, so there is nothing to alter. This is the strongest control in the stack and it should be used wherever it can be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the PDF Path Survives
&lt;/h2&gt;

&lt;p&gt;The automated path is not universal, and the cases where it is unavailable are not random. They correlate with exactly the borrower profiles where document fraud concentrates.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Institutions the vendor cannot reach.&lt;/strong&gt; Account-data coverage is wide but finite. Smaller credit unions, some regional institutions and most foreign banks are not connected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Foreign and cross-border assets.&lt;/strong&gt; A borrower whose down payment sits in an overseas account is documenting it with statements, full stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Borrowers who decline to connect.&lt;/strong&gt; Consent is required. Some applicants refuse on privacy grounds, some out of friction, and some because a direct pull would show something the document does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-party accounts.&lt;/strong&gt; A gift donor is not the lender’s borrower and typically will not be connecting an account to anyone’s verification service. The donor statement arrives as a PDF, and it is the least examined document in the file – a case worth its own treatment, which it gets in &lt;a href="https://htpbe.tech/blog/gift-letter-asset-letter-fraud-mortgage" rel="noopener noreferrer"&gt;mortgage gift letter fraud&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-agency and portfolio products.&lt;/strong&gt; Bank-statement loan programmes for self-employed borrowers are built on statements by design, sometimes twelve or twenty-four months of them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On every one of these files, the asset evidence is a document the borrower obtained and handed over. And a borrower who intends to change a number will end up on this path, because it is the only path where changing a number accomplishes anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Question the Stack Does Not Ask
&lt;/h2&gt;

&lt;p&gt;Line the controls up and the common thread is visible.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;th&gt;What it establishes&lt;/th&gt;
&lt;th&gt;Does it inspect the borrower’s file?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Large-deposit sourcing&lt;/td&gt;
&lt;td&gt;Where an unexplained deposit came from&lt;/td&gt;
&lt;td&gt;No – it reads the numbers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seasoning&lt;/td&gt;
&lt;td&gt;How long funds have been in the account&lt;/td&gt;
&lt;td&gt;No – it reads the numbers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VOD (Form 1006)&lt;/td&gt;
&lt;td&gt;Balances, confirmed by the bank&lt;/td&gt;
&lt;td&gt;No – it goes around the file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VOE / 4506-C&lt;/td&gt;
&lt;td&gt;Income consistency from employer and IRS&lt;/td&gt;
&lt;td&gt;No – different documents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Automated asset report&lt;/td&gt;
&lt;td&gt;Account data pulled from the institution&lt;/td&gt;
&lt;td&gt;No – there is no file&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nothing in that column inspects the document. Where a control is strong, it is strong because it &lt;em&gt;bypasses&lt;/em&gt; the document and goes to the source. Where the source is unreachable and the document is all there is, the controls fall back to reading the content – sourcing, seasoning, arithmetic, cross-reference.&lt;/p&gt;

&lt;p&gt;Reading content is a real check and it catches real fraud. It catches the borrower whose fabricated deposits do not match the pay cycle, whose balance column stops reconciling, whose lump sum has no story. What it cannot catch is the case where the content was &lt;em&gt;tuned to pass it&lt;/em&gt;. A borrower who downloads a genuine statement, opens it in an editor and changes one figure gets to see the checklist in advance. They can recompute the running balance so it still reconciles, pick a deposit that fits the stated pay cycle, and avoid the tidy round numbers that draw attention. The file that arrives is the bank’s real template, with the bank’s real logo, in the bank’s real typography, containing one number that is not the bank’s.&lt;/p&gt;

&lt;p&gt;Nothing on the underwriting checklist asks whether that file was edited after the bank issued it. Not because the checklist is careless – because it was built to verify money, not files. The general-lending version of the same gap, outside mortgage’s guideline apparatus, is set out in &lt;a href="https://htpbe.tech/blog/how-lenders-verify-bank-statements" rel="noopener noreferrer"&gt;how lenders verify bank statements&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Residual Question: Was the File Changed After Issue?
&lt;/h2&gt;

&lt;p&gt;A PDF is not a picture of a page. It is a structured file that carries an internal record of how it was assembled and what was done to it afterwards – which software wrote it, how many times it was written to, whether a signature covers the bytes. Opening a genuine statement, editing it and saving it generally leaves traces in that record, and those traces do not depend on whether the visible numbers are plausible.&lt;/p&gt;

&lt;p&gt;That record is not something retyping makes agree. Recomputing a balance column so it still reconciles is an operation on the page; it says nothing about how the file came to exist.&lt;/p&gt;

&lt;p&gt;HTPBE reads that structure and returns one of three verdicts. &lt;code&gt;intact&lt;/code&gt; means no structural evidence of post-creation change, with an origin consistent with a single-pass institutional export – the absence of evidence of change, not proof the numbers are true. &lt;code&gt;modified&lt;/code&gt; means the file carries structural evidence that it was written to after it was first generated; named &lt;code&gt;modification markers&lt;/code&gt; such as &lt;code&gt;HTPBE_EDITING_TOOL_FINGERPRINT&lt;/code&gt;, &lt;code&gt;HTPBE_MULTIPLE_REVISION_LAYERS&lt;/code&gt; or &lt;code&gt;HTPBE_DATES_DISAGREE&lt;/code&gt; describe what was found. &lt;code&gt;inconclusive&lt;/code&gt; means the file’s origin – &lt;code&gt;consumer software&lt;/code&gt;, an online editor, a scanner – leaves no &lt;code&gt;institutional&lt;/code&gt; baseline to check integrity against.&lt;/p&gt;

&lt;p&gt;On mortgage assets, &lt;code&gt;inconclusive&lt;/code&gt; earns its place rather than being a shrug. A statement the borrower says came straight out of a major retail bank’s online banking, arriving with a consumer-software origin, has a provenance question attached to it that is worth asking before the file moves. The same verdict on a scanned statement from a small credit union is unremarkable. The verdict is the same; the action depends on what the document claims to be. It is unpacked properly in &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;h2&gt;
  
  
  What This Layer Is Not
&lt;/h2&gt;

&lt;p&gt;This is a file-integrity layer that sits &lt;strong&gt;alongside&lt;/strong&gt; sourcing, seasoning, the VOD, the VOE, the 4506-C and automated asset assessment – not a substitute for any of them, and not a shortcut around agency documentation requirements. A structural verdict is not a verification of deposit, does not evidence assets, does not satisfy any GSE documentation standard, and carries no representation-and-warranty relief. Where the automated account-data path is available, use it; it is a stronger control than anything that can be learned from a file, because it does not involve a file.&lt;/p&gt;

&lt;p&gt;Two limits are worth stating plainly.&lt;/p&gt;

&lt;p&gt;It says nothing about whether the numbers are true. A file can be structurally unremarkable and describe an account that never held that balance. Content checks and source verification stay in the process; they are answering a different question.&lt;/p&gt;

&lt;p&gt;And it has little to say about a document that was never derived from a real statement at all. Where a statement is built from scratch rather than edited, there is no modification event to find. Depending on what produced it, such a file can come back &lt;code&gt;inconclusive&lt;/code&gt; or even &lt;code&gt;intact&lt;/code&gt; – and &lt;code&gt;intact&lt;/code&gt; means only that there is no structural evidence of change, never that the contents are true. That fabricated-from-scratch case is precisely what the VOD and the automated asset report handle well, which is the argument for having both layers rather than choosing between them.&lt;/p&gt;

&lt;p&gt;Nor is a structural finding a decision about a person. It is a reason to go back to the source: ask for a copy pulled directly from the bank’s portal, order the VOD, or route the file to a human who will.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring It Into an Asset Review
&lt;/h2&gt;

&lt;p&gt;For a one-off document, this needs nothing but the file. Register and you can upload a statement here for a verdict in a few seconds; new accounts come with five checks, and beyond that it is pay-per-check.&lt;/p&gt;

&lt;p&gt;At origination volume, it belongs at intake rather than at the underwriter’s desk. The integration is two calls: one to submit the document URL, one to fetch the verdict and named markers as structured data. Run it the moment an asset document lands in the loan file, before anyone reads it, and route on the outcome – &lt;code&gt;modified&lt;/code&gt; to manual review and a request for a fresh copy from the source; &lt;code&gt;inconclusive&lt;/code&gt; branched on what the document claims to be; &lt;code&gt;intact&lt;/code&gt; through to the sourcing and seasoning work the checklist above describes. Store the check identifier against the loan record so the result is retrievable if a decision is ever reviewed. Endpoints, response shape and &lt;code&gt;test key&lt;/code&gt; scenarios for building against it are documented in &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;the API reference&lt;/a&gt;, and the document-type walkthrough lives on the &lt;a href="https://htpbe.tech/use-cases/fake-bank-statement-detection" rel="noopener noreferrer"&gt;fake bank statement detection&lt;/a&gt; page.&lt;/p&gt;

&lt;p&gt;Mortgage underwriting verifies bank statements carefully, and where it can reach past the document to the institution it verifies them very well indeed. The gap is not in the diligence. It is that on the files where the document is the only evidence, no one is asking whether the document is the one the bank issued.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>fintech</category>
      <category>fraud</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What Happens When You Reverse-Charge an Invalid VAT Number</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Fri, 18 Sep 2026 09:00:46 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/what-happens-when-you-reverse-charge-an-invalid-vat-number-39jn</link>
      <guid>https://dev.to/iurii_rogulia/what-happens-when-you-reverse-charge-an-invalid-vat-number-39jn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/reverse-charge-invalid-vat-number-risk" 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;h1&gt;
  
  
  What Happens When You Reverse-Charge an Invalid VAT Number
&lt;/h1&gt;

&lt;p&gt;Short answer: the exposure lands on the seller. If you issue a zero-rated, reverse-charge invoice because the buyer gave you a VAT number, and that number turns out to have been invalid at the time of supply, a tax authority can reassess the transaction as a normal domestic one. You didn’t charge VAT. Now you’re the one who owes it – plus interest, plus whatever penalty regime applies where you’re established.&lt;/p&gt;

&lt;p&gt;That’s the mechanical answer. The more useful one is where the actual risk sits, because it usually isn’t ‘the number happened to be bad.’ It’s not validating at all, validating in a way you can’t reproduce later, or being unable to produce the evidence when someone asks for it. This post separates those two stories, because they get conflated constantly and that leads to bad decisions – treating every later deregistration as a crisis, or treating a validated-but-informal check as good enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reverse charge is a services mechanism, not a goods one
&lt;/h2&gt;

&lt;p&gt;Worth being precise here, because the term gets used loosely. In the strict, technical sense, ‘reverse charge’ is &lt;a href="https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:02006L0112-20240101" rel="noopener noreferrer"&gt;Article 44 (place of supply) and Article 196 (customer liable) of Directive 2006/112/EC&lt;/a&gt; – and it applies to &lt;strong&gt;services&lt;/strong&gt;. The customer, not the supplier, is the person liable for the VAT.&lt;/p&gt;

&lt;p&gt;The goods equivalent looks similar in effect – no VAT on the invoice – but it isn’t the same mechanism. A cross-border intra-EU supply of goods is a zero-rated exempt supply under Article 138, paired with the buyer’s intra-Community acquisition under Article 20. There’s no ‘customer liable’ flip; the exemption and the acquisition are two separate legal events. Calling that ‘goods reverse charge’ is common shorthand, but it isn’t the technical term, and conflating the two conditions can cost you the exemption on one side while you’re checking the wrong box on the other.&lt;/p&gt;

&lt;p&gt;This post is scoped to the services case – Article 196. If you’re dealing with the goods side, the &lt;a href="https://vatnode.dev/guides/vat-reverse-charge" rel="noopener noreferrer"&gt;reverse-charge concept guide&lt;/a&gt; covers both mechanisms properly, and &lt;a href="https://vatnode.dev/reverse-charge" rel="noopener noreferrer"&gt;by-country invoice wording&lt;/a&gt; is the reference for what to print on the invoice either way.&lt;/p&gt;

&lt;p&gt;One reason the distinction matters here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Since &lt;strong&gt;1 January 2020&lt;/strong&gt; (the ‘quick fixes’ under Council Directive (EU) 2018/1910), a VIES-valid customer VAT number stopped being a merely formal condition of the Article 138 goods exemption and became a &lt;em&gt;substantive&lt;/em&gt; one. Before that, &lt;a href="https://curia.europa.eu/juris/liste.jsf?num=C-24/15" rel="noopener noreferrer"&gt;Plöckl (C-24/15)&lt;/a&gt; treated a missing or wrong VAT number as something that shouldn’t defeat the exemption on its own if the substance was there; since 2020, a bad number can defeat it directly.&lt;/li&gt;
&lt;li&gt;Article 138(1a) makes the exemption conditional on correctly reporting the supply on the recapitulative statement (EC Sales List) – a valid number isn’t enough if the transaction isn’t reported right.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are goods-side rules. They raise the stakes generally, and they’re the reason ‘just eyeball the number’ was never a great practice even before the services case, which never had the pre-2020 formal/substantive distinction to begin with – Article 196 liability has always turned on the buyer actually being a taxable person, valid number or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ‘invalid’ actually costs the seller
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;This is general information about how EU VAT rules and CJEU case law generally operate. It is not&lt;br&gt;
  tax or legal advice, and it isn’t individualized to your facts. If you’ve already issued an&lt;br&gt;
  invoice against a number that turned out to be invalid, talk to a qualified adviser in the&lt;br&gt;
  relevant jurisdiction before you decide how to fix it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If a services supply didn’t qualify for reverse charge – because the buyer wasn’t a taxable person, or the VAT number given was never valid – the seller is the one exposed. The tax authority in the seller’s member state can assess the supply as domestic, meaning the seller owes the VAT that should have been charged in the first place. On top of the principal amount, expect interest for the period the VAT went unpaid, and a penalty. National law sets both the interest rate and the penalty percentage, and they vary by member state – there’s no single EU-wide figure to quote here, and treat any source that gives you one flat number as wrong. Assessment and limitation periods are national law too, which is part of why the evidence question below matters: you may need to defend a supply years after you made it.&lt;/p&gt;

&lt;p&gt;None of that is contingent on intent. A seller who genuinely believed the number was valid can still end up assessed – the good-faith question (next section) is about whether that assessment is the end of the story or whether there’s a viable defence against it. They’re different questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The good-faith defence – and its limits
&lt;/h2&gt;

&lt;p&gt;CJEU case law gives suppliers real protection here, but it’s a defence you have to make out on the facts, not an automatic pass. &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-271/06" rel="noopener noreferrer"&gt;Netto Supermarkt (C-271/06)&lt;/a&gt;, and &lt;a href="https://curia.europa.eu/juris/liste.jsf?num=C-21/16" rel="noopener noreferrer"&gt;Euro Tyre (C-21/16)&lt;/a&gt; collectively establish that a supplier who took every reasonable measure available and had no part in fraud can generally keep an exemption even where the evidence later turns out to be false. Be precise about their reach, though: these were decided on the goods exemption (Article 138) and the export exemption (Article 146), not on Article 196 services liability. Applying the same reasonable-measures, good-faith principle to the services case is an extension by analogy of general EU-law principles the Court has applied consistently across VAT provisions – not a services-specific holding. It’s a meaningful protection either way, but it’s built on ‘reasonable measures’ and ‘no involvement in fraud’ – both fact questions a tax authority or court has to be satisfied of, not a checkbox.&lt;/p&gt;

&lt;p&gt;Where this gets specifically relevant to invalid numbers: the fact pattern of ‘we checked, it was valid, it was deregistered afterward’ doesn’t have a CJEU ruling squarely on point that says a validated-then-later-deregistered number is automatically covered. The reasonable inference from Teleos and the line of cases after it is that a genuine, dated, requester-qualified check at the time of supply is the kind of ‘reasonable measure’ the case law is talking about – but that’s an inference from the general principle, not a decided rule for this exact scenario. If it matters for a real supply you’ve made, that’s a question for an adviser who knows your jurisdiction, not a blog post.&lt;/p&gt;

&lt;p&gt;This is also where the two risk stories diverge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The number was invalid at the time of supply.&lt;/strong&gt; You didn’t meet the condition. The good-faith defence might still apply depending on what checks you ran, but you’re starting from a failed condition and arguing your way out of liability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The number was valid at the time of supply and deregistered afterward.&lt;/strong&gt; You met the condition when it mattered. The &lt;a href="https://vatnode.dev/blog/reverse-charge-vat-saas-implementation-checklist" rel="noopener noreferrer"&gt;implementation checklist&lt;/a&gt; puts it plainly: that’s not your problem at the moment of supply, provided the validation was genuine at the time – the audit position is ‘valid when checked,’ not ‘still valid today.’ Keep the original evidence; don’t let a later status change rewrite what you knew when you issued the invoice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are not the same conversation, and treating the second as if it were the first is how teams end up either panicking over routine churn or, worse, assuming no check is ever really ‘safe’ and giving up on validating at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Format-valid is not the same as registered
&lt;/h2&gt;

&lt;p&gt;A VAT number can pass a regex and checksum and still not be registered, or no longer be registered. Format validation tells you the string is shaped correctly – it says nothing about whether the business behind it is VAT-registered right now. VIES is the system that answers that question – a real-time search over the national tax databases of each member state; the Commission doesn’t operate those databases itself, and VIES has downtime, particularly on specific national nodes. A VIES-valid result is a snapshot: it proves the number was valid at the moment you asked, nothing further forward or backward in time.&lt;/p&gt;

&lt;p&gt;That snapshot nature is precisely why a dated, requester-qualified check matters more than an undated one. A requester-qualified VIES lookup returns a &lt;code&gt;consultationNumber&lt;/code&gt; – a reference VIES itself issues, tying the check to a specific number, requester, and timestamp. It’s evidence you ran the check, not a guarantee the underlying transaction was correct, and it’s only present on requester-qualified lookups – a plain check or a national-fallback result won’t carry one. The business case for keeping it is covered in &lt;a href="https://vatnode.dev/blog/consultation-number-for-finance" rel="noopener noreferrer"&gt;the consultation number for finance teams&lt;/a&gt;; it’s not unique to any one provider, and no provider’s internal ID is a substitute for it. You can generate that same requester-qualified check yourself by calling VIES’s &lt;code&gt;checkVatApprox&lt;/code&gt; operation directly – &lt;a href="https://vatnode.dev/blog/build-vs-buy-vat-validation" rel="noopener noreferrer"&gt;build vs buy&lt;/a&gt; is the honest accounting of what maintaining that path actually costs.&lt;/p&gt;

&lt;p&gt;Country coverage adds another wrinkle, and it’s easy to get wrong. When VIES is unreachable for a member state, some checks can fall through to a genuine national fallback – a tax authority’s own VAT register that can independently confirm a number. Others have only a company registry, which vatnode does not treat as a validity fallback at all: a business can be active and in good standing in a company registry without being VAT-registered for intra-EU purposes, so registry presence can’t stand in for a VAT verdict. Where only a company registry exists, a VIES outage surfaces as an error, not a substitute ‘valid.’ And even where a real fallback answers, a fallback ‘valid’ is not equivalent to a VIES-valid result for the condition reverse charge actually needs. Which countries fall in which bucket changes over time – see &lt;a href="https://vatnode.dev/docs/coverage" rel="noopener noreferrer"&gt;current coverage&lt;/a&gt; rather than assuming a fixed list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an invalid result actually looks like – and why it’s useful
&lt;/h2&gt;

&lt;p&gt;A &lt;a href="https://vatnode.dev/vies-api" rel="noopener noreferrer"&gt;&lt;code&gt;GET /v1/vat/:vatId&lt;/code&gt;&lt;/a&gt; call against a number that isn’t registered doesn’t error. It returns HTTP 200 with &lt;code&gt;valid: false&lt;/code&gt;, and it still carries the source, timestamp, and check ID (the &lt;code&gt;consultationNumber&lt;/code&gt; below is populated because the account has a requester VAT set):&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;"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;"DE123456788"&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;"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;"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;"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-09-04T10:22:31Z"&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;"01926a3e-9f4c-7c2e-8b1a-3d7e6a2f9c11"&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="s2"&gt;"WAPIAAAAX9999999"&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;That’s a definitive negative, not a failure state, and it’s protective – it’s the artefact that lets you correctly decline to zero-rate the invoice in real time, before the supply happens, rather than finding out during an audit two years later. Contrast that with a VIES outage: &lt;code&gt;VIES_UNAVAILABLE&lt;/code&gt; (HTTP 503) or a national &lt;code&gt;MS_UNAVAILABLE&lt;/code&gt; doesn’t mean invalid, it means the check didn’t run – see &lt;a href="https://vatnode.dev/blog/vies-downtime-guide" rel="noopener noreferrer"&gt;handling VIES downtime&lt;/a&gt; and &lt;a href="https://vatnode.dev/docs/errors" rel="noopener noreferrer"&gt;the error reference&lt;/a&gt; for how to handle it without either blocking the customer or silently charging VAT on a transient outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the actual exposure comes from
&lt;/h2&gt;

&lt;p&gt;The real risk isn’t ‘a customer’s VAT number went bad.’ It’s one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never validating.&lt;/strong&gt; Taking the number at face value because it’s the right length and shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validating without a reproducible, dated, requester-qualified check.&lt;/strong&gt; An undated internal note or an ad-hoc lookup with no requester attached gives you a weaker evidentiary position than a check that VIES itself timestamped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not being able to produce the evidence later.&lt;/strong&gt; A check you ran but didn’t store – or stored without linking it to the invoice it supports – is functionally the same as not having checked, from an auditor’s point of view. &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; covers the triggers; &lt;a href="https://vatnode.dev/guides/vat-audit-trail" rel="noopener noreferrer"&gt;the full audit trail&lt;/a&gt; covers what to keep and for how long.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical defence against all three is the same: validate at the point where the treatment is decided, keep the dated evidence next to the invoice, and re-check periodically rather than assuming a signup-time validation holds forever. &lt;a href="https://vatnode.dev/blog/bulk-revalidate-customer-vat-ids" rel="noopener noreferrer"&gt;Bulk re-validating a customer base&lt;/a&gt; is the mechanism for that – a backfill pass plus an ongoing cadence, so a deregistration shows up as a flagged number on a known date instead of a surprise during an audit. That’s the mitigation story, and it’s a genuinely different conversation from the liability one above.&lt;/p&gt;

&lt;p&gt;If you’re building the invoicing logic itself – the decision function, the country-comparison rule, what to render on the invoice – that’s the &lt;a href="https://vatnode.dev/blog/reverse-charge-vat-saas-implementation-checklist" rel="noopener noreferrer"&gt;reverse-charge implementation checklist&lt;/a&gt;. This post is the risk case for getting a step in that checklist wrong; it isn’t a second version of the checklist.&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 supply made against an invalid number, get a qualified adviser in the relevant jurisdiction involved before deciding how to fix it.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Who owes the VAT if the buyer’s number turns out to be invalid?
&lt;/h3&gt;

&lt;p&gt;In the reverse-charge mechanism for services, the buyer is liable under Article 196 only when the conditions actually hold. If the number was invalid, one of those conditions failed, and a tax authority can reassess the supply as a normal domestic one – with the seller assessed for the VAT that was never charged, plus interest and penalties that vary by country. This is general information, not tax advice for a specific case.&lt;/p&gt;

&lt;h3&gt;
  
  
  We validated at invoice time and the number was deregistered later – are we exposed?
&lt;/h3&gt;

&lt;p&gt;That is a materially different fact pattern from never checking. If the check was genuine, requester-qualified, and dated to the time of supply, you have a documented basis for the treatment you applied at that moment – case law generally protects a supplier who took reasonable measures and was not party to fraud, though outcomes are fact-dependent and no single ruling guarantees a result. Keep the evidence and talk to a local adviser if this applies to a real supply you’ve already made.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does a VIES outage mean the buyer’s VAT number is invalid?
&lt;/h3&gt;

&lt;p&gt;No. VIES_UNAVAILABLE (or a national MS_UNAVAILABLE) is a transport failure, not a negative result. It tells you the check didn’t run, not that the number is bad. Treat it as ‘retry’ evidence and keep a record of the attempt.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a VIES screenshot enough evidence if this gets audited later?
&lt;/h3&gt;

&lt;p&gt;A screenshot is self-produced and hard to date convincingly on its own. A requester-qualified VIES check that returns a consultation number is stronger, because VIES itself issues and timestamps that reference. Neither one proves the underlying supply qualified for the treatment – they prove you checked, not that the transaction was correct.&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  Know before you invoice, not after an audit
&lt;/h3&gt;

&lt;p&gt;vatnode runs requester-qualified VIES checks – set your EU VAT number once in Settings, and every VIES check returns a definitive &lt;code&gt;valid: true&lt;/code&gt;/&lt;code&gt;false&lt;/code&gt; with the consultation number, source, and timestamp. So a bad number shows up before you zero-rate the invoice, not during a review two years later. 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/vies-api" rel="noopener noreferrer"&gt;VIES API&lt;/a&gt; · &lt;a href="https://vatnode.dev/blog/bulk-revalidate-customer-vat-ids" rel="noopener noreferrer"&gt;Bulk re-validate a customer base&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>tax</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Booking a Shipment from an Order Pipeline Without Stalling the Rest of the Order</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Wed, 16 Sep 2026 10:00:57 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/booking-a-shipment-from-an-order-pipeline-without-stalling-the-rest-of-the-order-1b11</link>
      <guid>https://dev.to/iurii_rogulia/booking-a-shipment-from-an-order-pipeline-without-stalling-the-rest-of-the-order-1b11</guid>
      <description>&lt;p&gt;On &lt;a href="https://iurii.rogulia.fi/projects/pikkuna-ecommerce-platform" rel="noopener noreferrer"&gt;Pikkuna&lt;/a&gt;, a Stripe webhook enqueues a confirmed order into a BullMQ (Redis-backed) job queue, and a worker runs through it sequentially – CRM deal, backup record, shipment, accounting entry, PDF invoice, confirmation email:&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;// workers/order-processor.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;orders&lt;/span&gt;&lt;span class="dl"&gt;"&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;job&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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;session&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;fetchSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&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;createZohoDeal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// CRM&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createAirtableRecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Backup DB&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createPostNordShipment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Shipping label&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendToNetvisor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Accounting&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invoice&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;generateInvoicePDF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&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;sendEmailWithInvoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;invoice&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;That’s the real shape, reconstructed from &lt;a href="https://iurii.rogulia.fi/projects/pikkuna-ecommerce-platform#order-flow-automation-via-stripe-webhook" rel="noopener noreferrer"&gt;the Pikkuna case study&lt;/a&gt; – the same worker I used for the accounting call in &lt;a href="https://iurii.rogulia.fi/blog/netvisor-accounting-integration" rel="noopener noreferrer"&gt;Wiring an Accounting System into a Payment Webhook Without Losing Money&lt;/a&gt;. &lt;code&gt;createPostNordShipment(session)&lt;/code&gt; is one line in it. I don’t have PostNord’s actual booking endpoint, payload shape, auth mechanism, or error codes to hand you, and I’m not going to invent them – this article isn’t about PostNord’s API surface. It’s about the questions that line raises for any logistics-provider call sitting in a sequential order-processing worker, whichever carrier is on the other end: PostNord, DHL, a regional courier, a shipping aggregator. Three of them come up every time this pattern gets built.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does the Booking API Answer Now, or Later?
&lt;/h2&gt;

&lt;p&gt;Shipping-provider APIs split roughly into two families, and the split changes how the worker step has to be written.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Synchronous booking&lt;/strong&gt; returns a tracking number and a label – usually a PDF or ZPL payload – in the response to the call that created the shipment. The worker step is a single &lt;code&gt;await&lt;/code&gt;: call, get the label back, store it, move on. This is the easy case, and it’s the one the Pikkuna snippet’s single &lt;code&gt;await createPostNordShipment(session)&lt;/code&gt; line implicitly assumes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Asynchronous booking&lt;/strong&gt; is different, and it’s common enough with carrier and freight APIs that it can’t be treated as an edge case. The call you make doesn’t create the shipment – it submits a booking request that gets &lt;em&gt;accepted&lt;/em&gt;, and the carrier confirms the label and tracking number later: a webhook callback, or a status you have to poll for. In that shape, &lt;code&gt;createPostNordShipment(session)&lt;/code&gt; can’t be one &lt;code&gt;await&lt;/code&gt; that returns a finished shipment. It has to do two things instead – submit the booking, and record that this order has a shipment &lt;em&gt;pending confirmation&lt;/em&gt; – because the worker function returns, moves the job on to the accounting step, and generates an invoice, all before the label exists:&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;// generic pattern — illustrative, not any specific carrier's API&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bookShipment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CheckoutSession&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;bookingRef&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;carrierClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submitBooking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// No label yet. Record the booking as pending so the callback&lt;/span&gt;
  &lt;span class="c1"&gt;// or a poller has something to reconcile against later.&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;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shipmentBookings&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&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;bookingRef&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;pending&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="c1"&gt;// The worker moves on — invoice generation doesn't wait on a label&lt;/span&gt;
  &lt;span class="c1"&gt;// that might not exist for another few minutes.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The invoice and confirmation email steps further down the same worker then can’t assume a tracking number exists yet. Either they render without one and a separate step (the webhook callback, or a follow-up job scheduled to poll) fills it in and re-sends, or the invoice is generated with a ‘shipping label pending’ placeholder that’s genuinely true rather than a bug. Which one is right is a product decision, not a technical one. But it has to be made deliberately: ‘assume the label exists by the time the invoice renders’ silently breaks the moment the booking API turns out to be asynchronous.&lt;/p&gt;

&lt;p&gt;
  slug="api-integrations"&lt;br&gt;
  text="Wiring a carrier’s booking API – synchronous or webhook-confirmed – into an order pipeline that also has to keep the invoice and the accounting entry correct? Getting the sequencing and the retries right the first time is the integration work I do."&lt;br&gt;
/&amp;gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Should a Slow Carrier API Stall the Customer’s Invoice?
&lt;/h2&gt;

&lt;p&gt;The worker in the snippet above runs shipment booking third, before accounting and before the PDF invoice. That ordering has a consequence: if &lt;code&gt;createPostNordShipment&lt;/code&gt; throws or hangs, the two steps after it – the accounting entry and the invoice the customer is waiting on – don’t run either, because a BullMQ job that throws partway through fails the whole job. Stripe already confirmed the charge, the webhook already returned &lt;code&gt;200&lt;/code&gt;, the customer paid. And now they’re waiting on their invoice because a shipping API is having a bad afternoon.&lt;/p&gt;

&lt;p&gt;The honest answer isn’t ‘always decouple it’. It depends on which of two things is true for the business:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;If the invoice is expected before the shipment ships&lt;/strong&gt; (typical for made-to-order or B2B goods with a lead time), there’s no real coupling problem. The customer isn’t blocked on the label; they’re blocked on the invoice, and the invoice doesn’t need the tracking number to be correct. In that case the fix isn’t decoupling the shipment call at all – it’s &lt;strong&gt;reordering the worker&lt;/strong&gt; so accounting and invoice generation run before shipment booking, and a failed booking retries on its own without holding up either.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If the invoice or the confirmation email is supposed to carry the tracking number&lt;/strong&gt; – common in DTC e-commerce, which is Pikkuna’s case – the two steps are genuinely coupled by the data, not just by position in the function. Reordering doesn’t fix it; the invoice needs a label that doesn’t exist yet. Here the real fix is upstream of retry logic. Decide, as a product decision, whether the invoice can ship without a tracking number (email it now, follow up with tracking once the label exists, which is the asynchronous-booking pattern above applied by choice even when the carrier itself is synchronous) or whether the business genuinely wants every invoice blocked until a label is confirmed. Only the second choice justifies leaving &lt;code&gt;createPostNordShipment&lt;/code&gt; in the synchronous critical path ahead of the invoice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either way, ‘shipment booking runs third in the function’ is not itself the design decision – it’s the default a sequential worker falls into when nobody made one. The actual decision is which downstream steps genuinely depend on the label existing, and that’s a question about the data, not about where the &lt;code&gt;await&lt;/code&gt; happens to sit in the file.&lt;/p&gt;
&lt;h2&gt;
  
  
  A Booking That Succeeded but the Response Got Lost
&lt;/h2&gt;

&lt;p&gt;The same failure shape that motivated the dedupe-key pattern for the accounting call applies here, for the same underlying reason: a timeout doesn’t tell you whether the carrier received the request and failed to answer, or received it, created the shipment, and the &lt;em&gt;response&lt;/em&gt; got lost on the way back. BullMQ retries the job either way. A naive &lt;code&gt;bookShipment(session)&lt;/code&gt; called a second time submits a second booking – a second label, a second tracking number, a second cost on the account, for one order.&lt;/p&gt;

&lt;p&gt;The fix follows the same shape covered in &lt;a href="https://iurii.rogulia.fi/blog/idempotency-keys-api-retries" rel="noopener noreferrer"&gt;Idempotency Keys: Building Retries That Don’t Double-Charge&lt;/a&gt;: a deterministic key derived from the order, claimed atomically before the carrier call happens, so a retry recognizes its own prior attempt instead of repeating it.&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;// generic pattern — illustrative, not any specific carrier's API&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bookShipment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CheckoutSession&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;dedupeKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`shipment:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;session&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// stable across retries&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shipmentBookings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findFirst&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&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;shipmentBookings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dedupeKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dedupeKey&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="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;confirmed&lt;/span&gt;&lt;span class="dl"&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;existing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// already booked&lt;/span&gt;

  &lt;span class="c1"&gt;// Claim the key atomically before calling the carrier. If a prior&lt;/span&gt;
  &lt;span class="c1"&gt;// attempt already claimed it and is still pending, this insert loses&lt;/span&gt;
  &lt;span class="c1"&gt;// the race — that's the signal to check status, not to book again.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claim&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;db&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shipmentBookings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;dedupeKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&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="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;pending&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;onConflictDoNothing&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;returning&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;claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Another attempt owns this key. If the carrier's API accepts an&lt;/span&gt;
    &lt;span class="c1"&gt;// idempotency key or a lookup-by-reference call, that's the honest&lt;/span&gt;
    &lt;span class="c1"&gt;// way to find out whether the earlier attempt actually landed —&lt;/span&gt;
    &lt;span class="c1"&gt;// don't just assume it did or didn't.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;resolveExistingBooking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dedupeKey&lt;/span&gt;&lt;span class="p"&gt;);&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;trackingNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;labelUrl&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;carrierClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createShipment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&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;shipmentBookings&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;confirmed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;trackingNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;labelUrl&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;shipmentBookings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dedupeKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dedupeKey&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// The carrier may have created the shipment before the timeout —&lt;/span&gt;
    &lt;span class="c1"&gt;// this record stays "pending", not "failed", until something checks.&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&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;Same mechanism as the accounting pattern: the row’s claim, not the code’s control flow, decides which attempt is allowed to call the carrier. A &lt;code&gt;UNIQUE&lt;/code&gt; constraint on &lt;code&gt;dedupeKey&lt;/code&gt; is checked by the database at insert time, so it holds even when the retry lands on a different worker process – which, on a BullMQ job re-queued after a timeout, it may well do. In-memory dedup logic doesn’t survive that.&lt;/p&gt;

&lt;p&gt;One thing this pattern deliberately doesn’t paper over: unlike the accounting call, a &lt;code&gt;pending&lt;/code&gt; row here can’t always be resolved by just calling &lt;code&gt;createShipment&lt;/code&gt; again and trusting the dedupe key alone, because the failure happened on the outbound leg to a stateful side effect at a &lt;em&gt;third party&lt;/em&gt; – a real label may already exist, with a real cost attached, sitting in the carrier’s system under a reference this worker doesn’t have yet if it never got the response. Whether the carrier API exposes a way to look up a booking by your own reference number, or accepts its own idempotency key so a resubmission is safe by design, determines whether &lt;code&gt;resolveExistingBooking&lt;/code&gt; above is ‘call the carrier and check’ or ‘wait for a human, because there’s no safe way to find out.’ That’s a fact about the specific carrier’s API, not something this pattern can guarantee in general. Confirm it before the first &lt;code&gt;pending&lt;/code&gt; row shows up stuck in production, not after.&lt;/p&gt;




&lt;p&gt;Sequencing a carrier call, an accounting entry, and an invoice generation step in the same worker function is a small amount of code that hides a real design decision: what’s allowed to fail without stalling the rest of the order. If that’s the stage your order pipeline is at, &lt;a href="https://iurii.rogulia.fi/contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt; and let’s work out which steps actually need to block each other before a slow shipping API becomes a stalled invoice.&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/netvisor-accounting-integration" rel="noopener noreferrer"&gt;Wiring an Accounting System into a Payment Webhook Without Losing Money&lt;/a&gt; – the same Pikkuna worker, same sequential-processing trade-offs, applied to the accounting call one step further down&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://iurii.rogulia.fi/blog/shipping-crm-automation-zoho-postnord" rel="noopener noreferrer"&gt;PostNord API and Zoho Desk Automation: Production Gotchas&lt;/a&gt; – PostNord’s actual endpoint behavior and error-handling quirks, for the specifics this article deliberately leaves out&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://iurii.rogulia.fi/blog/idempotency-keys-api-retries" rel="noopener noreferrer"&gt;Idempotency Keys: Building Retries That Don’t Double-Charge&lt;/a&gt; – the general dedupe-key contract this article’s shipment-booking pattern is built on&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://iurii.rogulia.fi/blog/stripe-webhooks-production" rel="noopener noreferrer"&gt;Stripe Webhooks: Idempotency, Retries, and Queue Setup&lt;/a&gt; – how the inbound side of this same handler deduplicates events before the job is ever enqueued&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 project this pattern is drawn from&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>typescript</category>
      <category>node</category>
      <category>bullmq</category>
      <category>postgres</category>
    </item>
    <item>
      <title>How to Check a PDF’s Creation &amp; Modification Dates</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Wed, 16 Sep 2026 10:00:41 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/how-to-check-a-pdfs-creation-modification-dates-26c4</link>
      <guid>https://dev.to/iurii_rogulia/how-to-check-a-pdfs-creation-modification-dates-26c4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/check-pdf-creation-modification-dates" 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;Every PDF carries two dates: when it was created, and when it was last saved. Open any file’s properties and you’ll find both in seconds. Both are plain text, the same kind of string as the title or the author name. Any editor that can open a PDF can rewrite them to say whatever the person editing wants.&lt;/p&gt;

&lt;p&gt;That doesn’t make the dates useless. It makes them a claim rather than a fact. Checking a PDF’s dates properly means knowing the difference between reading them and trusting them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the two dates live
&lt;/h2&gt;

&lt;p&gt;The Info dictionary, a small block of metadata in the file’s trailer, can carry a &lt;code&gt;CreationDate&lt;/code&gt; and a &lt;code&gt;ModDate&lt;/code&gt; field. Many PDFs duplicate the same two values in an embedded XMP packet (&lt;code&gt;xmp:CreateDate&lt;/code&gt; and &lt;code&gt;xmp:ModifyDate&lt;/code&gt;), a second, XML-based metadata layer that Adobe tools and some other generators write alongside the Info dictionary. Both live inside the file itself, not in your filesystem. They travel with the PDF when you email it, upload it, or copy it to a new folder. For the full field-by-field breakdown of the Info dictionary, including &lt;code&gt;Creator&lt;/code&gt;, &lt;code&gt;Producer&lt;/code&gt; and the xref table, see &lt;a href="https://htpbe.tech/blog/pdf-metadata-fields-complete-reference" rel="noopener noreferrer"&gt;PDF metadata forensics: a complete field-by-field reference&lt;/a&gt;. This article stays on the two date fields.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;CreationDate&lt;/code&gt;&lt;/strong&gt; records when the document was first generated: the moment the PDF bytes were written, not necessarily when the underlying content (a scanned page, a report’s source data) came into existence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ModDate&lt;/code&gt;&lt;/strong&gt; records when the file was last saved. Any operation that writes changes back into the file updates it: editing text, adding a digital signature, filling a form field, running the file through a compressor or format converter.&lt;/p&gt;

&lt;p&gt;On a document generated once and never touched again, the two should match, or come close enough that the gap is meaningless.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check a PDF’s creation date online or offline
&lt;/h2&gt;

&lt;p&gt;You don’t need any special software for a quick look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In Adobe Acrobat Reader:&lt;/strong&gt; open the file, then File → Properties. The &lt;code&gt;Created&lt;/code&gt; and &lt;code&gt;Modified&lt;/code&gt; fields are on the Description tab.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On Windows:&lt;/strong&gt; right-click the file, choose Properties, and check the General tab.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On macOS:&lt;/strong&gt; right-click the file, choose Get Info, and check &lt;code&gt;Created&lt;/code&gt; and &lt;code&gt;Last Modified&lt;/code&gt;. Finder sometimes shows filesystem timestamps rather than the values embedded in the PDF, so don’t treat what you see there as forensic-grade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Online, without opening a desktop app:&lt;/strong&gt; a metadata viewer that reads the file’s actual Info dictionary and XMP packet, rather than your OS’s file-manager timestamps, is more reliable. Our &lt;a href="https://htpbe.tech/pdf-metadata-viewer" rel="noopener noreferrer"&gt;free PDF metadata viewer&lt;/a&gt; dumps every field from a file you upload — both dates, &lt;code&gt;Creator&lt;/code&gt;, &lt;code&gt;Producer&lt;/code&gt;, and the rest — with no account required.&lt;/p&gt;

&lt;p&gt;Whichever method you use, you’re reading the same underlying bytes. The question is what those bytes are worth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the raw values lie
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;CreationDate&lt;/code&gt; and &lt;code&gt;ModDate&lt;/code&gt; are stored as plain-text strings in a format that looks like &lt;code&gt;D:20260115093000+02'00'&lt;/code&gt;. There is nothing cryptographic about them. No hash, no signature, no tamper-evident seal. A text editor, a command-line metadata tool, or any PDF editor’s ‘document properties’ panel can rewrite either field to any value at all, and the file opens normally afterward with no warning that anything changed.&lt;/p&gt;

&lt;p&gt;So a mismatch, or a suspiciously round number (‘created’ at exactly midnight, or matching today’s date on a document that claims to be years old), is not proof of fraud. A match between the two dates is not proof of an untouched file either. Someone forging a document can set both fields to whatever they want, including making them identical to hide the edit.&lt;/p&gt;

&lt;p&gt;Read alone, the two dates tell you one honest thing and one dishonest thing. The honest thing: if they genuinely differ, &lt;em&gt;something&lt;/em&gt; wrote to the file after it was first created. Any real save operation produces that as a byproduct. The dishonest thing: the exact values are self-reported by whatever software last touched the file, and nothing stops that software, or a person with a text editor, from writing false ones.&lt;/p&gt;

&lt;p&gt;This isn’t a theoretical weakness. General-purpose metadata utilities exist specifically to read and rewrite these fields. That’s their entire job, and it’s a legitimate one for privacy scrubbing before you share a file. The same capability that lets you strip your name from a PDF before sending it to a stranger lets someone backdate a document by five years with one command. The field doesn’t know, or care, which use case it’s serving.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a plain glance at the dates can’t tell you
&lt;/h2&gt;

&lt;p&gt;Two documents can carry identical, perfectly plausible &lt;code&gt;CreationDate&lt;/code&gt; and &lt;code&gt;ModDate&lt;/code&gt; values and still be nothing alike underneath. A bank statement generated once by an institutional system and a bank statement built from a template, with the balance changed and the dates manually reset to look consistent, can present the exact same two numbers in Properties. The dates report what the last piece of software &lt;em&gt;claimed&lt;/em&gt;. They carry no information about whether that claim is true.&lt;/p&gt;

&lt;p&gt;This is the gap between ‘checking a PDF’s dates’ and ‘verifying a PDF’s dates.’ Checking is reading the two fields. Verifying means asking whether the rest of the file’s structure — how many times it was actually saved, what software produced it, whether a signature or format conversion accounts for any gap — is consistent with the story the dates are telling. A forger who resets both fields to match still leaves the save history, the producer fingerprint, and the cross-reference structure largely as they found them. Rewriting those convincingly takes more than a metadata editor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gaps that are normal, and gaps that aren’t
&lt;/h2&gt;

&lt;p&gt;Not every difference between &lt;code&gt;CreationDate&lt;/code&gt; and &lt;code&gt;ModDate&lt;/code&gt; means someone tampered with the document. Several ordinary workflows produce a real, legitimate gap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Signing in the same sitting.&lt;/strong&gt; Open a document, sign it, save it. That whole sequence can land within a few seconds of &lt;code&gt;CreationDate&lt;/code&gt;, because applying a signature is itself a save operation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDF/A conversion.&lt;/strong&gt; Archival-format conversion rewrites the file’s structure even when the visible content doesn’t change, which updates &lt;code&gt;ModDate&lt;/code&gt;. The file genuinely was re-saved, and the conversion is a real reason for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch or scheduled report generation.&lt;/strong&gt; Some generation engines set &lt;code&gt;CreationDate&lt;/code&gt; when a document template or job is defined and only stamp &lt;code&gt;ModDate&lt;/code&gt; when the specific instance is rendered and saved. The gap reflects two pipeline stages, not anyone opening and editing content afterward.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A benign reason for a gap explains it; it doesn’t erase it. If a file was reopened and re-saved days or weeks after it was first created, for a signature or a re-export, that is a real event in the file’s history, and a structural check surfaces it as one. Whether the event matters for your workflow is your judgment call. The tool’s job is only to report that the write happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a structural check adds
&lt;/h2&gt;

&lt;p&gt;HTPBE’s &lt;a href="https://htpbe.tech/how" rel="noopener noreferrer"&gt;detection algorithm&lt;/a&gt; doesn’t stop at comparing two numbers. A few things it evaluates that a manual glance at Properties can’t:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why ordinary generation doesn’t trip this.&lt;/strong&gt; A generator may write &lt;code&gt;CreationDate&lt;/code&gt; when a document workflow starts and &lt;code&gt;ModDate&lt;/code&gt; when the render finishes. That is normal generation behavior, and the date analysis is built so it isn’t reported as a modification.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-checking the two metadata layers against each other.&lt;/strong&gt; When a PDF carries both an Info-dictionary date and an XMP date for the same event, they should normally describe the same moment. A disagreement usually means one layer was edited independently of the other — a partial edit that a manual check of one field misses entirely. That produces &lt;code&gt;HTPBE_TIMESTAMP_LAYERS_DISAGREE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catching physically impossible values.&lt;/strong&gt; A creation date decades before the PDF format existed, or a date meaningfully in the future, isn’t a normal software quirk. No generator legitimately produces it. That’s &lt;code&gt;HTPBE_IMPOSSIBLE_TIMESTAMP&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;HTPBE still checks the file’s broader structure first — how many times it was actually saved, what software last touched it — because a verdict rests on the whole file, not a single field. A full list of what each marker checks for is in the &lt;a href="https://htpbe.tech/how" rel="noopener noreferrer"&gt;forensic checks catalog&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading a structural result
&lt;/h2&gt;

&lt;p&gt;Submitting a PDF to the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;HTPBE API&lt;/a&gt; returns both raw date fields and the algorithm’s read on them as structured JSON. An abridged slice of the response, showing only the date-relevant fields:&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;"506a6b1b-1360-48a2-b389-abb346f85d04"&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;"certain"&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_DATES_DISAGREE"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"creation_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1704110400&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_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1707840000&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;"Adobe Acrobat Pro DC"&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;"Adobe PDF Library 15.0"&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;"Structural evidence of post-creation modification was found. Route this document to a human reviewer before any decision — do not use this verdict as the sole basis for an automatic adverse decision against a person."&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;&lt;code&gt;creation_date&lt;/code&gt; and &lt;code&gt;modification_date&lt;/code&gt; are Unix timestamps in seconds, here roughly 43 days apart – the file was written to long after it was created. &lt;code&gt;modification_confidence: "certain"&lt;/code&gt; reflects that this is consistent with a genuine post-creation write. &lt;code&gt;usage_caution&lt;/code&gt; is present on every response as a reminder that a file-level finding should route to review, not drive an automatic decision about the person who submitted it. The full response schema, including every field this endpoint returns, is documented at &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;&lt;code&gt;/api&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can a PDF’s creation date be changed after the fact?&lt;/strong&gt; Yes, trivially, with any of several free metadata-editing utilities or a hex editor. That’s why the date fields alone are a claim rather than evidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does printing a PDF or converting it change the dates?&lt;/strong&gt; Printing to a physical printer doesn’t touch the file’s metadata. Converting a file — re-saving from one format to another, running it through an optimizer — almost always updates &lt;code&gt;ModDate&lt;/code&gt;, because the conversion tool writes a new file. The file you’re looking at isn’t byte-for-byte the one that was first generated, and a structural check reports that write either way. ‘Print to PDF’ is a separate case: it produces a file with no PDF-editing history to find at all, and the honest verdict for that is &lt;code&gt;inconclusive&lt;/code&gt;, not a clean bill of health.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does my PDF show a creation date in the future?&lt;/strong&gt; A timestamp slightly ahead of the current time is usually clock drift on the machine that generated it. Hours or days out is a different matter. We cover this specific case in more depth in &lt;a href="https://htpbe.tech/blog/why-pdf-shows-future-creation-date" rel="noopener noreferrer"&gt;why does my PDF show a future creation date&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do matching dates prove a PDF wasn’t edited?&lt;/strong&gt; No. Some generators set both fields to the same value on export regardless of what happened before, and anyone editing a file with a metadata tool can reset both to match on purpose. Matching dates are consistent with an untouched file; they aren’t proof of one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I need the number of times a file was actually saved, not just whether the dates differ.&lt;/strong&gt; That’s a different question, answered by the file’s revision chain rather than its date fields. See &lt;a href="https://htpbe.tech/blog/how-to-view-pdf-edit-history" rel="noopener noreferrer"&gt;how to view a PDF’s edit history&lt;/a&gt; for how to read that separately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I want the full checklist, not just the date fields.&lt;/strong&gt; &lt;a href="https://htpbe.tech/blog/how-to-check-if-pdf-has-been-edited" rel="noopener noreferrer"&gt;Has this PDF been edited? Five ways to check&lt;/a&gt; covers metadata alongside visual inspection, signatures, and automated tools. This article stays on the date fields because they’re the check most guides get wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  A short checklist
&lt;/h2&gt;

&lt;p&gt;For a single file, right now:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the document properties (Acrobat, OS file properties, or a metadata viewer) and note both dates.&lt;/li&gt;
&lt;li&gt;If they match exactly, that’s consistent with an untouched file, not proof of one.&lt;/li&gt;
&lt;li&gt;If they differ, look for a plausible explanation — a same-sitting signature, a format conversion, a template-vs-instance report pipeline — before assuming the worst. The explanation doesn’t erase the write.&lt;/li&gt;
&lt;li&gt;For anything that needs a verdict rather than a guess — a loan application, a signed contract, a submitted invoice — run a structural check. Plausible-looking dates on a forged document prove nothing about the rest of the file; a structural check evaluates them alongside everything else the file’s bytes reveal.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of that scales to a queue of applications, contracts, or invoices, and a reviewer scanning Properties by hand won’t catch a date pair a forger reset to look clean. That’s a job for an API call. The &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;HTPBE API&lt;/a&gt; returns &lt;code&gt;creation_date&lt;/code&gt;, &lt;code&gt;modification_date&lt;/code&gt;, and the full &lt;code&gt;modification_markers&lt;/code&gt; array on every request. &lt;a href="https://htpbe.tech/auth/signup" rel="noopener noreferrer"&gt;Register&lt;/a&gt; for 5 free checks and a free test API key, so you can run the endpoint against synthetic documents before pointing anything at production files. The entry plan runs 30 checks a month from there, and &lt;a href="https://htpbe.tech/pricing" rel="noopener noreferrer"&gt;current pricing&lt;/a&gt; covers the higher tiers.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Build Against the VAT API Without Burning Quota</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Wed, 16 Sep 2026 09:00:43 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/build-against-the-vat-api-without-burning-quota-1bne</link>
      <guid>https://dev.to/iurii_rogulia/build-against-the-vat-api-without-burning-quota-1bne</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/vat-api-test-keys" 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;h1&gt;
  
  
  Build Against the VAT API Without Burning Quota
&lt;/h1&gt;

&lt;p&gt;Every vatnode account gets a test API key automatically, prefixed &lt;code&gt;vat_test_&lt;/code&gt; instead of &lt;code&gt;vat_live_&lt;/code&gt;. Point it at a reserved &lt;code&gt;XX&lt;/code&gt; VAT number and you get back a fixture response – no VIES call, no database write, no quota spent. That’s the whole mechanism. The rest of this post is what you can actually build with it.&lt;/p&gt;

&lt;p&gt;The API resolves the environment from the key itself, not from anything in the request body or URL. Send a &lt;code&gt;vat_test_&lt;/code&gt; key with any &lt;code&gt;GET /v1/vat/:vatId&lt;/code&gt; request and you’re in test mode for that call, full stop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a test key actually skips
&lt;/h2&gt;

&lt;p&gt;When the API sees a &lt;code&gt;vat_test_&lt;/code&gt; key, it short-circuits before doing any of the work a live request does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Format validation is skipped, except one check – the VAT ID has to start with &lt;code&gt;XX&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;No call to VIES or a national fallback is made.&lt;/li&gt;
&lt;li&gt;Nothing is written to your validation history (&lt;code&gt;vat_checks&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Your monthly quota counter is not incremented.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Be precise about that last point – ‘no effect on your data’ oversells it slightly. Test calls never count against your monthly quota and are never written to your validation history. The only record kept is a one-time timestamp of your first test call, used internally for onboarding – it doesn’t reflect the specific VAT number or result you tested. You won’t see individual test checks anywhere in your dashboard, and you can hammer the endpoint as hard as you like.&lt;/p&gt;

&lt;p&gt;Full reference, including the response schema, lives in &lt;a href="https://vatnode.dev/docs/test-mode" rel="noopener noreferrer"&gt;Test Mode&lt;/a&gt;. If you haven’t looked at how vatnode structures the seven documented error codes, &lt;a href="https://vatnode.dev/blog/handling-vies-errors-in-code" rel="noopener noreferrer"&gt;Handling VIES Errors in Code&lt;/a&gt; is the companion piece – the fixtures below are the fastest way to actually trigger those codes without waiting for a real outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The XX fixture numbers
&lt;/h2&gt;

&lt;p&gt;Test mode only accepts VAT numbers starting with &lt;code&gt;XX&lt;/code&gt; – a prefix reserved specifically because it can never collide with a real member-state format. Five specific numbers map to distinct scenarios; everything else in the &lt;code&gt;XX*&lt;/code&gt; space falls back to a default invalid result.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;VAT number&lt;/th&gt;
&lt;th&gt;HTTP&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;What it’s for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;XX0000001&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;valid: true&lt;/code&gt;, full company name + address&lt;/td&gt;
&lt;td&gt;Happy path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;XX0000002&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;valid: true&lt;/code&gt;, &lt;code&gt;companyName&lt;/code&gt;/&lt;code&gt;companyAddress&lt;/code&gt; null&lt;/td&gt;
&lt;td&gt;Valid VAT where VIES returns no company details (mirrors Germany)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;XX0000003&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;&lt;code&gt;valid: false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Not found / deregistered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;XX0000004&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;503 &lt;code&gt;VIES_UNAVAILABLE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;VIES member-state node down&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;XX0000005&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;502 &lt;code&gt;VIES_ERROR&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Unexpected upstream protocol fault&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;any other &lt;code&gt;XX*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;&lt;code&gt;valid: false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Default fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;non-&lt;code&gt;XX&lt;/code&gt; number&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;INVALID_FORMAT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Test mode rejects it – message tells you to use &lt;code&gt;XX&lt;/code&gt; numbers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A word on how close this is to a live response: the test response uses the same field names and types as a live response for every documented field. It won’t be byte-for-byte identical – &lt;code&gt;checkId&lt;/code&gt;, &lt;code&gt;verifiedAt&lt;/code&gt;, and &lt;code&gt;countryVat.countryVatUpdatedAt&lt;/code&gt; are generated fresh on each call, the same way they are live. What’s fixed per fixture is the verdict and the company fields, so you get deterministic verdicts to assert against without deterministic timestamps to mock around.&lt;/p&gt;

&lt;p&gt;One more detail that trips people up: fixture responses report &lt;code&gt;source: "VIES"&lt;/code&gt; so your source-handling logic gets exercised the same way it would on a live VIES answer – but no call to VIES is made. Treat it as a label for your parser, not as evidence a lookup happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Happy path
&lt;/h2&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/XX0000001 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer vat_test_your_test_key"&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;"XX0000001"&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;"XX"&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;"Test Country"&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;"Test Company Ltd"&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 Test Street, Test City, TC1 0AA"&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;"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-09-09T10: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;"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;"019d2a89-a5d9-7b97-b710-57b84604de2b"&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;consultationNumber&lt;/code&gt; is always &lt;code&gt;null&lt;/code&gt; in test mode, on every fixture, regardless of what requester VAT number is set on your account. It’s only ever populated on a live VIES consultation.&lt;/p&gt;

&lt;p&gt;And the same call in TypeScript:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkVat&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;apiKey&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="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="s2"&gt;`https://api.vatnode.dev/v1/vat/&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="s2"&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;apiKey&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;`&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="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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;return&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="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="nf"&gt;checkVat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;XX0000001&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;vat_test_your_test_key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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;valid&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;companyName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Wiring the fixtures into a test suite
&lt;/h2&gt;

&lt;p&gt;The five scenarios map cleanly onto assertions. This is the pattern to lift into your own integration tests – no mocking, no fixtures of your own to maintain, no live key required in CI:&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;TEST_KEY&lt;/span&gt; &lt;span class="o"&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_TEST_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="c1"&gt;// vat_test_...&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkVat&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="p"&gt;{&lt;/span&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="s2"&gt;`https://api.vatnode.dev/v1/vat/&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="s2"&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;TEST_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;return&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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&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;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="p"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;valid VAT with full company details&lt;/span&gt;&lt;span class="dl"&gt;'&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkVat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;XX0000001&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;status&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="mi"&gt;200&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&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="kc"&gt;true&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;companyName&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="s1"&gt;Test Company Ltd&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;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;valid VAT with no company details on file&lt;/span&gt;&lt;span class="dl"&gt;'&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkVat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;XX0000002&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;status&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="mi"&gt;200&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&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="kc"&gt;true&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;companyName&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeNull&lt;/span&gt;&lt;span class="p"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;deregistered VAT number&lt;/span&gt;&lt;span class="dl"&gt;'&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkVat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;XX0000003&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;status&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="mi"&gt;200&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&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="kc"&gt;false&lt;/span&gt;&lt;span class="p"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;VIES unavailable degrades, does not fail closed&lt;/span&gt;&lt;span class="dl"&gt;'&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkVat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;XX0000004&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;status&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="mi"&gt;503&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;body&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="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="s1"&gt;VIES_UNAVAILABLE&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;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;VIES protocol error&lt;/span&gt;&lt;span class="dl"&gt;'&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkVat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;XX0000005&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;status&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="mi"&gt;502&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;body&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="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="s1"&gt;VIES_ERROR&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;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;non-XX number rejected in test mode&lt;/span&gt;&lt;span class="dl"&gt;'&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkVat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DE143454214&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;status&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="mi"&gt;400&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;body&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="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="s1"&gt;INVALID_FORMAT&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that suite against &lt;code&gt;XX0000004&lt;/code&gt; and &lt;code&gt;XX0000005&lt;/code&gt; and you’ve exercised the exact retry/degrade branches from &lt;a href="https://vatnode.dev/blog/handling-vies-errors-in-code" rel="noopener noreferrer"&gt;Handling VIES Errors in Code&lt;/a&gt; – &lt;code&gt;503&lt;/code&gt; and &lt;code&gt;502&lt;/code&gt; – without depending on VIES actually being down when your CI runs. The full error vocabulary, including the codes test mode can’t reach (&lt;code&gt;RATE_LIMITED&lt;/code&gt;, &lt;code&gt;INVALID_REQUESTER&lt;/code&gt;, &lt;code&gt;UPSTREAM_TIMEOUT&lt;/code&gt;, &lt;code&gt;INTERNAL_ERROR&lt;/code&gt;), is in &lt;a href="https://vatnode.dev/docs/errors" rel="noopener noreferrer"&gt;Errors&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bulk endpoint runs the same fixtures through a real job
&lt;/h2&gt;

&lt;p&gt;If you’re validating in batches, &lt;a href="https://vatnode.dev/docs/bulk-vat-validation" rel="noopener noreferrer"&gt;Bulk VAT Validation&lt;/a&gt; accepts test keys too, and the same &lt;code&gt;XX*&lt;/code&gt; fixtures apply per position. A test job is a real job – same &lt;code&gt;jobId&lt;/code&gt;, same submit → poll → page contract a live job uses, just paid for in fixtures instead of quota. You can build and test your entire polling and pagination consumer, including per-position error branching, before a live VAT ID ever touches the endpoint.&lt;/p&gt;

&lt;p&gt;Submitting is the same as with a live key – &lt;code&gt;202&lt;/code&gt;, nothing validated during the request:&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.vatnode.dev/v1/vat/bulk &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer vat_test_your_test_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;'{"vatIds": ["XX0000001", "XX0000004", "DE12345"]}'&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;"jobId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0f8b1d64-4a1e-4a5e-9f4a-2b1c8d3e5f60"&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;"queued"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"totalItems"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"duplicatesDropped"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"createdAt"&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-09-09T10:00:00.000Z"&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;A test job runs every position at once rather than at the throttled concurrency a live job uses, so by the time your first &lt;code&gt;GET&lt;/code&gt; lands it’s usually already &lt;code&gt;finished&lt;/code&gt;. Page through the results the same way you would for a live job:&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="s2"&gt;"https://api.vatnode.dev/v1/vat/bulk/0f8b1d64-4a1e-4a5e-9f4a-2b1c8d3e5f60/results?page=1&amp;amp;limit=100"&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;"Authorization: Bearer vat_test_your_test_key"&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;"items"&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"position"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&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;"XX0000001"&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;"done"&lt;/span&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;"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;"checkId"&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;"error"&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;"processedAt"&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-09-09T10:00:00.400Z"&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;span class="nl"&gt;"position"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&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;"XX0000004"&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;"done"&lt;/span&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;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;"source"&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;"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;"checkId"&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;"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;"VIES_UNAVAILABLE"&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;"VIES service is temporarily unavailable"&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;"processedAt"&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-09-09T10:00:00.410Z"&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;span class="nl"&gt;"position"&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;"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;"DE12345"&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;"done"&lt;/span&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;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;"source"&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;"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;"checkId"&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;"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;"Test mode only accepts XX VAT numbers (e.g. XX0000001). Use your live key for real VAT IDs."&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;"processedAt"&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-09-09T10:00:00.415Z"&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;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&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;XX0000001&lt;/code&gt; comes back with &lt;code&gt;checkId: null&lt;/code&gt; – same as every test-mode result, single or bulk: no quota spent, no VIES call, no stored check.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;XX0000004&lt;/code&gt; and &lt;code&gt;XX0000005&lt;/code&gt; reproduce the same &lt;code&gt;VIES_UNAVAILABLE&lt;/code&gt; and &lt;code&gt;VIES_ERROR&lt;/code&gt; codes the single endpoint returns as an HTTP status, except a bulk position doesn’t carry an HTTP status of its own at all. The job’s own calls (submit, poll, page) stay in the 200s regardless of what happened to any individual position; the outcome lives entirely in that position’s &lt;code&gt;error&lt;/code&gt; object.&lt;/p&gt;

&lt;p&gt;A non-&lt;code&gt;XX&lt;/code&gt; entry like &lt;code&gt;DE12345&lt;/code&gt; becomes an accepted position carrying &lt;code&gt;INVALID_FORMAT&lt;/code&gt; in &lt;code&gt;error&lt;/code&gt;, not a 400 that would reject the whole submission.&lt;/p&gt;

&lt;p&gt;Duplicate &lt;code&gt;vatIds&lt;/code&gt; in the same batch are deduplicated on intake, test key or live – send &lt;code&gt;XX0000001&lt;/code&gt; three times and it becomes one position, counted in &lt;code&gt;duplicatesDropped&lt;/code&gt; on the &lt;code&gt;202&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The position shape is leaner than the single endpoint here too – no &lt;code&gt;companyName&lt;/code&gt; or &lt;code&gt;companyAddress&lt;/code&gt;. Follow up with &lt;code&gt;GET /v1/vat/:vatId&lt;/code&gt; if you need company details for a specific item.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where test keys don’t work
&lt;/h2&gt;

&lt;p&gt;Test mode covers the VAT-check endpoints – single and bulk – and nothing else. Subscriptions and webhooks reject a &lt;code&gt;vat_test_&lt;/code&gt; key with &lt;code&gt;403 TEST_KEY_NOT_ALLOWED&lt;/code&gt;, because those act on real VAT numbers and deliver real HTTP callbacks to your endpoint. There’s no sandbox version of ‘revalidate this customer monthly’ or ‘POST a signed payload to your server’ – both are live-key-only. Build and test the validation call against fixtures; switch to a &lt;code&gt;vat_live_&lt;/code&gt; key before you wire up monitoring or webhook delivery.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does using a vatnode test key ever hit the real VIES service?
&lt;/h3&gt;

&lt;p&gt;No. A request authenticated with a &lt;code&gt;vat_test_&lt;/code&gt; key short-circuits before any network call – VIES and every national fallback are skipped entirely. The response comes from a hardcoded fixture, not a lookup.&lt;/p&gt;

&lt;h3&gt;
  
  
  What VAT numbers work in test mode, and why do they have to start with XX?
&lt;/h3&gt;

&lt;p&gt;Only numbers starting with XX. XX is a reserved prefix that can never match a real member-state VAT number, so it can’t be confused with production data. Five specific XX numbers (XX0000001 through XX0000005) trigger distinct scenarios; any other &lt;code&gt;XX*&lt;/code&gt; number returns the default invalid result. A non-XX number with a test key returns 400 INVALID_FORMAT on the single endpoint; in a bulk job, the same rejection is a per-position INVALID_FORMAT error on an otherwise-accepted job, not a 400 for the whole submission.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will test-mode requests count against my monthly quota or plan limits?
&lt;/h3&gt;

&lt;p&gt;No. Test calls never count against your monthly quota and are never written to your validation history. The only record kept is a one-time timestamp of your first test call, used internally for onboarding – it doesn’t reflect the specific VAT number or result you tested.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I get a consultationNumber back from a test-mode check?
&lt;/h3&gt;

&lt;p&gt;No. consultationNumber is always null in test mode, on every fixture, regardless of whether your account has a requester VAT number configured. It’s only ever populated on a live VIES consultation.&lt;/p&gt;

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

&lt;p&gt;Every account already has a test key sitting in your dashboard’s API Keys section – you don’t need to request one or wait for approval. Point your integration at the &lt;a href="https://vatnode.dev/vat-api" rel="noopener noreferrer"&gt;VAT API&lt;/a&gt; or the &lt;a href="https://vatnode.dev/vies-api" rel="noopener noreferrer"&gt;VIES API&lt;/a&gt; reference for the full field list, build against the &lt;code&gt;XX&lt;/code&gt; fixtures above, and swap in a live key once your error handling passes.&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  Get a test key and a live key from the same account
&lt;/h3&gt;

&lt;p&gt;Sign up and vatnode creates your &lt;code&gt;vat_test_&lt;/code&gt; key automatically, ready to hit the fixtures above. The &lt;a href="https://vatnode.dev/docs" rel="noopener noreferrer"&gt;full API reference&lt;/a&gt; covers what changes when you switch to a live key.&lt;/p&gt;

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

</description>
      <category>tax</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Custom Software vs Off-the-Shelf: When to Build Your Own</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 14 Sep 2026 10:01:01 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/custom-software-vs-off-the-shelf-when-to-build-your-own-55ch</link>
      <guid>https://dev.to/iurii_rogulia/custom-software-vs-off-the-shelf-when-to-build-your-own-55ch</guid>
      <description>&lt;p&gt;Most businesses that ask me whether they should build custom software should not build custom software. They should use Shopify, or HubSpot, or whatever off-the-shelf platform fits their category, and spend the money on something else.&lt;/p&gt;

&lt;p&gt;I say this as someone whose actual work is building custom software. It’s still true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Off-the-Shelf Is the Right Default
&lt;/h2&gt;

&lt;p&gt;A platform like Shopify represents thousands of engineering years you don’t have to pay for. Payments, tax, hosting, security patches, checkout that converts, an app ecosystem for the common cases – all of it maintained by someone else, forever, for a monthly fee that’s a rounding error compared to a development budget.&lt;/p&gt;

&lt;p&gt;When you build custom, you own all of that. Not just the first version – the maintenance, the security updates, the edge cases you didn’t think of, the thing that breaks at the worst possible time. Custom software is not a purchase. It’s a commitment.&lt;/p&gt;

&lt;p&gt;So the honest default is: start off-the-shelf. If a platform can run your business, let it. The businesses that jump to custom too early usually end up with something more expensive and more fragile than the thing they were trying to escape, and they still have to hire someone to keep it alive.&lt;/p&gt;

&lt;p&gt;The question is not ‘is custom better.’ Custom is not better. The question is whether the platform is standing between you and the way your business actually works – and whether that’s costing you real money.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Signal That Actually Matters
&lt;/h2&gt;

&lt;p&gt;There’s one signal that separates ‘you’re using the platform wrong’ from ‘you’ve outgrown the platform’: &lt;strong&gt;the platform blocks your business logic, not your styling.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Wanting a different colour scheme is not outgrowing the platform. Wanting a slightly different checkout layout is not outgrowing the platform. Those are configuration problems, and a platform that can’t handle them is rare.&lt;/p&gt;

&lt;p&gt;You’ve outgrown the platform when the thing it can’t do is the thing your business is actually built on. When the way you make money doesn’t fit the shape the platform expects – and no amount of apps, plugins, or paid tiers closes the gap.&lt;/p&gt;

&lt;p&gt;Here’s what that looks like in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the Platform Blocks the Business
&lt;/h2&gt;

&lt;p&gt;A Finnish manufacturer I worked with, &lt;a href="https://iurii.rogulia.fi/projects/pikkuna-ecommerce-platform" rel="noopener noreferrer"&gt;Pikkuna&lt;/a&gt;, sells made-to-order vinyl curtains and PVC products. Not fixed SKUs – products where the customer chooses dimensions, material, and mounting options, and the price recalculates from those choices in real time.&lt;/p&gt;

&lt;p&gt;Shopify was ruled out early, and not because anyone disliked it. It could not handle the product configurators the business was built on. A store that sells ‘product A for €40’ is exactly what off-the-shelf platforms are designed for. A store that sells ‘your dimensions, your material, priced live as you choose’ is not a styling problem. It’s the core of the business, and it fell outside what the platform could do.&lt;/p&gt;

&lt;p&gt;That alone might have been survivable. The second problem was not. Every order was being processed by hand: confirm the payment, create the deal in the CRM, log the accounting entry, book the shipment, generate a PDF invoice, email it to the customer. Thirty minutes of manual work, spread across four separate tools, repeated for every single order.&lt;/p&gt;

&lt;p&gt;At a dozen orders a week, a person can do that. The business was about to expand across Europe – 32 countries, a different VAT rate for every market. The manual flow that worked locally would not survive that. It wasn’t a cost you could absorb; it was a wall.&lt;/p&gt;

&lt;p&gt;So we built custom. The manual order flow became a single automated pipeline: payment confirmed, and the CRM, accounting, shipping, invoice, and customer email all happen without anyone touching them. Thirty minutes became under two. The configurators the platform couldn’t do became the front door of the store.&lt;/p&gt;

&lt;p&gt;There’s a part the ‘custom is a last resort’ framing gets wrong, though. Once you’re on the right side of that line, custom stops being a compromise you settle for and becomes a lever. Pikkuna’s store runs exactly the way the business runs – not the way a platform assumed it should. The configurator Shopify couldn’t model is the thing customers come for. The order pipeline nobody touches is a cost that stays flat while volume climbs. A competitor running the same business off-the-shelf is capped at whatever that platform allows; Pikkuna isn’t. That’s not damage control – it’s an edge you can only get by building the software around the business instead of bending the business around the software.&lt;/p&gt;

&lt;p&gt;That’s the shape of a real ‘build custom’ decision. Not ‘we wanted more control.’ Specific business logic – real configurators, per-market VAT, an order flow spanning four tools – that the platform structurally could not do, on a scale where doing it by hand would have broken the business.&lt;/p&gt;

&lt;p&gt;
  slug="e-commerce"&lt;br&gt;
  text="I build custom e-commerce and business platforms for companies that have hit a real wall with off-the-shelf – configurators, multi-market VAT, automated order flows across CRM, accounting and shipping. If you’re not there yet, I’ll tell you that too."&lt;br&gt;
/&amp;gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Signals, Made Concrete
&lt;/h2&gt;

&lt;p&gt;You don’t need a Pikkuna-scale expansion to justify custom. You need at least one of these to be true, and true about your core business, not your wishlist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The platform can’t model what you sell.&lt;/strong&gt; Configurable products, real-time custom pricing, quotes instead of fixed prices, bundles the app store can’t express. If the way you price and package is fundamentally not ‘product × quantity,’ you’re fighting the platform.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your business logic spans tools it can’t cross.&lt;/strong&gt; The moment an order touches your CRM, your accounting system, your shipping provider, and your invoicing – and the platform expects each of those to be a separate manual step – you’re paying people to be the integration. &lt;a href="https://iurii.rogulia.fi/blog/ecommerce-order-automation" rel="noopener noreferrer"&gt;Automating a flow like that&lt;/a&gt; is often where custom pays for itself first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance is a first-class part of the sale.&lt;/strong&gt; Per-market VAT, VIES validation for B2B buyers, reverse-charge invoicing, region-specific payment methods. On the &lt;a href="https://iurii.rogulia.fi/projects/pi-pi-b2b-ecommerce" rel="noopener noreferrer"&gt;pi-pi.ee&lt;/a&gt; B2B platform, a German buyer expects their VAT number validated against the EU registry and the correct reverse-charge notation on the invoice – automatically. Platforms treat that as an edge case. For some businesses it’s the whole transaction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You’re paying enterprise pricing to work around the platform.&lt;/strong&gt; Shopify Plus and its equivalents can do a lot, at enterprise pricing that runs from roughly €25,000 a year well into six figures at scale – plus bespoke development for everything they still can’t do natively. When the platform tier is expensive &lt;em&gt;and&lt;/em&gt; you’re still building around it, the ‘cheaper’ option stopped being cheaper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The workaround has become the process.&lt;/strong&gt; A spreadsheet three people edit at once. A manual copy-paste step nobody’s allowed to skip. A ‘temporary’ fix from two years ago that’s now load-bearing. If &lt;a href="https://iurii.rogulia.fi/blog/who-owns-your-it" rel="noopener noreferrer"&gt;nobody actually owns&lt;/a&gt; the glue holding your systems together, that’s a risk, not a system.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If none of these describe you, you probably don’t need custom software. That’s not a disappointing answer. It’s a saved budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Honest Version of This Decision
&lt;/h2&gt;

&lt;p&gt;There’s a version of this conversation where a developer tells you that yes, you absolutely need custom software, and quotes you for it. Of course they do – that’s what they sell.&lt;/p&gt;

&lt;p&gt;The version I try to have is different. Sometimes the right answer is ‘keep using Shopify, you’re not using half of what you already pay for.’ Sometimes it’s ‘you need one integration, not a rebuild.’ And sometimes it’s ‘the platform genuinely can’t do the thing your business runs on, and every month you spend working around it is money and risk you’re absorbing quietly.’&lt;/p&gt;

&lt;p&gt;Telling those three situations apart is the actual job. It’s closer to &lt;a href="https://iurii.rogulia.fi/blog/developer-vs-it-partner" rel="noopener noreferrer"&gt;what an IT partner does than what a developer does&lt;/a&gt; – asking what your business actually needs before anyone quotes a build. A build that solves a configuration problem is worse than the configuration you started with. A platform that can’t run your business is a ceiling you’ll keep hitting until you deal with it.&lt;/p&gt;

&lt;p&gt;Custom software development is worth it exactly when the platform is the thing in your way – and not one day before.&lt;/p&gt;




&lt;p&gt;If you’re weighing a platform against a custom build and you’re not sure which side of that line you’re on, &lt;a href="https://iurii.rogulia.fi/contact" rel="noopener noreferrer"&gt;let’s figure that out before you commit to either&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>business</category>
      <category>ecommerce</category>
      <category>itstrategy</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How to View a PDF's Edit History: Reading the Timeline</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 14 Sep 2026 10:00:44 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/how-to-view-a-pdfs-edit-history-reading-the-timeline-20bf</link>
      <guid>https://dev.to/iurii_rogulia/how-to-view-a-pdfs-edit-history-reading-the-timeline-20bf</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/how-to-view-pdf-edit-history" 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;‘Was this PDF edited?’ has a yes/no answer. ‘What is this PDF’s edit history?’ is a different, more useful question – it asks for a timeline: how many times was the file saved after it was created, and what changed in each pass. Most guides answer the first question. This one answers the second.&lt;/p&gt;

&lt;p&gt;You don’t need forensic software to get a partial answer, and you don’t need to guess either. A PDF’s modification history is a real, structural thing – not a log file, but a chain of save events the format itself is built to preserve. This guide covers where that history lives, how to read it by hand, and how to read it properly with a structural analysis tool when a glance at two dates isn’t enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ‘edit history’ means for a PDF
&lt;/h2&gt;

&lt;p&gt;A PDF isn’t one solid block of text the way a &lt;code&gt;.docx&lt;/code&gt; file behaves in casual use. It’s a set of numbered objects – pages, fonts, images, the metadata dictionary – written into the file, plus an index that tells a PDF reader where to find each one. When most PDF software saves changes to an existing file, it doesn’t rewrite that index from scratch. It appends: new objects go on the end of the file, followed by a new copy of the index, followed by a pointer back to the previous index. Save the file three times and you get three of these index sections chained together, each one pointing to the one before it.&lt;/p&gt;

&lt;p&gt;That chain is the closest thing a PDF has to a built-in edit history. It’s not a list of ‘changed line 4’ the way source-control history is – but it does tell you, unambiguously, how many times the file was saved after it was first created, and roughly which objects changed in each session. We cover the byte-level mechanics of that chain – what the sections actually look like, how the pointers work – in &lt;a href="https://htpbe.tech/blog/pdf-xref-table-forensics" rel="noopener noreferrer"&gt;PDF xref table forensics&lt;/a&gt;. This article is about reading the resulting timeline, not building it from scratch.&lt;/p&gt;

&lt;p&gt;Two things worth being precise about before going further:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;‘Edit’ here means ‘saved a change to the file after it existed.’&lt;/strong&gt; That includes malicious edits, but also routine ones – adding a signature, filling a form field, running the file through a compressor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The timeline tells you &lt;em&gt;that&lt;/em&gt; something happened and roughly &lt;em&gt;what object&lt;/em&gt; changed, not &lt;em&gt;why&lt;/em&gt;.&lt;/strong&gt; Reading intent – was this a legitimate correction or a forged balance – takes context the file itself doesn’t carry.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The quick manual check: dates and metadata
&lt;/h2&gt;

&lt;p&gt;Every PDF stores a &lt;code&gt;CreationDate&lt;/code&gt; and a &lt;code&gt;ModDate&lt;/code&gt; in its Info dictionary (and often duplicates in embedded XMP metadata). Comparing them is the fastest – and weakest – way to spot activity:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open the file in Adobe Acrobat Reader and check File → Properties, or right-click the file in Finder/Explorer and check its properties.&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;Created&lt;/code&gt; and &lt;code&gt;Modified&lt;/code&gt; match, the file has probably only been through one save session.&lt;/li&gt;
&lt;li&gt;If they differ, something happened after creation – but that’s all a date comparison tells you. It doesn’t say how many times, in what order, or which parts of the document.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two problems make this check unreliable on its own. First, some PDF generators set both dates identically even after real edits, so a match isn’t proof of an unedited file. Second, and more relevant to a genuine history: a single modification date collapses every save session into one number. A file saved five times after creation and a file saved once look identical if you’re only reading the date field. If you want the actual count of sessions – the timeline itself – the date fields are the wrong place to look. The structural chain described below is.&lt;/p&gt;

&lt;p&gt;Metadata gives one more clue worth checking while you’re in there: the &lt;code&gt;Creator&lt;/code&gt; and &lt;code&gt;Producer&lt;/code&gt; fields. &lt;code&gt;Creator&lt;/code&gt; names the authoring application; &lt;code&gt;Producer&lt;/code&gt; names whatever software last wrote the PDF bytes. A mismatch – say, &lt;code&gt;Creator: Microsoft Word&lt;/code&gt; next to &lt;code&gt;Producer: iLovePDF&lt;/code&gt; – is consistent with the file having passed through a second tool after leaving Word. It’s a hint, not a timeline entry: it tells you a tool touched the file, not when or how many times. Our free &lt;a href="https://htpbe.tech/pdf-metadata-viewer" rel="noopener noreferrer"&gt;PDF metadata viewer&lt;/a&gt; dumps every field from a file without an account, if you want to see this for yourself on a specific document.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the real timeline lives: the revision chain
&lt;/h2&gt;

&lt;p&gt;The structural chain described above – the append-only index sections – is where an actual count of save sessions lives. You can approximate reading it yourself with a text editor on a small file: open the PDF in a plain-text or hex editor, search for the literal string &lt;code&gt;%%EOF&lt;/code&gt;. Each occurrence marks the end of one saved revision. One &lt;code&gt;%%EOF&lt;/code&gt; means one session – the file as originally exported, untouched since. Two or three means the file was saved again, and again, after that.&lt;/p&gt;

&lt;p&gt;This works in principle but gets impractical fast. Larger PDFs, PDFs using the newer binary xref-stream format (common from PDF 1.5 onward), and PDFs where a viewer displays the file differently than its raw bytes are all real complications a manual &lt;code&gt;%%EOF&lt;/code&gt; count runs into. It also doesn’t tell you &lt;em&gt;which&lt;/em&gt; objects changed in each session – did session two touch the invoice total, or just add a digital signature? Answering that requires actually parsing the chain, not eyeballing markers in a text editor.&lt;/p&gt;

&lt;p&gt;This is the part a structural analysis tool is built for: walking the chain, counting the sessions, and reporting which objects were touched in each one – turning ‘the file has three &lt;code&gt;%%EOF&lt;/code&gt; markers’ into ‘the file was created, then re-saved once with only the Info dictionary changed, then re-saved again with page content changed.’&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the timeline from an API response
&lt;/h2&gt;

&lt;p&gt;Submitting a PDF to the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;HTPBE API&lt;/a&gt; returns the revision chain as structured data instead of raw bytes to interpret by hand. A relevant slice of the response:&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;"ck_7b3e1a09-..."&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;"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_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;"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;3&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;"update_chain_length"&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;"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;"Microsoft Word"&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;"iLovePDF"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"creation_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1704067200&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_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1709251200&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;Read as a timeline, this says: the file has been through &lt;strong&gt;three&lt;/strong&gt; save sessions (&lt;code&gt;xref_count: 3&lt;/code&gt; – the original export plus two later saves, so &lt;code&gt;update_chain_length: 2&lt;/code&gt;). &lt;code&gt;has_incremental_updates: true&lt;/code&gt; confirms at least one of those later saves happened. The &lt;code&gt;creator&lt;/code&gt;/&lt;code&gt;producer&lt;/code&gt; mismatch – authored in Word, last touched by iLovePDF – tells you the &lt;em&gt;most recent&lt;/em&gt; session was written by a different tool than the one that created the file. It doesn’t by itself tell you whether both later sessions came from iLovePDF or from two different tools; &lt;code&gt;producer&lt;/code&gt; only records whoever wrote the file last, not a log of every tool that touched it along the way.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;modification_markers&lt;/code&gt; is where the tool names &lt;em&gt;why&lt;/em&gt; it reached a verdict, not just &lt;em&gt;what&lt;/em&gt; the chain looks like – &lt;code&gt;HTPBE_MULTIPLE_REVISION_LAYERS&lt;/code&gt; here means the multi-session chain was the signal that drove the &lt;code&gt;modified&lt;/code&gt; status. The full list of what each marker means is in &lt;a href="https://htpbe.tech/how" rel="noopener noreferrer"&gt;the forensic checks catalog&lt;/a&gt;; the &lt;a href="https://htpbe.tech/blog/pdf-xref-table-forensics" rel="noopener noreferrer"&gt;xref table forensics article&lt;/a&gt; walks through how the verdict engine weighs xref data against the other signals it checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ‘which session changed what’ actually gives you
&lt;/h2&gt;

&lt;p&gt;A revision count on its own – ‘this file was saved three times’ – is a coarse signal. A signed contract that picked up two routine re-saves (a signature, a form-field fill) and a bank statement that was opened once in an editor to change a balance can both show &lt;code&gt;update_chain_length: 2&lt;/code&gt;. The count alone doesn’t separate them.&lt;/p&gt;

&lt;p&gt;What does is knowing &lt;em&gt;which&lt;/em&gt; object changed in which session. Each save in the chain only touches the objects that were actually edited – the rest of the file’s objects stay untouched, referenced from earlier in the chain. A session that only rewrote the Info dictionary (metadata) reads very differently from a session that rewrote a page’s content stream (the visible text and layout). The first is consistent with something like re-saving after a metadata cleanup; the second is consistent with the document’s visible content actually changing. This is the level of detail a manual &lt;code&gt;%%EOF&lt;/code&gt; count can’t reach – counting markers tells you &lt;em&gt;how many&lt;/em&gt; sessions happened, not &lt;em&gt;what&lt;/em&gt; happened in each one – and it’s the reason structural analysis reads the chain object-by-object rather than just counting its sections.&lt;/p&gt;

&lt;p&gt;Fetching this for a file you have a URL for:&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/documents/contract.pdf"}'&lt;/span&gt;

&lt;span class="c"&gt;# { "id": "ck_7b3e1a09-..." }&lt;/span&gt;

curl https://api.htpbe.tech/v1/result/ck_7b3e1a09-... &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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second call returns the full object above, including &lt;code&gt;update_chain_length&lt;/code&gt; and &lt;code&gt;modification_markers&lt;/code&gt;. Building a document intake pipeline that needs to log a revision count for every file that comes through – loan applications, signed contracts, submitted invoices – is a matter of persisting those two fields alongside whatever else you already store per document.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the timeline isn’t there to read
&lt;/h2&gt;

&lt;p&gt;A revision chain only exists if the file has one to show. Two situations erase it entirely, and it’s worth knowing both before you treat an empty timeline as proof of an untouched file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The document was created in consumer software.&lt;/strong&gt; Word, Google Docs, a phone’s ‘print to PDF,’ and similar tools produce a single, clean revision chain even for a file assembled specifically to deceive – because no PDF editing ever happened. Someone who changes a number directly in the source document before exporting to PDF leaves no trace in the chain; the export itself is the only save event that exists. This is exactly the case HTPBE’s &lt;code&gt;inconclusive&lt;/code&gt; verdict is for: not a failure to analyze the file, but an honest statement that the file’s origin makes structural history unavailable. It’s covered in more depth 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; means&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The file was rebuilt rather than incrementally saved.&lt;/strong&gt; Tools like Ghostscript, &lt;code&gt;qpdf&lt;/code&gt;, and most ‘flatten’ or ‘optimize’ operations don’t append to the existing chain – they output a fresh, single-revision file from scratch. If a document was edited and then run through one of these tools before being sent to you, the multi-session history that edit created is gone. The modification happened; the timeline evidence of it did not survive the rebuild.&lt;/p&gt;

&lt;p&gt;Neither case means a tool is broken or a check failed. They mean the timeline itself doesn’t carry the answer for that particular file, and a verdict built on top of missing structural evidence needs to say so rather than guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  A short checklist
&lt;/h2&gt;

&lt;p&gt;For a single file, right now:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check the &lt;code&gt;Created&lt;/code&gt;/&lt;code&gt;Modified&lt;/code&gt; dates in the document properties – a mismatch means &lt;em&gt;something&lt;/em&gt; happened, nothing more specific.&lt;/li&gt;
&lt;li&gt;Pull the &lt;code&gt;Creator&lt;/code&gt;/&lt;code&gt;Producer&lt;/code&gt; metadata – a mismatch between the two is consistent with the file having passed through a second tool.&lt;/li&gt;
&lt;li&gt;If you need the actual session count rather than a date guess, run the file through a structural analysis tool that reads the revision chain directly, rather than counting &lt;code&gt;%%EOF&lt;/code&gt; markers by hand.&lt;/li&gt;
&lt;li&gt;Treat &lt;code&gt;inconclusive&lt;/code&gt; results as ‘this file’s origin doesn’t preserve a structural history,’ not as an error.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For checking PDFs in volume – a loan queue, an AP inbox, signed-contract intake – steps 1–2 don’t scale and step 3 needs to happen automatically. The &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;HTPBE API&lt;/a&gt; returns &lt;code&gt;xref_count&lt;/code&gt;, &lt;code&gt;update_chain_length&lt;/code&gt;, and the full &lt;code&gt;modification_markers&lt;/code&gt; array on every analysis call, and the entry plan covers 30 checks a month; see &lt;a href="https://htpbe.tech/pricing" rel="noopener noreferrer"&gt;current pricing&lt;/a&gt; for the higher tiers. You can pull your first result with a &lt;a href="https://htpbe.tech/auth/signup" rel="noopener noreferrer"&gt;free test API key&lt;/a&gt; against synthetic documents before pointing it at production files.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Validate VAT Numbers at Checkout, Not Just at Signup</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 14 Sep 2026 09:00:47 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/validate-vat-numbers-at-checkout-not-just-at-signup-48fj</link>
      <guid>https://dev.to/iurii_rogulia/validate-vat-numbers-at-checkout-not-just-at-signup-48fj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/validate-vat-numbers-at-checkout" 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;h1&gt;
  
  
  Validate VAT Numbers at Checkout, Not Just at Signup
&lt;/h1&gt;

&lt;p&gt;A VAT ID field at checkout has one job: help you decide, before payment, whether this specific order should carry VAT or not. That’s a different problem from validating a VAT ID at &lt;a href="https://vatnode.dev/blog/validate-vat-numbers-at-signup" rel="noopener noreferrer"&gt;signup&lt;/a&gt; – signup is about a stored customer attribute checked once, off the critical path of account creation. Checkout is about a single order total, computed live, often for a guest who has no account and never will – the cart-level eligibility decision this post is about.&lt;/p&gt;

&lt;p&gt;The scope is EU-27 plus XI (Northern Ireland – goods only via VIES), intra-Community B2B only – no OSS, no distance-selling thresholds, no VAT calculation or filing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the VAT ID field at checkout is actually deciding
&lt;/h2&gt;

&lt;p&gt;A VAT ID entered at checkout is evidence for one of two different legal questions, depending on what’s in the cart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Goods.&lt;/strong&gt; Since the 2020 Quick Fixes, a valid buyer VAT ID – plus a correct entry on your EC Sales List – is a &lt;strong&gt;substantive condition&lt;/strong&gt; for zero-rating an intra-Community supply of goods (&lt;a href="https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32018L0910" rel="noopener noreferrer"&gt;Council Directive (EU) 2018/1910&lt;/a&gt;, amending Art 138 of the &lt;a href="https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:02006L0112-20240101" rel="noopener noreferrer"&gt;VAT Directive&lt;/a&gt;). This is exemption, not reverse charge – the terms aren’t interchangeable. Art 138(1a) is rebuttable (‘unless the supplier can duly justify his shortcoming’), so a missing or invalid VAT ID isn’t necessarily fatal to the treatment, but it removes your cleanest path to it. And a valid VAT ID alone never finishes the job for goods: the items still have to physically leave the dispatch member state, with transport evidence – the rebuttable presumption mechanism is &lt;a href="https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32011R0282" rel="noopener noreferrer"&gt;Art 45a of Implementing Regulation 282/2011&lt;/a&gt;, inserted by Reg (EU) 2018/1912.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Services.&lt;/strong&gt; This is the actual reverse charge: the place of supply moves to the customer (Art 44) and the customer self-accounts for the VAT (Art 196). Your usable basis for treating the customer as a taxable person is &lt;a href="https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32011R0282" rel="noopener noreferrer"&gt;Implementing Regulation (EU) 282/2011 Art 18(1)(a)&lt;/a&gt;: the customer communicated a VAT ID, and you obtained confirmation of its validity &lt;strong&gt;and its associated name and address&lt;/strong&gt;. VIES doesn’t return a name and address for German or Spanish numbers, so full Art 18(1)(a) evidence for those two needs a source beyond VIES.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither rule says ‘run a VIES check.’ The statute requires the customer to hold and communicate a valid VAT ID; VIES is the evidentiary tool that shows you obtained confirmation in good faith (&lt;a href="https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32010R0904" rel="noopener noreferrer"&gt;Reg 904/2010 Art 31&lt;/a&gt;). That distinction matters for how you build the checkout flow: the check is evidence-gathering, not a legal gate by itself, so it shouldn’t block the transaction it’s supposed to support. Background on the two regimes side by side is in &lt;a href="https://vatnode.dev/guides/vat-reverse-charge" rel="noopener noreferrer"&gt;VAT reverse charge&lt;/a&gt; and the &lt;a href="https://vatnode.dev/reverse-charge" rel="noopener noreferrer"&gt;do-I-charge-VAT reference&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why checkout is a harder problem than signup
&lt;/h2&gt;

&lt;p&gt;At signup, a pending VAT check just means the account is created and the field says ‘verifying.’ At checkout, the VAT ID can change the number on the payment page the customer is about to authorize – so the wrong instinct is stronger: block payment until the check comes back clean.&lt;/p&gt;

&lt;p&gt;Don’t. VIES has no SLA. An outage code – &lt;code&gt;VIES_UNAVAILABLE&lt;/code&gt;, &lt;code&gt;UPSTREAM_TIMEOUT&lt;/code&gt;, &lt;code&gt;VIES_ERROR&lt;/code&gt; – means ‘VIES didn’t answer, try again,’ not ‘not registered.’ A &lt;code&gt;RATE_LIMITED&lt;/code&gt; or &lt;code&gt;INVALID_REQUESTER&lt;/code&gt; means your own quota or requester config is the problem: still not a verdict on the buyer, and not something a retry fixes. None of them is a reason to block the buy button – doing so turns a national tax authority’s maintenance window, or your own misconfiguration, into your abandoned-cart number. The only answer that means ‘not currently VAT-registered’ is a check that &lt;strong&gt;completed&lt;/strong&gt; with &lt;code&gt;valid: false&lt;/code&gt;. A check that hasn’t completed yet is not that answer.&lt;/p&gt;

&lt;p&gt;The fix is the same shape as at signup, applied to the order total instead of the account: format-check the field inline, run the real check server-side without blocking payment, and gate only the specific decision – zero-rate this order or not – on a completed answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guest checkout: the VAT ID is a one-time order field, not a customer record
&lt;/h2&gt;

&lt;p&gt;Most e-commerce checkout has no signup pattern to reuse in the first place: the buyer is a guest, there’s no account row to attach a ‘pending’ flag to, and the VAT ID exists only for the duration of this order. Design around that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Store the check result &lt;strong&gt;on the order&lt;/strong&gt;, not on a customer entity that may not exist tomorrow.&lt;/li&gt;
&lt;li&gt;Don’t defer the decision to ‘next time they log in’ – there is no next time for a guest.&lt;/li&gt;
&lt;li&gt;The order is also the audit unit later, so the evidence (the response fields below) needs to live wherever the order record lives, immutably, once the order is placed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A checkout handler, server-side
&lt;/h2&gt;

&lt;p&gt;The API key is a bearer credential – call it from your server, never from the browser. This is a Node.js handler (an API route or server action) that does the three things checkout needs: reject garbage inline, call the API server-side, and map every possible outcome – including the outage codes – to a VAT decision.&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;// checkout/vat-decision.ts — server-side only. Never call this from the browser.&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;EU_VAT_PATTERNS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;RegExp&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;DE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/^DE&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="na"&gt;FR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/^FR&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-HJ-NP-Z0-9&lt;/span&gt;&lt;span class="se"&gt;]{2}\d{9}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;NL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/^NL&lt;/span&gt;&lt;span class="se"&gt;\d{9}&lt;/span&gt;&lt;span class="sr"&gt;B&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...one entry per country you support at checkout, EL for Greece&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;isPlausibleVatId&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;boolean&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;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;/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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cleaned&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;2&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;pattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;EU_VAT_PATTERNS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;prefix&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;pattern&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;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="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;VatDecision&lt;/span&gt; &lt;span class="o"&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;no_vat_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// field left blank — charge VAT, no decision to make&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eligible_for_exemption&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// completed valid:true — one condition met, not the only one&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid_vat_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// completed valid:false or bad format — charge VAT, ask to re-enter&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending_recheck&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// VIES didn't answer — charge VAT provisionally, reconcile before invoicing&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blocked_config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// your own quota/requester problem — charge VAT, alert ops, don't retry&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decideVatTreatment&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="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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VatDecision&lt;/span&gt;
  &lt;span class="nx"&gt;checkId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;consultationNumber&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="o"&gt;&amp;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;rawVatId&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="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no_vat_id&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;isPlausibleVatId&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="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// INVALID_FORMAT — caught inline, 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;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid_vat_id&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="kd"&gt;const&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;rawVatId&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;/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="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;res&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&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="s2"&gt;`https://api.vatnode.dev/v1/vat/&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="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="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8000&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="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Network failure or timeout — the same "no answer" class as an outage.&lt;/span&gt;
    &lt;span class="c1"&gt;// Never let a hanging request block the checkout.&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;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending_recheck&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="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;switch &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="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;VIES_UNAVAILABLE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UPSTREAM_TIMEOUT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;VIES_ERROR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INTERNAL_ERROR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;// VIES didn't answer — retryable. Charge VAT now, reconcile later.&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;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending_recheck&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INVALID_FORMAT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;// Slipped past the inline check (a looser regex than the API's).&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;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid_vat_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;RATE_LIMITED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INVALID_REQUESTER&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;// Your own quota or requester config, not the buyer's — a retry won't&lt;/span&gt;
        &lt;span class="c1"&gt;// fix these. Charge VAT and alert ops; don't queue them for recheck.&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;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blocked_config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nl"&gt;default&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="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending_recheck&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="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;// { valid, vatId, countryCode, countryName, companyName, companyAddress,&lt;/span&gt;
  &lt;span class="c1"&gt;//   checkId, verifiedAt, source, consultationNumber }&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&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="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eligible_for_exemption&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;checkId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;consultationNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;consultationNumber&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid_vat_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;checkId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkId&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;&lt;code&gt;eligible_for_exemption&lt;/code&gt; is named that way on purpose. A &lt;code&gt;valid: true&lt;/code&gt; result is one condition met, not clearance to zero-rate the order – for goods you still need the transport evidence, a correct EC Sales List entry, and the buyer VAT-identified in a member state &lt;em&gt;other&lt;/em&gt; than where dispatch begins (Art 138(1)(b)): a valid same-country ID is a domestic supply, not an exempt intra-Community one. For services you still need the place-of-supply analysis to actually put the customer in scope of the reverse charge. Route the decision, don’t shortcut it:&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;applyVatDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VatDecision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;netTotal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;vatRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eligible_for_exemption&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;// For goods, also confirm the buyer is VAT-identified in a member state&lt;/span&gt;
      &lt;span class="c1"&gt;// other than where dispatch begins (Art 138(1)(b)) before zero-rating —&lt;/span&gt;
      &lt;span class="c1"&gt;// a same-country valid ID is a domestic supply. Recalculate from net; do&lt;/span&gt;
      &lt;span class="c1"&gt;// not derive this by subtracting VAT from a gross figure already shown.&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;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;netTotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;vatApplied&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;needsReconciliation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending_recheck&lt;/span&gt;&lt;span class="dl"&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="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;netTotal&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vatRate&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="na"&gt;vatApplied&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;needsReconciliation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// reconcile before the invoice is issued&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blocked_config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;// Your validation is broken, not the buyer's number. Charge VAT so the&lt;/span&gt;
      &lt;span class="c1"&gt;// order completes, and alert ops — this needs a human, not a retry.&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;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;netTotal&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vatRate&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="na"&gt;vatApplied&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;needsReconciliation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;needsOps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid_vat_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no_vat_id&lt;/span&gt;&lt;span class="dl"&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="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;netTotal&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vatRate&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="na"&gt;vatApplied&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;needsReconciliation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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 safe default on an outage is the seller’s own domestic VAT of the dispatch member state – not the buyer’s country rate. Charging the buyer’s-country rate is an OSS/distance-selling mechanism for a different scenario and is out of scope here; applying it as a fallback for a B2B intra-Community order is the wrong number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recalculating the total when the VAT ID lands late
&lt;/h2&gt;

&lt;p&gt;The awkward UX case: the customer sees a VAT-inclusive total, then pastes a VAT ID mid-checkout. Two mistakes are common here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Subtracting VAT from the displayed gross figure instead of recalculating from net – rounding differences between the two make the invoice not match what the customer thinks they agreed to pay.&lt;/li&gt;
&lt;li&gt;Re-running the check against whatever was in the field when the page first loaded instead of the final value the customer submits – a check against a half-typed ID doesn’t cover the order.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Recompute from &lt;code&gt;netTotal&lt;/code&gt;, and re-run &lt;code&gt;decideVatTreatment&lt;/code&gt; against the exact string that’s about to be submitted, not a cached earlier attempt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling the outage case honestly
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pending_recheck&lt;/code&gt; isn’t a dead end, it’s a queue entry. Charge VAT provisionally per the decision above, let the order complete, and reconcile before the invoice goes out – the invoice is the document that has to be right, not the checkout screen.&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;reconcileBeforeInvoicing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;id&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="nl"&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="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;decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;checkId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;consultationNumber&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="nf"&gt;decideVatTreatment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&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;decision&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eligible_for_exemption&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markOrderZeroRated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;checkId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;consultationNumber&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// else: the provisional VAT-inclusive charge stands — nothing to reverse&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A national tax authority or company registry can sometimes answer even when VIES itself is unavailable – see &lt;a href="https://vatnode.dev/docs/coverage" rel="noopener noreferrer"&gt;coverage&lt;/a&gt; for which countries have that fallback. When one does answer, &lt;code&gt;source&lt;/code&gt; reports the registry that responded and &lt;code&gt;consultationNumber&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;, because national answers don’t produce one; only a requester-qualified VIES answer does. Design your reconciliation job to treat both the same way operationally – it’s the &lt;code&gt;valid&lt;/code&gt; value that drives the invoice, not which source produced it. The retry-window and backoff mechanics for the outage case itself are covered in the &lt;a href="https://vatnode.dev/blog/vies-downtime-guide" rel="noopener noreferrer"&gt;VIES downtime guide&lt;/a&gt;, and the full error-code-to-behavior mapping is in &lt;a href="https://vatnode.dev/blog/handling-vies-errors-in-code" rel="noopener noreferrer"&gt;handling VIES errors in code&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to store per order
&lt;/h2&gt;

&lt;p&gt;The evidence has to attach to the order, not float in a separate log you’d have to cross-reference later:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;persistOrderVatCheck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;orderId&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;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&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="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
    &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
    &lt;span class="nx"&gt;consultationNumber&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="nx"&gt;checkId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
    &lt;span class="nx"&gt;verifiedAt&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="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;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;order_vat_checks&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;order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;vat_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&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="na"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;consultation_number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;consultationNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// nullable&lt;/span&gt;
    &lt;span class="na"&gt;check_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;verified_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verifiedAt&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;Once the order is placed, this row is append-only – a re-check produces a new row, never an edit to the one taken at checkout. The consultation number, where one exists, is the European Commission’s own reference tying a specific requester-qualified lookup to a moment in time – useful evidence, not proof of the buyer’s eligibility by itself. More on what it does and doesn’t certify: &lt;a href="https://vatnode.dev/blog/vies-consultation-number-explained" rel="noopener noreferrer"&gt;the VIES consultation number explained&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  One call for the whole checkout flow
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://vatnode.dev/vat-api" rel="noopener noreferrer"&gt;EU VAT validation API&lt;/a&gt; runs the VIES call, falls back to a national source when VIES can’t answer, and returns the outcome plus the evidence fields in one response – no SOAP parsing, no separate registry integration to maintain. Set your own VAT as the requester in dashboard Account details and each fresh VIES answer also carries a consultation number; leave it unset and you keep the national fallback instead. The two are a config choice, not both at once – a requester-qualified call has no fallback, so a VIES outage there surfaces as one of the error codes above, which your &lt;code&gt;pending_recheck&lt;/code&gt; path already handles. More on that trade-off in &lt;a href="https://vatnode.dev/blog/vies-consultation-number-explained" rel="noopener noreferrer"&gt;the VIES consultation number explained&lt;/a&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="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/FR40123456824&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="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": "FR40123456824",&lt;/span&gt;
&lt;span class="c1"&gt;//   "countryCode": "FR",&lt;/span&gt;
&lt;span class="c1"&gt;//   "countryName": "France",&lt;/span&gt;
&lt;span class="c1"&gt;//   "companyName": "Example SAS",&lt;/span&gt;
&lt;span class="c1"&gt;//   "companyAddress": "1 rue de Rivoli, 75001 Paris",&lt;/span&gt;
&lt;span class="c1"&gt;//   "source": "VIES",&lt;/span&gt;
&lt;span class="c1"&gt;//   "consultationNumber": "WAPIAAAAX9999999", // null unless a requester VAT is set&lt;/span&gt;
&lt;span class="c1"&gt;//   "checkId": "019d2a89-a5d9-7b97-b710-57b84604de2b",&lt;/span&gt;
&lt;span class="c1"&gt;//   "verifiedAt": "2026-09-07T08: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;For a French buyer, that’s the &lt;a href="https://vatnode.dev/vat-api/fr" rel="noopener noreferrer"&gt;France VAT API reference&lt;/a&gt;; the same shape applies across every supported country under &lt;a href="https://vatnode.dev/vat-api" rel="noopener noreferrer"&gt;/vat-api&lt;/a&gt;. If you’re building into WooCommerce rather than a custom checkout, the &lt;a href="https://vatnode.dev/woocommerce" rel="noopener noreferrer"&gt;WooCommerce VAT validation plugin&lt;/a&gt; wires the same check into the order flow without custom code. For a Stripe-based checkout, &lt;a href="https://vatnode.dev/guides/stripe-vat-validation" rel="noopener noreferrer"&gt;Stripe VAT validation&lt;/a&gt; covers that integration point. To re-check many pending orders at once, &lt;code&gt;POST /v1/vat/bulk&lt;/code&gt; accepts up to 50,000 VAT IDs and returns a &lt;code&gt;jobId&lt;/code&gt;: you poll the job, then page its results. Each position does carry &lt;code&gt;checkId&lt;/code&gt;, but not &lt;code&gt;verifiedAt&lt;/code&gt;, &lt;code&gt;companyName&lt;/code&gt; or &lt;code&gt;companyAddress&lt;/code&gt;, so keep using the single call where an order needs all the evidence fields. See 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/docs/errors" rel="noopener noreferrer"&gt;error reference&lt;/a&gt; for the full code list.&lt;/p&gt;

&lt;p&gt;This is general information about EU VAT and VIES, not tax advice. Whether a specific order qualifies for zero-rating or reverse charge depends on facts we can’t assess here – dispatch location, transport evidence, the buyer’s actual status – confirm the treatment of your own transactions with a qualified tax adviser.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Should a VAT ID field block checkout if it can’t be validated instantly?
&lt;/h3&gt;

&lt;p&gt;No – the VIES call is server-side and has no SLA, so waiting on it before allowing payment turns a temporary outage into a lost order. Let checkout complete, apply a provisional VAT treatment, and reconcile before the invoice is issued.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is validating a VAT ID at checkout about reverse charge or VAT exemption?
&lt;/h3&gt;

&lt;p&gt;It depends on what’s in the cart. For goods, a valid buyer VAT ID is a substantive condition for zero-rating the intra-Community supply. For services, it supports treating the customer as a taxable person under the reverse charge, where the customer self-accounts for VAT. They’re different regimes with different legal bases – don’t treat ‘reverse charge’ as the umbrella term for both.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if a customer enters a valid VAT ID after the order total is already shown?
&lt;/h3&gt;

&lt;p&gt;Recalculate the order total from net, not by subtracting VAT from the gross figure already shown, and re-run the check against the final VAT ID before payment – a validation done against an earlier or partial number doesn’t cover the order.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need to re-check the VAT ID before issuing the invoice?
&lt;/h3&gt;

&lt;p&gt;Yes if the checkout answer was provisional – a pending state from a VIES outage, or a check that ran before the customer finished editing the field. If checkout already returned a completed, valid answer for the exact ID on the order, that check is your evidence; you don’t need to repeat it just because time has passed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  Validate VAT IDs from your own checkout flow
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://vatnode.dev/vat-api" rel="noopener noreferrer"&gt;EU VAT validation API&lt;/a&gt; runs the VIES call, falls back to a national source on downtime, and returns the outcome plus the evidence fields in one response – no SOAP parsing. &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>webdev</category>
    </item>
  </channel>
</rss>
