<?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>What an MVP Actually Costs</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 31 Aug 2026 10:01:01 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/what-an-mvp-actually-costs-123d</link>
      <guid>https://dev.to/iurii_rogulia/what-an-mvp-actually-costs-123d</guid>
      <description>&lt;p&gt;You've decided to build the thing. You send the same brief to three developers, and the quotes come back thousands apart. One is a fraction of the others, and it's tempting to read that as the same product for less money.&lt;/p&gt;

&lt;p&gt;It almost never is.&lt;/p&gt;

&lt;p&gt;The cheapest quote is rarely the same work done cheaper. It's a different, smaller amount of work — one that produces something that demos well and falls apart the moment real customers touch it. The gap between the quotes isn't margin. It's the corners the cheap one is quietly cutting, and you're the one who pays to un-cut them later.&lt;/p&gt;

&lt;p&gt;So before you pick a number, it's worth understanding what actually drives the cost of an MVP — and, more importantly, where cutting cost is smart and where it's a trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Cost Actually Comes From
&lt;/h2&gt;

&lt;p&gt;An MVP is not a pile of features priced by the unit. Most of the real cost sits in a few places founders don't see on the surface.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scope.&lt;/strong&gt; The single biggest driver, and the one most under your control. Every screen, every "while we're at it," every integration adds time. This is where a good builder earns their fee — by talking you &lt;em&gt;out&lt;/em&gt; of things.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data model.&lt;/strong&gt; How your information is structured underneath. It's invisible in the demo and expensive to change after launch. Getting it roughly right early is cheap. Getting it wrong is a rebuild.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correctness where it matters.&lt;/strong&gt; Login that can't be bypassed, payments that charge the right amount, tax handled properly. This is careful, boring work, and it's a large part of any honest estimate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The unglamorous 30%.&lt;/strong&gt; Error handling, edge cases, the states nobody screenshots. A demo skips all of it. A product that survives contact with customers can't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A cheap quote usually gets there by shrinking the last three. The scope looks the same on paper. What's missing is everything that doesn't show up in a five-minute walkthrough.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cheap MVP That Becomes an Expensive Rebuild
&lt;/h2&gt;

&lt;p&gt;Here's the pattern I've watched play out more than once.&lt;/p&gt;

&lt;p&gt;A founder pays for a fast, cheap build. It demos beautifully. The first users arrive. Then the bill for the corners comes due: two customers get charged twice because the payment flow was never made safe against a double-click. The data model can't represent a customer belonging to two companies, so a whole feature is impossible without restructuring the foundation. Nobody's logged out properly, so one account can see another's data.&lt;/p&gt;

&lt;p&gt;None of these are visible in the demo. All of them surface in production, in front of real, paying, unhappy customers.&lt;/p&gt;

&lt;p&gt;Now you rebuild — except it's worse than starting fresh, because you're also migrating live customer data, keeping the broken thing running, and rebuilding trust you already spent. The "savings" from the cheap quote get repaid with interest, plus the revenue you lost while the product was on fire.&lt;/p&gt;

&lt;p&gt;The cheapest quote didn't save money. It moved the cost forward in time and made it larger. That's the trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Safe to Cut
&lt;/h2&gt;

&lt;p&gt;Cutting scope is not the enemy. It's the whole point of an MVP — the &lt;em&gt;minimum viable&lt;/em&gt; is right there in the name. The skill is cutting the right things.&lt;/p&gt;

&lt;p&gt;Safe to cut, almost always:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nice-to-have features.&lt;/strong&gt; The admin dashboard, the settings nobody's asked for yet, the second onboarding flow. If a spreadsheet or a manual step gets you through the first three months, use the spreadsheet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Polish that isn't load-bearing.&lt;/strong&gt; Animations, a second theme, edge-case screens for situations you haven't hit yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scale you don't have.&lt;/strong&gt; Building for a million users when you have zero is paying today for a problem you'd be lucky to have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reversible decisions.&lt;/strong&gt; Anything you can change later without touching the foundation is safe to defer. Ship the plain version, learn, then improve.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cutting these isn't cheapness. It's focus. Every one of them can be added later without tearing anything down — which is exactly what makes them safe.&lt;/p&gt;

&lt;p&gt;
  slug="mvp-development"&lt;br&gt;
  text="The hard part of an MVP isn't building features — it's deciding which ones don't get built yet. I help founders cut the deferrable scope hard while protecting the parts that are expensive to fix later."&lt;br&gt;
/&amp;gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Must Never Cut
&lt;/h2&gt;

&lt;p&gt;Then there's the short list of things where cutting cost doesn't save money — it just borrows it at a punishing rate. These are the load-bearing foundations. Skimp here and you don't get a cheaper MVP; you get a delayed rebuild.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authentication and access control.&lt;/strong&gt; Who can log in, and what each account is allowed to see and do. Get this wrong and one customer sees another's data — the kind of incident that ends a young product. This is not a feature to bolt on later. It shapes everything above it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payment correctness.&lt;/strong&gt; If you take money, the amounts must be right, charges must not fire twice, refunds must actually refund, and tax must be handled properly. I once built a whole SaaS billing layer from scratch — mandates, proration, dunning, EU VAT — because payment correctness isn't optional the moment real money moves. A payment bug isn't a bug report. It's a chargeback, a furious customer, and sometimes a legal problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The data model.&lt;/strong&gt; The structure your information lives in. This is the one thing that's genuinely hard to change after launch, because by then it's full of real customer data. You don't need the final version. You need one that won't have to be demolished when you add the second obvious feature. A little thought here at the start is the cheapest insurance you'll ever buy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice what these have in common: they're all invisible in a demo and ruinously expensive to fix after customers arrive. That's exactly why a cheap quote is happy to shave them, and exactly why you shouldn't let it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Read the Quotes
&lt;/h2&gt;

&lt;p&gt;You don't need to evaluate the code to avoid the trap. You need to ask what the cheap number is leaving out. A few questions do most of the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;How are payments made safe against double-charging and failure?&lt;/strong&gt; A confident, specific answer means they've thought about it. A shrug means you're paying for the fix later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What happens when the data model needs to change after launch?&lt;/strong&gt; You want to hear that it's been designed to grow, not that "we'll deal with it then."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What's explicitly &lt;em&gt;not&lt;/em&gt; in this scope?&lt;/strong&gt; The best answer is a builder who has already cut things and can tell you exactly what and why. Silence here is the cheap quote hiding its corners.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A quote that's cheap because it cut nice-to-haves is a good deal. A quote that's cheap because it cut authentication, payment correctness, or a sane data model is a loan against your future — one you'll repay as a rebuild, at the worst possible time, in front of your first real customers.&lt;/p&gt;

&lt;p&gt;The right MVP spends nothing on what you can add later, and refuses to save money on what you can't. That's the line worth paying for.&lt;/p&gt;




&lt;p&gt;If you're staring at two quotes that are thousands apart and can't tell which corners the cheap one is cutting, that's a conversation worth having before you sign. &lt;a href="https://iurii.rogulia.fi/contact" rel="noopener noreferrer"&gt;Let's read them together&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>business</category>
      <category>mvp</category>
      <category>startup</category>
      <category>decision</category>
    </item>
    <item>
      <title>Vendor Bank Account Change Fraud: Detect the Forged Bank-Letter PDF</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 31 Aug 2026 10:00:40 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/vendor-bank-account-change-fraud-detect-the-forged-bank-letter-pdf-3ild</link>
      <guid>https://dev.to/iurii_rogulia/vendor-bank-account-change-fraud-detect-the-forged-bank-letter-pdf-3ild</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/vendor-bank-change-fraud-supplier-onboarding" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt;. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A supplier you already pay sends an email: their bank has changed, please update the remittance details. Attached is a PDF on bank letterhead — an “updated banking details” confirmation, or a voided check, or a signed account-verification letter. Your AP analyst calls the number on file, the contact confirms, the change goes into the vendor master. Three weeks later the real supplier asks where their money went. It went to the fraudster’s account, and the PDF that authorized the change was edited after the bank issued it.&lt;/p&gt;

&lt;p&gt;This is vendor bank account change fraud, and it sits in a blind spot that callback procedures do not cover. The controls every AP team runs — phone confirmation, email approval, a second signer — test whether the &lt;em&gt;request&lt;/em&gt; is plausible. None of them test whether the &lt;em&gt;document&lt;/em&gt; attached to the request was modified after it was created. HTPBE reads the submitted PDF’s structure and returns a verdict on exactly that question.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is not BEC invoice fraud — it’s the vendor master
&lt;/h2&gt;

&lt;p&gt;It is worth separating two frauds that get lumped together, because the document, the moment of attack, and the control gap are different.&lt;/p&gt;

&lt;p&gt;Business email compromise invoice fraud targets a single payment. The fraudster intercepts or spoofs a real invoice, swaps the remittance bank account in the PDF, and sends it through a compromised or look-alike email thread. The damage is one invoice. We cover that case in detail in &lt;a href="https://htpbe.tech/blog/bec-invoice-fraud-detection-api" rel="noopener noreferrer"&gt;BEC invoice fraud detection&lt;/a&gt; and the broader &lt;a href="https://htpbe.tech/blog/invoice-fraud-accounts-payable-pdf" rel="noopener noreferrer"&gt;invoice fraud in accounts payable&lt;/a&gt; playbook.&lt;/p&gt;

&lt;p&gt;Vendor bank account change fraud is upstream of any invoice. It targets the vendor master record itself. Instead of editing one invoice, the fraudster changes the standing bank details attached to a supplier, so that &lt;em&gt;every&lt;/em&gt; future payment to that vendor — this month’s and next year’s — routes to the attacker. The supporting document is not an invoice. It is a bank-issued confirmation: a letter from the supplier’s bank, a voided check, an account-ownership letter, a SEPA or ACH mandate. One forged PDF can redirect a payment relationship worth millions over its lifetime.&lt;/p&gt;

&lt;p&gt;Same forensic question (was this PDF edited after issue?), different document and different point in the workflow. Treat them as two checks at two gates: one at invoice intake, one at any vendor bank-detail change.&lt;/p&gt;

&lt;p&gt;The economics make the vendor-master version the more dangerous of the two. A redirected invoice loses you one payment, and the discrepancy often surfaces fast — the supplier chases the unpaid invoice within the payment cycle. A redirected vendor master loses you every payment until someone notices, and the discrepancy can hide for months, because the legitimate supplier keeps invoicing and you keep paying — just to the wrong account. By the time the real vendor escalates a string of “missing” payments, several cycles of money may already be gone and unrecoverable. The forged bank letter at the change gate is a higher-leverage target for the fraudster, which is exactly why it deserves a dedicated check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AP controls miss the forged bank letter
&lt;/h2&gt;

&lt;p&gt;Standard vendor-change controls are built around social-engineering resistance, and they are good at it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Callback verification&lt;/strong&gt; — call the supplier on a number from the master record, not from the request email.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email confirmation&lt;/strong&gt; — require written sign-off from a known contact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dual approval&lt;/strong&gt; — a second person authorizes the master-data change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Change-notification holds&lt;/strong&gt; — freeze the first payment after any bank-detail change for a cooling-off window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these evaluates the &lt;em&gt;request&lt;/em&gt;. Did a believable person ask, through a believable channel, with a believable reason? A determined fraudster can pass all four. Compromised supplier inboxes mean the callback reaches a number the attacker controls. Spoofed look-alike domains satisfy the email check. And the attached bank letter is, to the human eye, perfect: real letterhead, real account-holder name, a plausible new account number, a signature, a date.&lt;/p&gt;

&lt;p&gt;What no control in that list does is open the PDF and ask how it was built. The analyst reads the document; they do not examine the file structure underneath it. That structure is where the edit lives. A genuine bank confirmation comes out of the bank’s own system — a core-banking platform, a statement generator, an institutional document service. A forged one is a real bank letter that someone opened in a desktop or online PDF editor, changed the account number on, and re-saved. The save operation rewrites the file in ways the bank’s system never would, and those changes survive email and stay in the bytes you receive.&lt;/p&gt;

&lt;p&gt;There is a reason this gap is structural rather than a training problem you can fix with a sharper analyst. A human reviewer is matching the document against business context: is this a supplier we know, is the contact someone we recognize, does the new account look like a normal account. All of that lives at the content level — the words and numbers on the page. The evidence of tampering does not live on the page. It lives in the file’s internal structure: which software wrote it, how many times it was saved, and what its internal timestamps say about when it was made versus when it was last changed. You cannot see that by reading the letter more carefully, because it is not in the letter. It is underneath the letter, and reading it requires parsing the PDF’s structure — which is exactly what an automated check does and a manual review cannot.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the fraudster changes, and what it leaves behind
&lt;/h2&gt;

&lt;p&gt;The visible change is small: an account number, a sort code or routing number, sometimes the account-holder name. Visually it is indistinguishable from the genuine field. The act of making that change, though, leaves structural evidence inside the PDF. Described in plain terms, without a how-to:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The software that generated the file stops matching the issuer.&lt;/strong&gt; Every PDF records which software produced it. A bank’s confirmation letter carries the fingerprint of the bank’s institutional system. When a fraudster opens that letter in a consumer or online editor and saves it, the file now carries the editor’s identity instead of — or alongside — the bank’s. An institutional bank letter that reports it was last saved by a free online PDF tool did not come from the bank that way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The document gains an edit history it should not have.&lt;/strong&gt; A PDF issued once by an automated system is a single clean generation. Editing and re-saving appends a new revision layer to the file; the file now records that it was created, then changed, then saved again. Multiple revision layers on a document that claims to be a freshly issued bank confirmation mean it was reopened and altered after issue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Internal timestamps disagree.&lt;/strong&gt; A document carries an internal record of when it was created and when it was last modified. On a genuine one-shot bank letter these are effectively the same moment. When the modification record postdates the creation record, the file is telling you it was edited after it was made — on a bank confirmation, after the bank issued it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A signed letter shows changes after signing, or a missing signature.&lt;/strong&gt; Some bank confirmations carry a digital signature. If the content was altered after that signature was applied, the file records that the signed region no longer matches what is on the page. In other cases the original carried a signature that the fraudster’s save operation stripped, leaving the residue of a signature that was removed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The account number itself was overlaid.&lt;/strong&gt; Sometimes the fraudster does not rebuild the page — they patch a single field, dropping a new account number on top of the original using a desktop editor. That targeted character-level overlay is a different fingerprint from a full re-save, and it points straight at the field that was changed.&lt;/p&gt;

&lt;p&gt;None of these require the original document. HTPBE compares the submitted file against how genuinely issued institutional documents are constructed — not against a copy you do not have.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the API returns on a forged bank-change letter
&lt;/h2&gt;

&lt;p&gt;HTPBE is a two-step call. You &lt;code&gt;POST&lt;/code&gt; the PDF’s URL to &lt;code&gt;/v1/analyze&lt;/code&gt; and get back an &lt;code&gt;id&lt;/code&gt;; you &lt;code&gt;GET /v1/result/{id}&lt;/code&gt; for the verdict. The result is a flat object — a &lt;code&gt;status&lt;/code&gt;, the named &lt;code&gt;modification_markers&lt;/code&gt;, and a &lt;code&gt;modification_confidence&lt;/code&gt; of &lt;code&gt;certain&lt;/code&gt;, &lt;code&gt;high&lt;/code&gt;, or &lt;code&gt;none&lt;/code&gt;. There is no numeric risk score; the markers name what was found.&lt;/p&gt;

&lt;p&gt;Here is a representative result on a vendor bank-change letter. The supplier’s genuine bank confirmation was opened in a desktop editor, the IBAN was swapped to an attacker-controlled account, and the file was re-saved before being attached to the change request:&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_4f2a9c1e-7b3d-4e8a-91c2-0d6e5f8a7b34"&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="w"&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="s2"&gt;"HTPBE_EDITING_TOOL_FINGERPRINT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"HTPBE_MULTIPLE_REVISION_LAYERS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"HTPBE_CHARACTER_OVERLAY_EDIT"&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;"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;"Institutional Banking Platform"&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;"Desktop PDF Editor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"has_digital_signature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"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;1755302400&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;1755734400&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 it in business terms. &lt;code&gt;HTPBE_DATES_DISAGREE&lt;/code&gt; — the file’s own modification timestamp postdates its creation timestamp; the letter was edited five days after it was made. This is the strongest marker present, and it produces &lt;code&gt;certain&lt;/code&gt; confidence: the document is internally inconsistent about its own age in a way that a one-shot bank letter never is. &lt;code&gt;HTPBE_EDITING_TOOL_FINGERPRINT&lt;/code&gt; — the file carries the fingerprint of a known desktop PDF editing tool, which has no place in a bank’s issuance pipeline. &lt;code&gt;HTPBE_MULTIPLE_REVISION_LAYERS&lt;/code&gt; — the document was generated, then modified, then saved again, rather than issued once and untouched. &lt;code&gt;HTPBE_CHARACTER_OVERLAY_EDIT&lt;/code&gt; — a targeted character-level overlay sits on the page, the signature of a single field patched rather than the whole document rebuilt.&lt;/p&gt;

&lt;p&gt;The verdict is &lt;code&gt;modified&lt;/code&gt; with &lt;code&gt;certain&lt;/code&gt; confidence. In practice that is a routing signal: hold the vendor-change request, do not commit it to the master, and escalate to a verified out-of-band confirmation with the bank or the supplier’s known finance contact — through a channel the fraudster cannot have compromised. The forensic check produces evidence; your vendor-management policy decides the action.&lt;/p&gt;

&lt;p&gt;Other documents in the same workflow throw different combinations. A signed account-verification letter altered after signing surfaces &lt;code&gt;HTPBE_POST_SIGNATURE_EDIT&lt;/code&gt;; one whose signature was stripped surfaces &lt;code&gt;HTPBE_SIGNATURE_REMOVED&lt;/code&gt;. A letter whose institutional generator identity was deliberately spoofed to look like it came from the bank surfaces &lt;code&gt;HTPBE_PRODUCER_IDENTITY_FORGED&lt;/code&gt;. You route on the verdict, not on memorizing which marker means what.&lt;/p&gt;

&lt;h2&gt;
  
  
  What inconclusive means here — and why it is still useful
&lt;/h2&gt;

&lt;p&gt;A third verdict matters as much as &lt;code&gt;modified&lt;/code&gt;. If the submitted PDF was built in consumer software — written in Word, exported from a browser, printed to PDF — HTPBE returns &lt;code&gt;inconclusive&lt;/code&gt;. The file was assembled by a tool that does not preserve the structural history needed to prove the document is intact. That is not a detector failure. It is information.&lt;/p&gt;

&lt;p&gt;For a vendor bank-detail change, &lt;code&gt;inconclusive&lt;/code&gt; is a red flag in itself, because of what you expected. A real bank does not send your supplier’s confirmation as a Word document or a print-to-PDF. If you asked for a bank-issued confirmation of a new account and the file comes back &lt;code&gt;inconclusive&lt;/code&gt; because it was produced in a word processor, the document is not what it claims to be — regardless of whether anyone tampered with it afterward. Treat &lt;code&gt;inconclusive&lt;/code&gt; on a document that should be institutionally issued the same way you treat a missing document: insufficient to authorize a change. Route it to the same manual-verification queue as &lt;code&gt;modified&lt;/code&gt;, for a different reason.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;intact&lt;/code&gt; is the only verdict that lets a bank-change request proceed on the document’s own merits — and even then your callback and dual-approval controls still apply. HTPBE removes one specific failure mode (a forged or tampered file passing as genuine); it does not replace the rest of your process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits: what this check does not do
&lt;/h2&gt;

&lt;p&gt;Structural PDF forensics answers one question — was this file modified after it was created, and does its construction match a genuine institutional issuer? It is decisive on that question and silent on others. Be clear about the boundary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It does not validate the account.&lt;/strong&gt; HTPBE cannot tell you whether the IBAN on an &lt;code&gt;intact&lt;/code&gt; letter actually belongs to your supplier. A document can be structurally pristine and still name the wrong account if the fraud happened before the PDF was generated. Confirmation-of-payee and bank-account-verification services answer that question; HTPBE answers a different one. Run both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It does not catch a born-forged document.&lt;/strong&gt; If a fraudster builds a fake bank letter from scratch in a tool that issues clean, single-generation files — never editing an original — there is no post-creation edit to detect. The file may be entirely fabricated yet structurally consistent. That is why &lt;code&gt;inconclusive&lt;/code&gt; matters: a fabricated letter built in consumer software lands there, and a fabricated letter that claims an institutional origin it cannot back up surfaces an identity-forgery marker. But a clean synthetic letter is the boundary of what structural analysis sees, and we say so plainly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It does not verify content truth.&lt;/strong&gt; HTPBE does not read the letter and decide whether the sort code is real or the signatory is authorized. It checks the file’s integrity, not the facts it asserts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The value is that the largest category in practice — a real bank document edited to swap an account number — is exactly what structural forensics catches cold, and it is exactly the category callbacks and dual approval cannot see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integrating the check into vendor onboarding and change requests
&lt;/h2&gt;

&lt;p&gt;The check belongs at two gates: new-supplier onboarding (when the first bank details are captured) and any subsequent bank-detail change request. In both cases the trigger is the same — a supporting document arrives that will authorize where money goes. Run the check before the change touches the vendor master.&lt;/p&gt;

&lt;p&gt;Three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Capture the document at intake.&lt;/strong&gt; When a supplier-onboarding form or a bank-change request comes in with a PDF, store the file and obtain a URL the API can fetch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Call HTPBE before committing the change.&lt;/strong&gt; One synchronous analyze-and-result call per document. Gate the master-data update on the verdict.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route on the verdict.&lt;/strong&gt; &lt;code&gt;modified&lt;/code&gt; and &lt;code&gt;inconclusive&lt;/code&gt; go to a manual-verification hold; &lt;code&gt;intact&lt;/code&gt; proceeds into your existing callback and dual-approval flow.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.htpbe.tech/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;HEADERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HTPBE_API_KEY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_bank_change_doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vendor_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Step 1: submit the PDF for analysis
&lt;/span&gt;    &lt;span class="n"&gt;submit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/analyze&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pdf_url&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;check_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;submit&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="c1"&gt;# Step 2: retrieve the verdict
&lt;/span&gt;    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/result/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;check_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&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;# Persist the check_id against the vendor record for audit
&lt;/span&gt;    &lt;span class="nf"&gt;log_vendor_change_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vendor_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;modified&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;hold_bank_change&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vendor_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;modification_markers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inconclusive&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Bank confirmation should be institutionally issued, not built in
&lt;/span&gt;        &lt;span class="c1"&gt;# consumer software — treat as insufficient to authorize a change.
&lt;/span&gt;        &lt;span class="nf"&gt;hold_bank_change&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vendor_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;non_institutional_origin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="c1"&gt;# status == "intact": continue into callback + dual-approval flow
&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store the &lt;code&gt;id&lt;/code&gt; against the vendor master record. If a payment to that supplier is later disputed or clawed back, the forensic report is retrievable as a permanent audit trail showing exactly which structural signals triggered — or did not trigger — the hold. That record matters for internal investigation, for insurance claims on a fraudulent-payment loss, and for any dispute with the bank.&lt;/p&gt;

&lt;p&gt;The full request and response reference, error codes, and the test scenarios that return synthetic deterministic results are on the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;API page&lt;/a&gt;. The broader accounts-payable workflow — where this check sits alongside invoice intake and &lt;a href="https://htpbe.tech/blog/expense-receipt-fraud-altered-pdf" rel="noopener noreferrer"&gt;altered expense-receipt detection&lt;/a&gt; — is laid out on the &lt;a href="https://htpbe.tech/use-cases/accounts-payable" rel="noopener noreferrer"&gt;accounts-payable use-case page&lt;/a&gt;. Volume pricing for vendor-management platforms and shared-service centers processing thousands of onboarding and change documents a month is on the &lt;a href="https://htpbe.tech/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who should run this check
&lt;/h2&gt;

&lt;p&gt;If you own vendor master data, supplier onboarding, or procurement fraud controls, this is your gap. The personas who feel it most directly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Accounts payable and shared-service leads&lt;/strong&gt; who process supplier bank-detail changes and have watched a callback-verified change still turn out to be fraud.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Procurement and vendor-management teams&lt;/strong&gt; standing up onboarding for new suppliers, where the first bank details are captured from a document you have no prior record of.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Finance fraud and controls functions&lt;/strong&gt; building a defense-in-depth program around payments — structural document forensics is the layer that sits under callbacks and dual approval, checking the artifact those controls trust.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Developers on procure-to-pay, vendor-management, or onboarding platforms&lt;/strong&gt; who want to embed a per-document integrity check at the bank-detail gate. The API is self-serve: a key, two calls, a verdict.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Callbacks confirm that someone asked for the change. Dual approval confirms that two people signed off. Neither confirms that the bank letter authorizing the new account was not edited after the bank issued it. That last question is the one a forged-PDF fraudster is counting on you not to ask — and it is the one HTPBE answers from the file’s own bytes.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>fintech</category>
      <category>fraud</category>
      <category>forensics</category>
    </item>
    <item>
      <title>VIES Uptime, Measured — 28 Member States, One Sample Every 15 Minutes</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 31 Aug 2026 09:00:43 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/vies-uptime-measured-28-member-states-one-sample-every-15-minutes-bj6</link>
      <guid>https://dev.to/iurii_rogulia/vies-uptime-measured-28-member-states-one-sample-every-15-minutes-bj6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/vies-uptime-measured" rel="noopener noreferrer"&gt;vatnode.dev&lt;/a&gt;. The version on vatnode.dev is the canonical source — refer to it for the latest content.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;{/*&lt;br&gt;
RECOMPUTE BEFORE PUBLISHING — every figure below comes from one script run.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;python3 vies-report.py &amp;gt; report.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Numbers currently in this file are from a 5-day window (2026-08-06 → 2026-08-11),&lt;br&gt;
548 samples per state. They must be replaced with the 30-day run taken on or&lt;br&gt;
after 2026-09-06. Figures to update, in order of appearance:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;description frontmatter ......... aggregate availability&lt;/li&gt;
&lt;li&gt;"What we measure" ............... window length, samples/state, total samples&lt;/li&gt;
&lt;li&gt;Chart 1 ......................... full block, paste verbatim from script&lt;/li&gt;
&lt;li&gt;aggregate + zero-miss count ..... paragraph under Chart 1&lt;/li&gt;
&lt;li&gt;Germany section ................. DE %, missed count, hour profile chart&lt;/li&gt;
&lt;li&gt;Ireland section ................. IE %, outage window, missed count&lt;/li&gt;
&lt;li&gt;Latvia section .................. LV %, hour profile chart, missed count&lt;/li&gt;
&lt;li&gt;"What this does not measure" .... window length restated&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If a claim is not in the script output, it does not belong in this post.&lt;br&gt;
*/}&lt;/p&gt;

&lt;p&gt;The European Commission runs an endpoint that reports whether each member state's VAT validation backend is reachable right now. It answers "is Germany up". It keeps no history, so it cannot answer the question that actually changes your code: "is Germany reliably down at the time my nightly job runs".&lt;/p&gt;

&lt;p&gt;So we started sampling it every 15 minutes and keeping every sample. This is what the 28 member states look like once you have history.&lt;/p&gt;
&lt;h2&gt;
  
  
  The aggregate number is useless
&lt;/h2&gt;

&lt;p&gt;Across every member state and every sample, VIES was available &lt;strong&gt;98.71%&lt;/strong&gt; of the time.&lt;/p&gt;

&lt;p&gt;That number is true and it is worthless. Nothing in your system consumes "VIES". Your system consumes Germany, or Ireland, or Latvia, and those three behave nothing like each other and nothing like the average.&lt;/p&gt;
&lt;h2&gt;
  
  
  What we measure
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Source:&lt;/strong&gt; the Commission's own &lt;code&gt;GET /taxation_customs/vies/rest-api/check-status&lt;/code&gt;, which returns a per-country availability string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interval:&lt;/strong&gt; one sample per member state every 15 minutes, 96 per state per day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coverage:&lt;/strong&gt; 28 codes — the EU-27 plus XI (Northern Ireland). Greece appears as &lt;code&gt;EL&lt;/code&gt;, following VIES rather than ISO 3166.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Window in this post:&lt;/strong&gt; 5 days, &lt;strong&gt;548 samples per state, 15,344 samples total&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rule:&lt;/strong&gt; only the literal string &lt;code&gt;Available&lt;/code&gt; counts as up. Every other state the Commission reports — including strings we have not seen before — counts as down rather than being silently dropped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two caveats before the charts. They bound everything that follows.&lt;/p&gt;

&lt;p&gt;This is the Commission's self-report, not an independent probe. It is what VIES believes about its own member states. It can disagree with what an actual &lt;code&gt;checkVat&lt;/code&gt; call does, and it can lag a member state going down.&lt;/p&gt;

&lt;p&gt;And the history is short. Five days is enough to establish a pattern that repeats daily. It is not enough to claim an annual uptime figure, and this post does not.&lt;/p&gt;
&lt;h2&gt;
  
  
  Availability by member state
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DE  ████████████████████████░░░░   83.94%     88 missed
LV  █████████████████████████░░░   88.87%     61 missed
IE  ███████████████████████████░   95.44%     25 missed
BE  ████████████████████████████   98.91%      6 missed
MT  ████████████████████████████   98.91%      6 missed
EE  ████████████████████████████   99.45%      3 missed
IT  ████████████████████████████   99.64%      2 missed
RO  ████████████████████████████   99.64%      2 missed
CZ  ████████████████████████████   99.82%      1 missed
DK  ████████████████████████████   99.82%      1 missed
NL  ████████████████████████████   99.82%      1 missed
PT  ████████████████████████████   99.82%      1 missed
SE  ████████████████████████████   99.82%      1 missed
AT  ████████████████████████████  100.00%      0 missed
BG  ████████████████████████████  100.00%      0 missed
CY  ████████████████████████████  100.00%      0 missed
EL  ████████████████████████████  100.00%      0 missed
ES  ████████████████████████████  100.00%      0 missed
FI  ████████████████████████████  100.00%      0 missed
FR  ████████████████████████████  100.00%      0 missed
HR  ████████████████████████████  100.00%      0 missed
HU  ████████████████████████████  100.00%      0 missed
LT  ████████████████████████████  100.00%      0 missed
LU  ████████████████████████████  100.00%      0 missed
PL  ████████████████████████████  100.00%      0 missed
SI  ████████████████████████████  100.00%      0 missed
SK  ████████████████████████████  100.00%      0 missed
XI  ████████████████████████████  100.00%      0 missed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;15 of 28 member states missed nothing at all.&lt;/strong&gt; Of the 198 unavailable samples in the whole dataset, 174 belong to three countries. The distribution is not a bell curve with a long tail. It is a handful of specific backends with specific problems, and everyone else is fine.&lt;/p&gt;

&lt;p&gt;Which means the useful question is never "how reliable is VIES". It is "what does &lt;em&gt;this&lt;/em&gt; member state do, and when".&lt;/p&gt;
&lt;h2&gt;
  
  
  Germany: switched off every night
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DE
  00:00  ░░░░░░░░░░░░░░░░░░░░░░░░    0.00%  n=24
  01:00  ████████████████████████  100.00%  n=22
  02:00  ████████████████████████  100.00%  n=23
  03:00  ████████████████████████  100.00%  n=23
  04:00  ████████████████████████  100.00%  n=24
  05:00  ████████████████████████  100.00%  n=24
  06:00  ████████████████████████  100.00%  n=23
  07:00  ████████████████████████  100.00%  n=24
  08:00  ████████████████████████  100.00%  n=23
  09:00  ████████████████████████  100.00%  n=22
  10:00  ████████████████████████  100.00%  n=24
  11:00  ████████████████████████  100.00%  n=25
  12:00  ████████████████████████  100.00%  n=23
  13:00  ████████████████████████  100.00%  n=25
  14:00  ████████████████████████  100.00%  n=24
  15:00  ████████████████████████  100.00%  n=24
  16:00  ████████████████████████  100.00%  n=21
  17:00  ████████████████████████  100.00%  n=20
  18:00  ████████████████████████  100.00%  n=20
  19:00  ████████████████████████  100.00%  n=20
  20:00  ████████████████████████  100.00%  n=20
  21:00  ███████░░░░░░░░░░░░░░░░░   27.27%  n=22
  22:00  ░░░░░░░░░░░░░░░░░░░░░░░░    0.00%  n=24
  23:00  ░░░░░░░░░░░░░░░░░░░░░░░░    0.00%  n=24
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;a href="https://vatnode.dev/check/de" rel="noopener noreferrer"&gt;Germany&lt;/a&gt; is available in &lt;strong&gt;every single sample&lt;/strong&gt; between 01:00 and 20:00 UTC, and unavailable in &lt;strong&gt;every single sample&lt;/strong&gt; between 22:00 and 00:59 UTC. The 21:00 hour is the boundary: 27% available, so the window opens somewhere around 21:40 UTC.&lt;/p&gt;

&lt;p&gt;That is roughly &lt;strong&gt;21:40–01:00 UTC, and it occurred on all five nights&lt;/strong&gt; — weekends included. Germany's 83.94% is not flakiness. It is a scheduled shutdown, and it is the single largest contributor to the aggregate.&lt;/p&gt;

&lt;p&gt;We do not know why. The Commission's endpoint reports state, not reason, and we found no published maintenance calendar for it. What matters for your code is that the window is predictable, and predictable downtime is the easy kind.&lt;/p&gt;
&lt;h2&gt;
  
  
  Ireland: one outage, one block
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://vatnode.dev/check/ie" rel="noopener noreferrer"&gt;Ireland's&lt;/a&gt; misses are not spread out. Every one of them falls inside a single contiguous stretch: &lt;strong&gt;Monday 22:00 UTC through Tuesday 04:00 UTC&lt;/strong&gt;, unavailable in every sample from 23:00 to 03:59, partially available at the two edges. Outside that block, Ireland missed nothing.&lt;/p&gt;

&lt;p&gt;Six hours, once, in five days. That is 95.44%, and it is a different failure from Germany's. Nothing about Monday night predicts next Monday night. An unplanned outage looks like a step function and then it is over.&lt;/p&gt;

&lt;p&gt;If you build a retry schedule from Ireland's 95.44%, you will build the wrong thing. The number describes an event, not a rhythm.&lt;/p&gt;
&lt;h2&gt;
  
  
  Latvia: degrades under load
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LV
  00:00  ████████████████████████  100.00%  n=24
  01:00  ████████████████████████  100.00%  n=22
  02:00  ████████████████████████  100.00%  n=23
  03:00  ████████████████████████  100.00%  n=23
  04:00  ████████████████████████  100.00%  n=24
  05:00  ████████████████████████  100.00%  n=24
  06:00  ████████████████████████  100.00%  n=23
  07:00  ████████████████████████  100.00%  n=24
  08:00  ████████████████████████  100.00%  n=23
  09:00  ████████████████████░░░░   81.82%  n=22
  10:00  █████████████████████░░░   87.50%  n=24
  11:00  ██████████████████████░░   92.00%  n=25
  12:00  █████████████████░░░░░░░   69.57%  n=23
  13:00  ██████████████████░░░░░░   76.00%  n=25
  14:00  █████████████░░░░░░░░░░░   54.17%  n=24
  15:00  ████████████░░░░░░░░░░░░   50.00%  n=24
  16:00  ███████████░░░░░░░░░░░░░   47.62%  n=21
  17:00  ██████████████████████░░   90.00%  n=20
  18:00  ████████████████████████  100.00%  n=20
  19:00  ████████████████████████  100.00%  n=20
  20:00  ████████████████████████  100.00%  n=20
  21:00  ██████████████████████░░   90.91%  n=22
  22:00  ███████████████████████░   95.83%  n=24
  23:00  ████████████████████████  100.00%  n=24
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;a href="https://vatnode.dev/check/lv" rel="noopener noreferrer"&gt;Latvia&lt;/a&gt; is clean overnight — every sample from 23:00 through 08:00 UTC came back available — and then falls apart across the working day, bottoming out at &lt;strong&gt;47.62% availability at 16:00 UTC&lt;/strong&gt;, early evening local time. It never goes fully down for a whole hour and it never stays up for one either.&lt;/p&gt;

&lt;p&gt;This is the shape that breaks naive retry code. Germany you route around. Ireland you wait out. Latvia will answer if you ask again in ten minutes, and it will refuse if you ask again in ten seconds — but a fixed exponential backoff cannot tell those apart, so it burns its whole budget inside the same degraded minute and gives up.&lt;/p&gt;

&lt;p&gt;Three states, three failure modes, one aggregate number covering all of them.&lt;/p&gt;
&lt;h2&gt;
  
  
  What VIES actually returns when it fails
&lt;/h2&gt;

&lt;p&gt;Availability is only half the problem. The other half is that the SOAP API's failure modes are underdocumented, and several of them are not really failures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fault strings are the useful part.&lt;/strong&gt; VIES answers a failed &lt;code&gt;checkVat&lt;/code&gt; with a SOAP fault whose &lt;code&gt;faultstring&lt;/code&gt; is a bare code. &lt;code&gt;MS_UNAVAILABLE&lt;/code&gt; means that one member state is down. &lt;code&gt;SERVICE_UNAVAILABLE&lt;/code&gt; means the Commission's own gateway is. &lt;code&gt;MS_MAX_CONCURRENT_REQ&lt;/code&gt; and &lt;code&gt;GLOBAL_MAX_CONCURRENT_REQ&lt;/code&gt; are rate limits, per-state and global. &lt;code&gt;INVALID_INPUT&lt;/code&gt; is a malformed number. Treating these as one generic error throws away every signal you need: three of them say "retry later", one says "retry somewhere else", one says "never retry this".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not every fault is a code.&lt;/strong&gt; Pass a requester country that is not an EU member state and production VIES answers with a human-readable &lt;code&gt;Invalid Requester member state&lt;/code&gt; instead of a documented code. Pass an invalid requester &lt;em&gt;VAT&lt;/em&gt; under a valid country and you get the exact &lt;code&gt;INVALID_REQUESTER_INFO&lt;/code&gt; string. Same class of error, two different response formats, and only one of them is in the docs. If you match on exact codes only, the first one falls through to your generic 502 handler.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;---&lt;/code&gt; is not an address.&lt;/strong&gt; VIES fills unavailable name and address fields with a literal three-hyphen placeholder. It is a string, it is truthy, and it will end up rendered in your UI and stored in your database as a company name unless you normalise it to null.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;checkVat&lt;/code&gt; and &lt;code&gt;checkVatApprox&lt;/code&gt; disagree about field names.&lt;/strong&gt; The plain call returns &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;address&lt;/code&gt;. The approx call returns &lt;code&gt;traderName&lt;/code&gt;, and an address that may arrive flat as &lt;code&gt;traderAddress&lt;/code&gt; or split across &lt;code&gt;traderStreet&lt;/code&gt;, &lt;code&gt;traderPostcode&lt;/code&gt; and &lt;code&gt;traderCity&lt;/code&gt;. Same data, three shapes, depending on the member state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A mandatory field can be empty.&lt;/strong&gt; &lt;code&gt;checkVatApprox&lt;/code&gt; returns a &lt;code&gt;requestIdentifier&lt;/code&gt; — the consultation number, the Commission-issued evidence that a check took place, the thing you keep next to the invoice when a tax authority asks you to prove you validated a customer. The schema declares it mandatory. It is also schema-legal to return it as an empty string. A response can therefore be simultaneously valid, successful, and useless as evidence. Fail loudly on that rather than storing the empty string and finding out during an audit.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why national registers are a worse fallback than they look
&lt;/h2&gt;

&lt;p&gt;The obvious move when a member state's VIES node is down is to ask that country's own register directly. It works, but only for some countries, and the reason is not technical.&lt;/p&gt;

&lt;p&gt;A national &lt;strong&gt;VAT register&lt;/strong&gt; and a national &lt;strong&gt;company register&lt;/strong&gt; answer different questions. Poland's MF, Romania's ANAF and the Czech ARES &lt;code&gt;dic&lt;/code&gt; status expose the VAT register itself — asking them "is this VAT number registered" gets you the same answer VIES would have given. Those are real fallbacks.&lt;/p&gt;

&lt;p&gt;Finland's PRH, France's SIRENE, Denmark's CVR, Sweden's Bolagsverket, the Dutch KVK, the Belgian CBE are company registers. A company can be listed and active there without being VAT-registered at all — below the threshold, on a small-business exemption, or exempt by activity. Some of them exclude sole traders entirely. Deriving &lt;code&gt;valid: true&lt;/code&gt; from "the company exists in the register" produces false positives precisely when you are least able to check them, which is during an outage. We use those sources to enrich a result, never to decide one.&lt;/p&gt;

&lt;p&gt;Germany is the interesting case. The BZSt runs &lt;code&gt;eVatR&lt;/code&gt;, which looks like the answer to Germany's nightly window. It is not: it only answers a German business asking about a &lt;em&gt;foreign&lt;/em&gt; VAT number. Confirming a German number through it requires an entitlement we do not hold, and every request we made returned &lt;code&gt;evatr-0006&lt;/code&gt;, not authorised. Germany's nightly window has no fallback. You wait, or you queue.&lt;/p&gt;
&lt;h2&gt;
  
  
  What to do with this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Track availability per country, not globally.&lt;/strong&gt; A single circuit breaker in front of VIES opens because Latvia is having an afternoon and stops you from validating the other 27.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not schedule German validation between 21:00 and 01:00 UTC.&lt;/strong&gt; If your nightly batch runs at midnight UTC, it currently fails every German number, every night, and the fix is a cron expression.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make backoff per-country and jittered.&lt;/strong&gt; Latvia's shape needs minutes of spacing, not seconds. Retrying four times in ninety seconds is four failures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distinguish "unavailable" from "invalid".&lt;/strong&gt; They are different words in the response and they must be different words in your database. A number that could not be checked is not a number that failed the check — and treating an outage as an invalid VAT ID is how you accidentally charge a B2B customer domestic VAT.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queue, don't block.&lt;/strong&gt; Nothing in a checkout flow should sit on a synchronous call to a service that is switched off for three hours a night.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  What this does not measure
&lt;/h2&gt;

&lt;p&gt;The limits, plainly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is the Commission's self-report.&lt;/strong&gt; We sample what VIES says about its member states, not what a &lt;code&gt;checkVat&lt;/code&gt; call returns. An independent probe would be a stronger instrument, and it is the obvious next thing to build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The window is 5 days.&lt;/strong&gt; Long enough to establish that Germany's window repeated on every night observed. Not long enough for a monthly or annual figure, and we make no such claim.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ireland's 95.44% is one event.&lt;/strong&gt; A single six-hour outage in a short window produces a percentage that looks like a reliability rating and is not one. The same is true in reverse for the 15 states at 100%: five clean days is not a guarantee.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Availability is not correctness.&lt;/strong&gt; A member state can be up and still return a stale or wrong answer. We do not measure that here.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;The data behind every chart is public and unauthenticated:&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;-s&lt;/span&gt; https://api.vatnode.dev/v1/vies-status | jq &lt;span class="s1"&gt;'.uptime'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That returns the per-country uptime, the sample counts, and the weekday × hour heatmap the charts are built from. There is a rendered version at &lt;a href="https://vatnode.dev/vies-status" rel="noopener noreferrer"&gt;vatnode.dev/vies-status&lt;/a&gt;, updated continuously, and the underlying Commission endpoint is at &lt;code&gt;ec.europa.eu/taxation_customs/vies/rest-api/check-status&lt;/code&gt; if you would rather sample it yourself.&lt;/p&gt;

&lt;p&gt;The first chart in this post is the whole of this script, standard library only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.vatnode.dev/v1/vies-status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uptime&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uptime30d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uptime30d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;filled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;missed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sampleCount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;countryCode&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;█&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;filled&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;░&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;28&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;filled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
          &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;6.2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;%  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;missed&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; missed&lt;/span&gt;&lt;span class="sh"&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 hour-of-day charts come out of the same document: &lt;code&gt;data["countryHeatmaps"]["DE"]&lt;/code&gt; is a list of weekday × hour cells, each with an availability fraction and a sample count. Group by &lt;code&gt;hourUtc&lt;/code&gt;, weight each cell by its &lt;code&gt;samples&lt;/code&gt;, and you have the German profile above.&lt;/p&gt;

&lt;p&gt;If you want the retry and fallback behaviour described here without building it, that is what &lt;a href="https://vatnode.dev/" rel="noopener noreferrer"&gt;vatnode&lt;/a&gt; does.&lt;/p&gt;

</description>
      <category>tax</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Structured Logging in Next.js with Pino (Request IDs to stdout)</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Fri, 28 Aug 2026 10:00:59 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/structured-logging-in-nextjs-with-pino-request-ids-to-stdout-5c0f</link>
      <guid>https://dev.to/iurii_rogulia/structured-logging-in-nextjs-with-pino-request-ids-to-stdout-5c0f</guid>
      <description>&lt;p&gt;A request failed in production at 02:14 and the only thing you have is &lt;code&gt;console.log("error", err)&lt;/code&gt; somewhere in a route handler. You cannot tell which user hit it, which path, how long it took, or whether the three log lines you found even belong to the same request. That is the situation structured logging exists to prevent.&lt;/p&gt;

&lt;p&gt;I run this site on a self-hosted VPS with Coolify — no platform log aggregation doing the work for me. Every request produces one JSON line on stdout, carries an ID I can grep for, and never prints a secret. This is the exact setup, straight from the codebase — not a toy example.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Structured, Not Strings
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;console.log&lt;/code&gt; gives you strings. Strings are fine until you need to answer a question like "show me every 500 on &lt;code&gt;/api/contact&lt;/code&gt; in the last hour that took over a second." You cannot query a string. You end up writing fragile regexes against your own log format.&lt;/p&gt;

&lt;p&gt;Structured logging means every log line is a JSON object with named 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;"level"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"info"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"time"&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-08-21T00:14:03.921Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"service"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"requestId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a1f3..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/contact"&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="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;84&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[iurii.rogulia.fi] POST /api/contact 200 84ms"&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;Now the question is a filter: &lt;code&gt;level=error AND path="/api/contact" AND ms&amp;gt;1000&lt;/code&gt;. Any log processor — &lt;code&gt;jq&lt;/code&gt; locally, or a hosted backend if you add one later — can answer it. The &lt;code&gt;msg&lt;/code&gt; field stays human-readable so you can also just eyeball the stream during development.&lt;/p&gt;

&lt;p&gt;The point isn't the tool. It's the shape. Decide on your fields once, emit them everywhere, and every future question becomes a query instead of an archaeology project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Logger
&lt;/h2&gt;

&lt;p&gt;Pino is a fast JSON logger for Node. The whole configuration is nine lines:&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;// lib/logger.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;pino&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pino&lt;/span&gt;&lt;span class="dl"&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;logger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pino&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;level&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;LOG_LEVEL&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;info&lt;/span&gt;&lt;span class="dl"&gt;"&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;pid&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;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;msgPrefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[iurii.rogulia.fi] &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pino&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdTimeFunctions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isoTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;formatters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;label&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="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;label&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;Every choice here earns its place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;level&lt;/code&gt; from an env var&lt;/strong&gt; — default &lt;code&gt;info&lt;/code&gt;, but I can drop to &lt;code&gt;debug&lt;/code&gt; in one deploy without touching code. Levels below the current one are compiled out, so &lt;code&gt;logger.debug()&lt;/code&gt; calls cost almost nothing in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;base&lt;/code&gt;&lt;/strong&gt; adds &lt;code&gt;pid&lt;/code&gt; and &lt;code&gt;service&lt;/code&gt; to every line automatically. In a multi-process or multi-service setup you can tell log streams apart without threading the value through every call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;msgPrefix&lt;/code&gt;&lt;/strong&gt; tags the human-readable message. Useful when the same log backend collects several apps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;timestamp: isoTime&lt;/code&gt;&lt;/strong&gt; writes ISO-8601 with a &lt;code&gt;Z&lt;/code&gt; suffix instead of pino's default epoch milliseconds. Machine-sortable, timezone-unambiguous, and it matches what I'd want to paste into an incident timeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;formatters.level&lt;/code&gt;&lt;/strong&gt; logs the level as its string label (&lt;code&gt;"info"&lt;/code&gt;, &lt;code&gt;"error"&lt;/code&gt;) instead of pino's default numeric code. I'd rather read &lt;code&gt;level:"error"&lt;/code&gt; than memorize that &lt;code&gt;50&lt;/code&gt; means error.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pino writes to stdout. That's deliberate — in a container, the application should not care where logs go. It writes lines to stdout, and the platform (Docker, Coolify, whatever collects the stream) decides on rotation, shipping, and retention. The &lt;a href="https://12factor.net/logs" rel="noopener noreferrer"&gt;twelve-factor&lt;/a&gt; rule holds: treat logs as an event stream, not a file the app manages.&lt;/p&gt;

&lt;h2&gt;
  
  
  One ID Per Request
&lt;/h2&gt;

&lt;p&gt;A single request touches middleware first, then a route handler. If each of those logs independently with no shared identifier, you cannot reconstruct what happened. The fix is a request ID minted once and propagated.&lt;/p&gt;

&lt;p&gt;In Next.js 16, middleware lives in &lt;code&gt;proxy.ts&lt;/code&gt; (renamed from &lt;code&gt;middleware.ts&lt;/code&gt; — I covered that migration &lt;a href="https://iurii.rogulia.fi/blog/nextjs-middleware-to-proxy" rel="noopener noreferrer"&gt;here&lt;/a&gt;). This runs on every matched request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// proxy.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;randomUUID&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;logger&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/logger&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&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;requestId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-request-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;randomUUID&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="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&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;ua&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user-agent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="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;shouldSkip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getIp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nx"&gt;ua&lt;/span&gt;&lt;span class="p"&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="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&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;path&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&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;headers&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-request-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestId&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things carry the whole design:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generate or pass through.&lt;/strong&gt; &lt;code&gt;request.headers.get("x-request-id") ?? randomUUID()&lt;/code&gt;. If an upstream proxy or the client already set an &lt;code&gt;x-request-id&lt;/code&gt;, we honor it. If not, we mint one. This is what lets an ID survive across service boundaries — a reverse proxy in front, or a client that wants to correlate its own request with your logs, sets the header once and every hop reuses it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Echo it back.&lt;/strong&gt; &lt;code&gt;res.headers.set("x-request-id", requestId)&lt;/code&gt;. The response carries the ID back to the caller. Now a user reporting a bug can hand you the ID from their network tab, and you grep one string to find their exact request. That one behavior turns "it broke sometime this afternoon" into a five-second lookup.&lt;/p&gt;

&lt;p&gt;The IP extraction is worth its own function, because behind a proxy &lt;code&gt;request.ip&lt;/code&gt; is the proxy, not the user:&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;getIp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&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="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-forwarded-for&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&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="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-real-ip&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&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;&lt;code&gt;x-forwarded-for&lt;/code&gt; is a comma-separated chain; the first entry is the original client. Fall back to &lt;code&gt;x-real-ip&lt;/code&gt;, then to &lt;code&gt;"unknown"&lt;/code&gt; so the field is always present. Never a &lt;code&gt;null&lt;/code&gt; field to trip up a downstream query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skip the Noise
&lt;/h2&gt;

&lt;p&gt;Logging every request sounds thorough until your log stream is 90% favicon fetches and uptime pings. The skip list keeps signal high:&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;UPTIME_UA_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/SentryUptimeBot|uptime|healthcheck|pingdom|statuscake|betteruptime/i&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;shouldSkip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&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;ua&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;UPTIME_UA_RE&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;ua&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&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;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/health&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/health&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="kc"&gt;true&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;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/favicon.ico&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/robots.txt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/sitemap.xml&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="kc"&gt;true&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="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.(&lt;/span&gt;&lt;span class="sr"&gt;svg|ico|png|jpg|jpeg|webp|js|css|map|woff2&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;|ttf&lt;/span&gt;&lt;span class="se"&gt;)(\?&lt;/span&gt;&lt;span class="sr"&gt;|$&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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 path-based rules are obvious: static assets, favicon, robots, sitemap — none of these are interesting when you're debugging a real request. The extension regex catches them in bulk, with &lt;code&gt;(\?|$)&lt;/code&gt; so a query string doesn't defeat the match.&lt;/p&gt;

&lt;p&gt;The user-agent rule is the non-obvious part, and it comes from a real problem. Uptime probes hit real pages. &lt;code&gt;SentryUptimeBot&lt;/code&gt; pings &lt;code&gt;/&lt;/code&gt;, which is your homepage — you cannot filter it by path without also silencing real visitors. So health checkers get matched by User-Agent instead. Miss this and every 30-second uptime ping becomes a log line, and your actual traffic drowns.&lt;/p&gt;

&lt;p&gt;One caveat: filtering by User-Agent is a signal-to-noise decision, not a security control. A UA is trivially spoofed, so this is fine for "stop logging my own monitoring" and wrong for anything that needs to be trustworthy. Keep it in the right category.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap the Route Handlers
&lt;/h2&gt;

&lt;p&gt;Middleware logs that a request arrived. It does not log the outcome — status code and latency are decided inside the route handler, after middleware has already returned &lt;code&gt;NextResponse.next()&lt;/code&gt;. For that you wrap the handler:&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;// lib/with-log.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;logger&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/logger&lt;/span&gt;&lt;span class="dl"&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;function&lt;/span&gt; &lt;span class="nf"&gt;withLog&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;C&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;routeName&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;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;C&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="nx"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="nx"&gt;Response&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;return&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&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="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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;requestId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-request-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&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="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;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;C&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;ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;routeName&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="nx"&gt;ms&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&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;routeName&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;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="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;ms`&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;logger&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;routeName&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="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ms&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&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;routeName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; 500 &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;ms: &lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&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="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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Usage is a one-liner around the handler:&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;// app/api/contact/route.ts&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;POST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withLog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/contact&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;req&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="c1"&gt;// ...handle the request&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&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="na"&gt;ok&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this buys:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Latency on every route.&lt;/strong&gt; &lt;code&gt;start&lt;/code&gt; is captured before the handler runs, &lt;code&gt;ms&lt;/code&gt; computed after. You get timing for free on both the success and failure paths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors logged, then re-thrown.&lt;/strong&gt; The &lt;code&gt;catch&lt;/code&gt; logs at &lt;code&gt;error&lt;/code&gt; level with the caught &lt;code&gt;err&lt;/code&gt;, then &lt;code&gt;throw&lt;/code&gt;s. It does not swallow the error — Next.js and Sentry still see it and produce the correct 500. The wrapper is an observer, not an owner. Swallowing here would be the classic mistake: a logged error that never reaches your error tracker, so the alert never fires.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same request ID.&lt;/strong&gt; &lt;code&gt;withLog&lt;/code&gt; reads &lt;code&gt;x-request-id&lt;/code&gt; from the request headers — the same header middleware stamped upstream — so the middleware line and the handler line share an ID. &lt;code&gt;?? crypto.randomUUID()&lt;/code&gt; is a defensive fallback for the rare path that doesn't pass through middleware.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is deliberate duplication here: both &lt;code&gt;proxy.ts&lt;/code&gt; and &lt;code&gt;with-log.ts&lt;/code&gt; do &lt;code&gt;header ?? randomUUID()&lt;/code&gt;. That is not an accident to refactor away. They run in different runtimes — middleware in the edge/proxy layer, the handler in the Node runtime — and coupling them through a shared import to save four lines trades a real boundary for false tidiness. Two honest reads of the same header beat one clever abstraction.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;&amp;lt;C&amp;gt;&lt;/code&gt; generic keeps the wrapper type-safe: your handler declares its own context type (route params, for instance), the wrapper accepts &lt;code&gt;unknown&lt;/code&gt; from Next.js at the boundary and casts once, inside, where the shape is known.&lt;/p&gt;

&lt;h2&gt;
  
  
  Never Log a Secret
&lt;/h2&gt;

&lt;p&gt;This is the rule that has no exceptions, and structured logging makes it easier to hold — because you log named fields, not "everything in this object."&lt;/p&gt;

&lt;p&gt;The failing patterns are always the same:&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;// don't&lt;/span&gt;
&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&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="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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&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;incoming&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Authorization, cookies&lt;/span&gt;
&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;payload&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// passwords, tokens, PII&lt;/span&gt;
&lt;span class="nx"&gt;logger&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="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;boom&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// config often holds API keys&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The moment you log a whole &lt;code&gt;req&lt;/code&gt;, a whole request body, or a whole config object, you have almost certainly written an &lt;code&gt;Authorization&lt;/code&gt; header, a session cookie, a password field, or an API key into a log line that will sit in a stream for as long as your retention allows.&lt;/p&gt;

&lt;p&gt;The discipline that prevents it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Allowlist fields, never dump objects.&lt;/strong&gt; Log &lt;code&gt;req.method&lt;/code&gt; and &lt;code&gt;req.nextUrl.pathname&lt;/code&gt;, not &lt;code&gt;req&lt;/code&gt;. Every field in the examples above is named on purpose — &lt;code&gt;requestId&lt;/code&gt;, &lt;code&gt;method&lt;/code&gt;, &lt;code&gt;path&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;ms&lt;/code&gt;, &lt;code&gt;ip&lt;/code&gt;, &lt;code&gt;ua&lt;/code&gt;. Nothing is a blind spread.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log the outcome, not the payload.&lt;/strong&gt; For the contact form I log that a submission happened and whether it succeeded. I do not log the message, the email, or the name. The status code answers the operational question; the content is none of the log's business.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors are the trap.&lt;/strong&gt; &lt;code&gt;err&lt;/code&gt; is safe to log — but if you catch and log a whole config or the arguments that caused the failure, secrets ride along inside the error context. Log the message and stack, not the surrounding state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be careful with query strings.&lt;/strong&gt; A path like &lt;code&gt;/reset?token=...&lt;/code&gt; puts a secret in the &lt;code&gt;path&lt;/code&gt; field. If your routes carry sensitive query params, strip them before logging.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Structured logging doesn't magically redact anything. It just means the default is a short list of named fields instead of a firehose — and a short list is something you can actually audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You End Up With
&lt;/h2&gt;

&lt;p&gt;Put together, every request through this site produces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One middleware line&lt;/strong&gt; — request ID, method, path, IP, user-agent — for anything that isn't a static asset or an uptime probe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One handler line&lt;/strong&gt; — the same request ID, plus status and latency — for any route wrapped in &lt;code&gt;withLog&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A response header&lt;/strong&gt; carrying the ID back to the caller.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When something breaks, the path is: get the &lt;code&gt;x-request-id&lt;/code&gt; from the response (or the reporter), grep it, and read the two or three lines that describe exactly what happened and how long each part took. No SSH, no guessing which log lines belong together, no regex against unstructured strings.&lt;/p&gt;

&lt;p&gt;None of this is a large amount of code — a nine-line logger, a middleware function, and a wrapper. That's the point. Observability isn't a platform you buy; it's a shape you commit to and apply consistently. Error tracking (I use Sentry here) tells you &lt;em&gt;that&lt;/em&gt; something failed. Structured logs tell you the &lt;em&gt;sequence&lt;/em&gt; — what the request was, how long it took, and where in the flow it went wrong. You want both, and this is the log half.&lt;/p&gt;

&lt;p&gt;
  slug="technical-consultation"&lt;br&gt;
  text="Running a Next.js app in production and can't reconstruct what happened when a request failed? Reviewing observability — logging, request tracing, error tracking — is exactly the kind of consultation I do."&lt;br&gt;
/&amp;gt;&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://iurii.rogulia.fi/blog/healthcheck-endpoint-production" rel="noopener noreferrer"&gt;Health Check Endpoint in Node.js: Liveness vs Readiness&lt;/a&gt; — the other half of knowing your service is alive&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://iurii.rogulia.fi/blog/nextjs-middleware-to-proxy" rel="noopener noreferrer"&gt;Next.js 16 proxy.ts Migration: From middleware.ts&lt;/a&gt; — why middleware moved and how the file above got its name&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://iurii.rogulia.fi/blog/idempotency-keys-api-retries" rel="noopener noreferrer"&gt;Idempotency Keys for API Retries&lt;/a&gt; — correctness under retries, where a shared request ID also earns its keep&lt;/li&gt;
&lt;li&gt;&lt;a href="https://getpino.io/" rel="noopener noreferrer"&gt;pino documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://12factor.net/logs" rel="noopener noreferrer"&gt;The Twelve-Factor App — Logs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>pino</category>
      <category>observability</category>
    </item>
    <item>
      <title>Fake Tax Return and Transcript Fraud: The PDF Forensic Layer 4506-C Misses</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Fri, 28 Aug 2026 10:00:45 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/fake-tax-return-and-transcript-fraud-the-pdf-forensic-layer-4506-c-misses-6b3</link>
      <guid>https://dev.to/iurii_rogulia/fake-tax-return-and-transcript-fraud-the-pdf-forensic-layer-4506-c-misses-6b3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/fake-tax-return-transcript-fraud-lending" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt;. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A borrower applies for a mortgage and uploads a copy of last year’s Form 1040 along with an IRS tax return transcript. The adjusted gross income on the transcript tracks the 1040. The wage and Schedule C lines reconcile. The taxpayer name, the filing status, and the IRS header formatting all look right. Your processor reviews the file against the income checklist, finds the tax documents complete and internally consistent, and clears it to move the loan forward while the 4506-C request goes out to the IRS.&lt;/p&gt;

&lt;p&gt;Every step of that review was done correctly. And the income on those tax documents could still be inflated.&lt;/p&gt;

&lt;p&gt;The reason is that the manual review confirms whether the &lt;em&gt;numbers on the page are plausible and self-consistent&lt;/em&gt;. It was never built to confirm whether the &lt;em&gt;PDF itself&lt;/em&gt; was edited after it left the tax-preparation software or the IRS. Those are two different questions. A borrower who downloads a real 1040 or transcript, opens it in an editor, and types a larger AGI over the original defeats the second question entirely — while passing the first, and while the lender waits days for the IRS pull that would have caught it.&lt;/p&gt;

&lt;p&gt;This article walks through how lenders verify tax documents today, exactly where the 4506-C and IVES process leaves a timing gap, and the structural PDF forensic layer that flags an altered 1040 or forged transcript the moment it lands — before the IRS comes back. It is written for underwriting and risk operations teams at mortgage and consumer lenders, with a short integration section at the end for the people who wire it in.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Lenders Verify Tax Documents Today
&lt;/h2&gt;

&lt;p&gt;Tax-document verification is one of the most disciplined controls in lending, because tax income drives the debt-to-income ratio for self-employed and variable-income borrowers, and the DTI drives the approval. No single document carries the decision. Underwriters stack several checks and look for them to agree.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read the 1040 against the income picture.&lt;/strong&gt; The underwriter confirms total income, adjusted gross income, and the supporting schedules — Schedule C for self-employment, Schedule E for rental, Schedule B for interest and dividends. The figures are checked against the application, the borrower’s stated occupation, and any paystubs or profit-and-loss statements in the file. Numbers that do not reconcile across the documents get flagged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compare the borrower’s copy to the IRS transcript.&lt;/strong&gt; Many lenders ask the borrower to supply an IRS tax return transcript or a tax account transcript alongside their own 1040 copy. The transcript restates the key lines the IRS has on record, so a borrower-supplied 1040 that disagrees with the transcript is a problem the underwriter is trained to catch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pull the transcript directly through 4506-C and IVES.&lt;/strong&gt; This is the strongest control. Through a signed IRS Form 4506-C, the lender authorizes an Income Verification Express Service (IVES) participant to request tax transcripts straight from the IRS. Because the transcript comes from the IRS rather than from the borrower, it is one of the few checks a forger cannot tune in advance. When the 4506-C transcript comes back and matches the documents in the file, the income is corroborated by a source the borrower never touched.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reconcile against other income documents.&lt;/strong&gt; The tax return is cross-checked against W-2s, 1099s, bank statements, and paystubs already in the file. A 1040 that contradicts the borrower’s own pay history or deposit record is a flag the underwriter follows up.&lt;/p&gt;

&lt;p&gt;Each of these is a genuine, valuable control. Stacked together they catch a great deal of fraud: clumsy fakes, income that doesn’t reconcile, returns that contradict the transcript. The question is not whether this process works. It is &lt;em&gt;what it was built to verify&lt;/em&gt; — and where the uploaded PDF slips between the controls, specifically in the window before the IRS pull clears.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Timing Gap the 4506-C Leaves Open
&lt;/h2&gt;

&lt;p&gt;The 4506-C transcript pulled directly through IVES is the right answer to tax-document fraud whenever it applies, and it would be dishonest to suggest otherwise. When the IRS transcript comes back and matches, the income is confirmed by a source the borrower never handled — there is nothing the borrower could have edited. Keep it. HTPBE does not replace the 4506-C.&lt;/p&gt;

&lt;p&gt;But the 4506-C process has timing and coverage boundaries, and tax-document fraud concentrates exactly at those boundaries.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The IRS pull takes time.&lt;/strong&gt; A 4506-C request routed through an IVES participant does not return instantly. Turnaround commonly runs from a couple of days to over a week, and it lengthens during peak filing season, after IRS system outages, or when a transcript is not yet available. In that window, the loan is moving and decisions are being made on the borrower’s own uploaded copies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Current-year income isn’t on file yet.&lt;/strong&gt; A transcript reflects returns the IRS has already processed. For a recently filed return — or current-year income that hasn’t been filed at all — the IRS does not have the data yet, so the underwriter leans on the borrower-supplied 1040 and schedules to fill the gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-approval and early underwriting run ahead of the pull.&lt;/strong&gt; To keep the loan moving and give the borrower a rate, lenders often issue pre-approvals and early conditions on the documents in hand — before the 4506-C transcript has cleared. The altered PDF does its work in that interval.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transcript mismatches need a reason to be investigated.&lt;/strong&gt; When a transcript and a borrower copy disagree, someone still has to notice it, escalate, and investigate. A forger who edits &lt;em&gt;both&lt;/em&gt; the 1040 copy and a borrower-supplied transcript image to agree with each other removes the easy contradiction that would have triggered the escalation in the first place.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In every one of these cases, the lender is back to a human reviewing an uploaded 1040 or transcript PDF — the exact scenario where a content review can’t see an edit. The 4506-C removes the document from the equation for the borrowers and timelines it covers. For everyone else, and for the days before the pull clears, the document is the evidence, and its integrity goes unchecked. KYC and identity platforms do not fill this gap either: they confirm &lt;em&gt;who&lt;/em&gt; is applying — ID match, watchlist, face check — not whether a submitted tax PDF was altered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The One Thing the Review Doesn’t Check
&lt;/h2&gt;

&lt;p&gt;Read the verification list again and notice the common thread: every control either confirms the numbers on the page are plausible and self-consistent, or reaches outside the document to the IRS. When the 4506-C path has not yet cleared and the underwriter is left with the uploaded file, all that remains is a content review — and a content review asks one question: “Do these tax figures fit together and look like a real return?”&lt;/p&gt;

&lt;p&gt;It never asks the other question: “Was this file edited after the tax software or the IRS produced it?”&lt;/p&gt;

&lt;p&gt;That is the gap a careful forger exploits. Consider the most common tax-document fraud in lending:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The borrower downloads their real 1040 PDF from their tax-preparation software, or pulls a genuine tax return transcript from their IRS online account.&lt;/li&gt;
&lt;li&gt;They open it in a desktop or online PDF editor.&lt;/li&gt;
&lt;li&gt;They type a higher adjusted gross income over the original, adjust the wage or Schedule C lines so the totals still add up, and recompute the dependent figures so the return reconciles internally.&lt;/li&gt;
&lt;li&gt;They re-save and upload it as their income documentation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result is a document built on the IRS’s or the tax software’s real template, in the right fonts, with the right header formatting — and, critically, the forger had every chance to make the edited numbers &lt;em&gt;internally consistent&lt;/em&gt;. They can raise the AGI and adjust the supporting lines so the arithmetic still works. They can pick a figure that supports the DTI they need without making it look implausible. A patient borrower produces a file that passes every content check on the underwriter’s list, because the content was tuned specifically to pass those checks — and it does so days before the 4506-C transcript that would have contradicted it arrives.&lt;/p&gt;

&lt;p&gt;Doctored IRS transcripts work the same way. A borrower starts from a real transcript pulled from their IRS account, edits the AGI, total income, or taxable income lines, and exports a clean-looking page. On screen, an edited figure looks exactly like an original one — the editor renders it in the same font, at the same position. The human eye has nothing to catch. And because the borrower can edit both their 1040 copy and a transcript image to agree, the cross-check that was supposed to catch the lie is satisfied.&lt;/p&gt;

&lt;p&gt;This is why tax-document and income fraud is so persistent. Industry studies have long put the share of income documents submitted to lenders that are fabricated or altered in the high single digits to one in five, depending on the channel. Tools to edit a fake tax form are openly marketed online for a few dollars, and a careful edit takes only minutes. These documents are easy to alter and — once edited carefully — invisible to a process built to verify content rather than file integrity. They are also slow to be contradicted by an IRS pull that arrives days later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Missing Layer: Structural PDF Forensics
&lt;/h2&gt;

&lt;p&gt;A PDF is not a flat picture. It is a structured file that carries an internal record of how it was built and saved — when it was created, what software produced it, whether it holds a digital signature, and how many times it was written to disk. When someone opens a tax program’s original 1040 or an IRS transcript and saves an edit, that act leaves traces in the file’s structure, no matter how plausible the visible numbers are.&lt;/p&gt;

&lt;p&gt;Structural forensics reads that internal record and returns a verdict. It never judges whether a $180,000 AGI is a believable income — that’s the underwriter’s job and the content layer’s job. It judges whether the file’s own construction is consistent with a clean, single-pass export from tax software or an IRS system, or whether it bears the marks of having been opened and re-saved in an editing tool after the fact.&lt;/p&gt;

&lt;p&gt;HTPBE is a self-serve, developer-first API that performs exactly this analysis. You send it a PDF; it returns one of three verdicts plus a list of named markers describing what it found. It is &lt;strong&gt;not&lt;/strong&gt; a KYC or identity vendor and was never meant to be one — it answers a single, narrow question: &lt;em&gt;was this file altered after it was created?&lt;/em&gt; Critically for lending, it answers that question in seconds, on the file in hand, while the 4506-C is still in flight.&lt;/p&gt;

&lt;h3&gt;
  
  
  The three verdicts
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;intact&lt;/code&gt;&lt;/strong&gt; — no evidence of post-creation modification, and the file looks like a genuine institutional export. The structural layer found nothing to flag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;modified&lt;/code&gt;&lt;/strong&gt; — the file carries forensic evidence that it was changed after it was first generated. This is the case that matters most for fraud: a 1040 or transcript that should be a clean export but instead shows the fingerprints of an editing session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;inconclusive&lt;/code&gt;&lt;/strong&gt; — the file was produced by consumer software, an online editor, or a scanner, so there is no institutional baseline to check integrity against. This is &lt;strong&gt;not a pass.&lt;/strong&gt; It is a routing signal, and in tax-document verification it is often the most useful verdict of all (more on that below).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What it actually catches — in plain terms
&lt;/h3&gt;

&lt;p&gt;Without turning this into a how-to for forgers, here is the kind of structural evidence the analysis surfaces, described as outcomes rather than recipes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Editing-tool fingerprints.&lt;/strong&gt; Genuine 1040s come out of tax-preparation software, and IRS transcripts come out of IRS systems. When a file instead carries the signature of a consumer PDF editor or an online conversion service, that origin is inconsistent with a real tax-document pipeline. The marker &lt;code&gt;HTPBE_EDITING_TOOL_FINGERPRINT&lt;/code&gt; flags this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disagreeing internal timestamps.&lt;/strong&gt; A clean export’s creation and modification timestamps line up. When they contradict each other — the file claims to have been created on one date but was last written days later — that gap is recorded in the file structure even though it is invisible on the page. &lt;code&gt;HTPBE_DATES_DISAGREE&lt;/code&gt; covers this, and it is one of the most conclusive signals there is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple revision layers.&lt;/strong&gt; Each time a PDF is edited and saved, the change is appended as a new layer on top of the original. A tax document that should have been generated in a single pass but instead shows several stacked write sessions reveals that it was modified after creation. The marker is &lt;code&gt;HTPBE_MULTIPLE_REVISION_LAYERS&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A spoofed institutional producer.&lt;/strong&gt; Some tools try to disguise their involvement by rewriting the file’s stated generator to impersonate legitimate tax software or an IRS system. When that producer identity has been forged to mask the real origin, the analysis flags it with &lt;code&gt;HTPBE_PRODUCER_IDENTITY_FORGED&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Targeted character and glyph edits.&lt;/strong&gt; When specific figures on a return — an AGI line, a wage box, a Schedule C total — have been overwritten character by character or glyph by glyph by a desktop editor, the analysis can surface the localized edit. The markers &lt;code&gt;HTPBE_CHARACTER_OVERLAY_EDIT&lt;/code&gt; and &lt;code&gt;HTPBE_GLYPH_LEVEL_EDIT&lt;/code&gt; describe this pattern: individual numbers replaced after the document was authored, while everything around them was left untouched.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these depend on the visible numbers being implausible. A perfectly reconciled, perfectly tidy inflated AGI still sits inside a file that was edited — and the edit is what gets caught, on day zero, not after the IRS responds.&lt;/p&gt;

&lt;p&gt;
  Want to see it on a real document? You can drop a 1040 or an IRS transcript into the free check on
  this site and get a verdict in a few seconds — no account needed. It’s the same engine
  the API runs, and it’s a fast way to sanity-check a single suspicious tax document before
  you decide whether to wire it into your intake.
&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;inconclusive&lt;/code&gt; Means in a Tax-Document Context
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;inconclusive&lt;/code&gt; verdict is the one that confuses people, so it is worth being precise.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;inconclusive&lt;/code&gt; means the file was created in software that anyone can use to build a document from scratch — Word, Excel, an online editor, a scanner. Because there is no institutional baseline, the structural layer cannot say whether the content was tampered with. It is telling you, honestly, “I cannot verify integrity here.”&lt;/p&gt;

&lt;p&gt;The power of that verdict comes from context — specifically, &lt;em&gt;where the document claims to come from.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;IRS transcripts and 1040 exports from mainstream tax software have a recognizable institutional shape. A document that genuinely came from the IRS or a major tax-preparation product should look like an institutional export. So if a borrower hands you an “IRS tax return transcript” and the analysis returns &lt;code&gt;inconclusive&lt;/code&gt; because the file was actually built in a word processor, rebuilt by a print-to-PDF driver, or run through an online editor, the verdict is doing real work: a real transcript pulled from the IRS would not look like that. That mismatch is a reason to insist on the 4506-C transcript before the income counts — not to approve the file on its face.&lt;/p&gt;

&lt;p&gt;The same &lt;code&gt;inconclusive&lt;/code&gt; verdict on a self-employed borrower’s own profit-and-loss export, or a return printed and re-scanned at a branch, is routine. The verdict is identical; the action depends on whether the claimed source is supposed to produce institutional files. Used this way, &lt;code&gt;inconclusive&lt;/code&gt; is not a dead end — it is often the signal to fall back to the 4506-C pull the document was standing in for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest Limits — What This Layer Cannot Catch
&lt;/h2&gt;

&lt;p&gt;Structural forensics is a powerful additional control, not a fraud oracle. Two scenarios sit outside its reach, and a serious underwriting team should know them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Documents fabricated entirely from scratch in the right kind of software.&lt;/strong&gt; If a forger does not edit a real 1040 but instead builds a fake one from zero using a tool that produces clean, single-pass, institutional-looking output, there is no “original” to compare against and no editing event to detect. The file may look clean structurally because, structurally, it &lt;em&gt;is&lt;/em&gt; a clean single-pass file; it just contains invented data. This is precisely where the 4506-C transcript pulled directly from the IRS remains the right control: it corroborates the income against a source the borrower never touched. The structural layer’s strength is the far more common case — editing a real document — not fabrication from nothing. Use it to catch the edits in the days before the IRS responds, and let the 4506-C finish the job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Legitimately consumer-generated tax documents.&lt;/strong&gt; Some borrowers legitimately export their own records through generic office software, and self-employed filers produce their own statements. For those, &lt;code&gt;inconclusive&lt;/code&gt; is the correct and expected verdict, and reading it as guilt would generate false positives. The signal only carries weight when the claimed source — the IRS, mainstream tax software — has an institutional baseline to deviate from.&lt;/p&gt;

&lt;p&gt;Being upfront about these limits is the point. The structural layer is designed to slot &lt;em&gt;alongside&lt;/em&gt; your existing process — catching the altered-PDF case that a content review and a lagging 4506-C can’t see in the moment — not to be a single switch that decides loans on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring It Into Your Tax-Document Workflow
&lt;/h2&gt;

&lt;p&gt;For teams that decide the structural layer belongs in their intake, integration is deliberately small. The pattern is three steps, and it runs the moment the borrower uploads — not when the IRS responds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Analyze the uploaded document.&lt;/strong&gt; When a borrower uploads a 1040 or a tax transcript, send its URL to the analyze endpoint. There is no numeric risk score to interpret — you get a verdict and named markers.&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/income/applicant-7821-1040.pdf"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is just the check ID:&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="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;strong&gt;2. Fetch the verdict.&lt;/strong&gt; Retrieve the result by ID. The response is a flat object — the two fields your policy cares about are &lt;code&gt;status&lt;/code&gt; and &lt;code&gt;modification_markers&lt;/code&gt;.&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/506a6b1b-1360-48a2-b389-abb346f85d04 &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;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;"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_markers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"HTPBE_EDITING_TOOL_FINGERPRINT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HTPBE_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;"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="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;strong&gt;3. Route on the verdict.&lt;/strong&gt; Wire the three outcomes into your existing review queue rather than auto-deciding on any single marker:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;modified&lt;/code&gt; → route to manual review, hold the income until the 4506-C transcript clears, or — for the most conclusive markers — reject. The structural flag lets you stop the loan &lt;em&gt;before&lt;/em&gt; the IRS pull rather than discovering the discrepancy after closing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;inconclusive&lt;/code&gt; → branch on the claimed source. A consumer-software origin on a document claimed to be an IRS transcript or a mainstream-tax-software 1040 is worth insisting on the direct 4506-C pull; the same verdict from a self-employed borrower’s own export is routine.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;intact&lt;/code&gt; → no structural evidence of alteration; proceed with your normal verification while the transcript request runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Store the check ID against the loan file. If a credit decision is ever disputed or audited, the forensic result stays available as a permanent record showing exactly which structural signals fired — useful for both compliance and repurchase defense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who Should Add This Layer
&lt;/h2&gt;

&lt;p&gt;If you run underwriting or fraud operations at a mortgage lender, a consumer lender, or any shop where borrowers upload 1040s and IRS transcripts — especially where the 4506-C transcript lags the decision, or current-year income isn’t on file with the IRS yet — this is the gap in your stack worth closing. Your underwriters are already verifying income well, and your 4506-C process is the right backstop. What neither can see, in the days before the IRS responds, is whether the file in front of them was edited after the tax software or the IRS produced it. That blind spot is exactly where careful borrowers operate, and it’s exactly what a structural PDF forensic layer covers — in seconds, on day zero.&lt;/p&gt;

&lt;p&gt;For the closely related case of altered paystubs and W-2s, see &lt;a href="https://htpbe.tech/blog/altered-paystub-w2-mortgage-underwriting" rel="noopener noreferrer"&gt;the forensic layer mortgage underwriting misses on income documents&lt;/a&gt;; for doctored bank statements, see &lt;a href="https://htpbe.tech/blog/how-lenders-verify-bank-statements" rel="noopener noreferrer"&gt;how lenders verify bank statements and the forgery layer they miss&lt;/a&gt;; and for the broader picture across the funnel, &lt;a href="https://htpbe.tech/blog/pdf-fraud-detection-loan-origination" rel="noopener noreferrer"&gt;PDF fraud detection in loan origination&lt;/a&gt; ties the document types together. You can also read how the structural layer fits &lt;a href="https://htpbe.tech/use-cases/fake-tax-document-detection" rel="noopener noreferrer"&gt;fake tax-document detection&lt;/a&gt; and &lt;a href="https://htpbe.tech/use-cases/mortgage" rel="noopener noreferrer"&gt;mortgage origination&lt;/a&gt; specifically. When you’re ready to put a verdict behind your tax-document intake, the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;self-serve API&lt;/a&gt; is documented end-to-end with test keys you can wire up before you spend a credit, and &lt;a href="https://htpbe.tech/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; starts at a tier built for a single underwriting team.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>fintech</category>
      <category>fraud</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The VIES Consultation Number for Finance Teams</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Fri, 28 Aug 2026 09:00:43 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/the-vies-consultation-number-for-finance-teams-31eh</link>
      <guid>https://dev.to/iurii_rogulia/the-vies-consultation-number-for-finance-teams-31eh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/consultation-number-for-finance" 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;
  
  
  The VIES Consultation Number for Finance Teams
&lt;/h1&gt;

&lt;p&gt;When an auditor reviews a zero-rated intra-EU invoice, one of the things they can ask is a simple question: how do you know the customer's VAT number was valid when you raised it? A screenshot of a VIES page from last week does not answer that. The VIES consultation number does — it is a reference the European Commission's system issues that ties a specific VAT number, a specific date and time, and a specific reply together. This guide is about why that reference matters to a finance, tax, or audit team, and what it does and does not prove. It is the business companion to the technical write-up on &lt;a href="https://vatnode.dev/blog/vies-consultation-number-explained" rel="noopener noreferrer"&gt;how to extract and store it&lt;/a&gt; — there is no code here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field auditors ask for
&lt;/h2&gt;

&lt;p&gt;On a VAT audit of cross-border B2B supplies, the reviewer is testing whether you were entitled to not charge VAT. Part of that is substantive — was this genuinely a supply to a taxable person in another member state — and part of it is evidential: can you show you did the checks a diligent business is expected to do?&lt;/p&gt;

&lt;p&gt;For the evidential part, self-produced records help but they are exactly that: self-produced. A line in your own system saying "checked, valid" is your word. The consultation number is different in kind. It is issued by VIES, not by you, and it points back to a validation that VIES itself performed. When &lt;a href="https://europa.eu/youreurope/business/taxation/vat/check-vat-number-vies/index_en.htm" rel="noopener noreferrer"&gt;Your Europe's guidance on checking a VAT number&lt;/a&gt; talks about proving to a tax administration that you checked a number and got a validation reply, the consultation number is the thing it tells you to keep. That is the whole reason it exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the consultation number is, in plain English
&lt;/h2&gt;

&lt;p&gt;VIES has two modes. A plain check answers "is this number valid, yes or no." A &lt;em&gt;requester-qualified&lt;/em&gt; check answers the same question but records who was asking — you supply your own VAT number as the requester alongside the number you are checking. Only the requester-qualified check produces a consultation number.&lt;/p&gt;

&lt;p&gt;Think of it as a receipt. It says: requester &lt;code&gt;X&lt;/code&gt; asked VIES about number &lt;code&gt;Y&lt;/code&gt; on date &lt;code&gt;Z&lt;/code&gt;, and here is the reply. In the raw VIES response the value sits in a field called &lt;code&gt;requestIdentifier&lt;/code&gt; — a short opaque string. You never need to read or interpret it; you store it as text and keep it with the transaction it relates to. That is the extent of what a finance team needs to know about its shape. If a single field reference is useful, it looks like this in a structured 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;"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;"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="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-08-31T09:12:00Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point is not the string. The point is that VIES attests to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters for zero-rated intra-EU B2B invoices
&lt;/h2&gt;

&lt;p&gt;On an intra-EU B2B supply, the seller often does not charge local VAT — the supply may be a zero-rated intra-Community supply of goods, or the place of supply for a service may shift to the buyer under the reverse-charge mechanism. In both cases the buyer accounts for the VAT in their own country, and in both cases one of the conditions is that the buyer is a VAT-registered taxable person in another member state.&lt;/p&gt;

&lt;p&gt;Verifying the buyer's VAT registration is therefore not a nicety, it is one of the things national authorities expect a seller to have done. The consultation number is the cleanest evidence that you did it, and that you did it at a point in time you can name. Where a plain screenshot proves nothing about &lt;em&gt;when&lt;/em&gt;, the consultation number is contemporaneous — it is dated by the system that issued it. That is what makes it useful for an invoice you may have to defend years later.&lt;/p&gt;

&lt;p&gt;Two clarifications keep this honest. First, the consultation number supports proof of verification; it is one input to the treatment, not the treatment itself. Second, whether validation is even required, and how often, depends on the situation — that is covered in &lt;a href="https://vatnode.dev/blog/when-to-validate-customer-vat-numbers" rel="noopener noreferrer"&gt;when validation is required&lt;/a&gt;. If you want the plain-English picture of what VIES is before going further, &lt;a href="https://vatnode.dev/blog/what-is-vies-for-business" rel="noopener noreferrer"&gt;what VIES is, for finance&lt;/a&gt; is the place to start.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does and does not prove
&lt;/h2&gt;

&lt;p&gt;This is where the line has to be drawn precisely, because it is easy to claim too much.&lt;/p&gt;

&lt;p&gt;What it proves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;That you checked a specific VAT number.&lt;/li&gt;
&lt;li&gt;The date and time you checked it.&lt;/li&gt;
&lt;li&gt;The reply VIES gave.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it does not prove:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;That the transaction is exempt or zero-rated. Those depend on substantive conditions — the nature of the supply, evidence of transport for goods, the buyer's status — not on the fact that a check happened.&lt;/li&gt;
&lt;li&gt;That the VAT number was "certified" or that the treatment is secured. The consultation number does not adjudicate the transaction. It is evidence you did your part of the diligence, nothing more.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the accurate framing is narrow: keep the consultation number to prove that you checked, not to prove that the sale was correctly treated. It complements your own records; it does not replace them. If VIES was unreachable and a check fell through to another source, or if your client logged an attempt that never returned, those entries still belong in your trail — the consultation number just will not be on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to keep alongside it
&lt;/h2&gt;

&lt;p&gt;On its own the consultation number is a reference without context. To be usable as evidence, keep it next to the facts that give it meaning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The VAT number you checked&lt;/strong&gt;, in the form you sent it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your requester VAT number&lt;/strong&gt; — the consultation number is meaningless without knowing who asked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The timestamp&lt;/strong&gt; of the check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The reply&lt;/strong&gt; — valid or not, and the trader name and address if returned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The source&lt;/strong&gt; — was this answered by VIES, or by a national fallback source? Only VIES lookups carry a consultation number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The invoice or transaction&lt;/strong&gt; the check belongs to, so the two can be tied together later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last link is what turns a pile of validations into an audit trail. A consultation number that you cannot connect to an invoice does not help anyone. Building the whole chain — check, evidence, invoice, retention — is its own exercise; there is a walkthrough in &lt;a href="https://vatnode.dev/guides/vat-audit-trail" rel="noopener noreferrer"&gt;build the full audit trail&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it belongs in your records
&lt;/h2&gt;

&lt;p&gt;A common question is whether the consultation number goes on the invoice itself. It does not have to, and no EU member state requires it to appear there. Some teams do print a short "VIES ref" near the customer's VAT number as a courtesy signal to the buyer's finance department, but that is a convention, not a legal invoice field. Treat it as internal evidence first.&lt;/p&gt;

&lt;p&gt;Where it does belong is in your validation records, retained on the same footing as the invoice it supports. If your invoices are kept for a given period, the consultation numbers backing them should be kept at least as long — they are only useful for as long as the invoice they defend is open to review. Retention periods differ by jurisdiction, so the specific number of years is a question for your adviser, not for this page.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to make sure you capture it every time
&lt;/h2&gt;

&lt;p&gt;The consultation number is easy to lose without noticing, because a plain valid/invalid answer looks complete on its own. To capture it reliably:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run requester-qualified checks, not plain ones.&lt;/strong&gt; If your validation path does not send a requester VAT number, VIES will answer the question but issue no consultation number. There is nothing to store after the fact — you either asked for it at the time or you did not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persist it immutably.&lt;/strong&gt; Append each check as a new record; never overwrite an earlier one with a later result. The evidence is time-specific, and overwriting destroys it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allow it to be absent.&lt;/strong&gt; When a check is answered by a national fallback source instead of VIES, there is no consultation number, and that is expected. Keep the record anyway, mark the source, and leave the reference blank rather than inventing one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you would rather not manage the VIES call and its edge cases yourself, a &lt;a href="https://vatnode.dev/vies-api" rel="noopener noreferrer"&gt;VIES API that returns the consultation number&lt;/a&gt; does the requester-qualified lookup on every live check and hands you the field as structured data — the mechanics of extracting and storing it are in &lt;a href="https://vatnode.dev/blog/vies-consultation-number-explained" rel="noopener noreferrer"&gt;how to extract and store it&lt;/a&gt;. You can &lt;a href="https://vatnode.dev/check" rel="noopener noreferrer"&gt;run a check&lt;/a&gt; to see the consultation number a live lookup returns.&lt;/p&gt;

&lt;p&gt;This is general information about EU VAT and VIES, not tax advice — whether and how to rely on the consultation number for a specific filing is for a qualified adviser.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  What is a VIES consultation number?
&lt;/h3&gt;

&lt;p&gt;It's the reference VIES issues for a requester-qualified validation — proof that you checked a specific VAT number at a specific time and what reply you got. The Commission's guidance is to keep it in your records as evidence you ran the check.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the consultation number unique to vatnode?
&lt;/h3&gt;

&lt;p&gt;No. VIES issues it; any provider that runs a requester-qualified check can return it. It is a standard part of the VIES response, not something exclusive to one service.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a consultation number returned for every check?
&lt;/h3&gt;

&lt;p&gt;No. It exists only for requester-qualified VIES lookups. A check answered by a national fallback source has no consultation number, so don't assume it's always present.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long should we keep it?
&lt;/h3&gt;

&lt;p&gt;Align it with your invoice retention — the consultation number backs up the invoice it relates to. Retention periods vary by jurisdiction; this is general information, not tax advice.&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  Capture the consultation number on every check
&lt;/h3&gt;

&lt;p&gt;vatnode runs requester-qualified VIES checks — set your EU VAT number once in Settings and every live VIES check then returns the consultation number as a structured field, so your finance records carry the evidence without extra work. Free plan, 100 requests/month.&lt;/p&gt;

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

</description>
      <category>tax</category>
      <category>tutorial</category>
      <category>europe</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Three small design decisions in a "toggle effects via CSS class" library, and the tradeoffs behind them</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Thu, 27 Aug 2026 12:13:45 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/three-small-design-decisions-in-a-toggle-effects-via-css-class-library-and-the-tradeoffs-behind-36c1</link>
      <guid>https://dev.to/iurii_rogulia/three-small-design-decisions-in-a-toggle-effects-via-css-class-library-and-the-tradeoffs-behind-36c1</guid>
      <description>&lt;h1&gt;
  
  
  Three small design decisions in a "toggle effects via CSS class" library, and the tradeoffs behind them
&lt;/h1&gt;

&lt;p&gt;I built halloween.js, a small library that adds Halloween-themed page effects (blinking eyes, flying witches, a dropping spider, screen-corner webs) to any website, driven entirely by CSS classes on &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt;. The effects themselves aren't interesting — CSS animations and a &lt;code&gt;setTimeout&lt;/code&gt; scheduler. What I want to write about are three decisions that turned out harder than they looked once real usage exposed the edge cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Reactive sync via MutationObserver instead of an imperative API
&lt;/h2&gt;

&lt;p&gt;The obvious API for a library like this is imperative: &lt;code&gt;Halloween.start("eyes")&lt;/code&gt;. I built that first, then threw most of it away.&lt;/p&gt;

&lt;p&gt;The problem: this library is meant to be dropped into contexts where you don't control JS execution order — a WordPress header, a page builder, a CMS field that toggles a class based on user state. An imperative API assumes you can call a function at the right moment. In practice, the "right moment" doesn't exist in these environments.&lt;/p&gt;

&lt;p&gt;So instead, the library watches &lt;code&gt;document.body&lt;/code&gt;'s classList:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const observer = new MutationObserver(trySync);
observer.observe(document.body, {
  attributes: true,
  attributeFilter: ['class', 'data-halloween-start', 'data-halloween-end'],
});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Any code, anywhere, adding or removing &lt;code&gt;halloween-eyes&lt;/code&gt; on &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; — a page builder's visual toggle, a &lt;code&gt;classList.toggle()&lt;/code&gt; in unrelated code, a browser extension — gets picked up and re-synced automatically. No init call, no "did this run before or after my class change" race.&lt;/p&gt;

&lt;p&gt;The non-obvious part: when a class is removed, the corresponding effect has to stop &lt;em&gt;immediately&lt;/em&gt;, not after its current animation cycle finishes. If a spider is mid-drop and you flip &lt;code&gt;halloween&lt;/code&gt; off, waiting for the drop-and-climb animation to complete before tearing down the node means up to several seconds of an effect running after it was explicitly turned off — which, from the caller's perspective, looks like a bug ("I removed the class, why is it still animating"). So &lt;code&gt;trySync()&lt;/code&gt; does a hard stop-and-remove on any mutation where the gate condition is now false, rather than a graceful fade-out queued behind the current animation frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Three-source config with per-edge precedence, resolved fresh every time
&lt;/h2&gt;

&lt;p&gt;The library only runs within a season window (defaults to ~2 weeks around Halloween), so it can be left on a page year-round. That window's start/end can come from three places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;code&gt;data-halloween-start&lt;/code&gt;/&lt;code&gt;data-halloween-end&lt;/code&gt; attribute on &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; (highest priority)&lt;/li&gt;
&lt;li&gt;A classic &lt;code&gt;&amp;lt;script src="..."&amp;gt;&lt;/code&gt; tag's own &lt;code&gt;?s=&lt;/code&gt;/&lt;code&gt;?e=&lt;/code&gt; query params, captured once via &lt;code&gt;document.currentScript&lt;/code&gt; while the script is synchronously executing&lt;/li&gt;
&lt;li&gt;A hardcoded library default&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The precedence is resolved independently for the start and end edge — you can override just the end date and let the start fall through to the query param or default. More importantly, it's re-resolved on &lt;em&gt;every&lt;/em&gt; sync call, not cached at load:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;export function getSeasonWindow(): { start: string; end: string } {
  return {
    start: bodyAttrOr('data-halloween-start', CONFIG.seasonStart),
    end: bodyAttrOr('data-halloween-end', CONFIG.seasonEnd),
  };
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;This matters because the &lt;code&gt;data-*&lt;/code&gt; attributes are in the MutationObserver's &lt;code&gt;attributeFilter&lt;/code&gt;. If a page changes &lt;code&gt;data-halloween-end&lt;/code&gt; at runtime (say, a marketing team extends the promotion by a week via a CMS field), the new value takes effect on the very next mutation-triggered sync — no page reload needed. Caching the resolved window at module load, which is what I did in the first draft, silently broke that.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Fail-open on malformed date input, plus a wraparound range and leap-day clamp
&lt;/h2&gt;

&lt;p&gt;Date parsing for &lt;code&gt;DD-MM&lt;/code&gt; strings turned out to have more edge cases than expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Malformed input fails open, not closed.&lt;/strong&gt; If &lt;code&gt;data-halloween-start&lt;/code&gt; is set to garbage (typo, wrong format), the library defaults to &lt;em&gt;running&lt;/em&gt;, not to silently disabling itself:&lt;/p&gt;

&lt;p&gt;export function isWithinSeason(now: Date, startStr: string, endStr: string): boolean {&lt;br&gt;
    const start = parseDDMM(startStr);&lt;br&gt;
    const end = parseDDMM(endStr);&lt;br&gt;
    if (!start || !end) return true;  // fail open&lt;br&gt;
    ...&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reasoning: a config typo that silently turns the whole feature off is a much worse failure mode for this library's use case (a seasonal decoration someone configured once and forgot about) than one that just always runs. A support ticket for "it's running when it shouldn't" is easier to diagnose than "it silently never ran and nobody noticed for a year."&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The range can wrap across New Year's&lt;/strong&gt; (e.g., a &lt;code&gt;25-12&lt;/code&gt;–&lt;code&gt;05-01&lt;/code&gt; window), which flips the comparison from AND to OR:&lt;/p&gt;

&lt;p&gt;if (startDate &amp;lt;= endDate) {&lt;br&gt;
    return now &amp;gt;= startDate &amp;amp;&amp;amp; now &amp;lt;= endDate;&lt;br&gt;
  }&lt;br&gt;
  return now &amp;gt;= startDate || now &amp;lt;= endDate;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;29-02&lt;/code&gt; is accepted as a valid date year-round&lt;/strong&gt;, not just in leap years, and gets clamped to &lt;code&gt;28-02&lt;/code&gt; when resolved against a non-leap year. This avoids a config that silently stops working every three years out of four, which is exactly the kind of intermittent bug that's miserable to track down months after the config was written.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd still call unfinished
&lt;/h2&gt;

&lt;p&gt;The season gate is only re-evaluated when something mutates the watched attributes — there's no polling for "it's now past midnight, recheck the date." If a page is left open across a season boundary with zero DOM mutations in between, the library won't notice until something else touches &lt;code&gt;class&lt;/code&gt; or the &lt;code&gt;data-halloween-*&lt;/code&gt; attributes (a reload, another caller of the public &lt;code&gt;halloween()&lt;/code&gt; re-sync function). For a page that's actually being interacted with this is a non-issue in practice, but it's a real gap I haven't closed, not an oversight I'm unaware of.&lt;/p&gt;




&lt;p&gt;Source: &lt;a href="https://github.com/rogulia/halloween.js" rel="noopener noreferrer"&gt;https://github.com/rogulia/halloween.js&lt;/a&gt;&lt;br&gt;
Live demo: &lt;a href="https://halloween.js.org/" rel="noopener noreferrer"&gt;https://halloween.js.org/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>frontend</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A Typed MDX Content Pipeline with Velite (Next.js Tutorial)</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Wed, 26 Aug 2026 10:00:54 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/a-typed-mdx-content-pipeline-with-velite-nextjs-tutorial-3je7</link>
      <guid>https://dev.to/iurii_rogulia/a-typed-mdx-content-pipeline-with-velite-nextjs-tutorial-3je7</guid>
      <description>&lt;p&gt;Most Next.js blogs read MDX at request time, hand you &lt;code&gt;frontmatter&lt;/code&gt; as &lt;code&gt;any&lt;/code&gt;, and discover a missing &lt;code&gt;date&lt;/code&gt; or a typo in a tag when the page 500s in production. That works until the content set grows past a handful of posts. Then every rename, every new frontmatter field, every tag cleanup becomes a guessing game, because nothing checks the shape of your content until a user hits the route.&lt;/p&gt;

&lt;p&gt;This site works differently. Content is compiled once, validated against Zod schemas, and emitted as typed JSON that the app imports like any other module. If a post is missing a required field, the build fails — not the page. Here's how it's wired, taken straight from the config that ships this site.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem with Reading MDX at Request Time
&lt;/h2&gt;

&lt;p&gt;The common pattern looks like this: a &lt;code&gt;getPostBySlug()&lt;/code&gt; helper reads a file, runs &lt;code&gt;gray-matter&lt;/code&gt;, and returns an object. TypeScript has no idea what's in the frontmatter, so you either cast to &lt;code&gt;any&lt;/code&gt; or hand-write an interface that drifts from reality the moment someone adds a field in an &lt;code&gt;.mdx&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;Three things go wrong as the content grows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No validation.&lt;/strong&gt; A post dated &lt;code&gt;2026-13-01&lt;/code&gt; or missing a &lt;code&gt;summary&lt;/code&gt; sails through until render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No single source of truth.&lt;/strong&gt; The frontmatter shape lives in your head; the TypeScript type lives in a &lt;code&gt;types.ts&lt;/code&gt; that nobody updates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime cost.&lt;/strong&gt; Parsing and compiling MDX on every request, or fighting the router's caching to avoid it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Velite moves all of that to build time. You describe each content collection once, with a schema, and get back validated, typed data plus compiled MDX.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defining a Collection with a Zod-Style Schema
&lt;/h2&gt;

&lt;p&gt;Velite's &lt;code&gt;s&lt;/code&gt; object is a thin wrapper over Zod with content-aware helpers (&lt;code&gt;s.slug()&lt;/code&gt;, &lt;code&gt;s.isodate()&lt;/code&gt;, &lt;code&gt;s.mdx()&lt;/code&gt;, &lt;code&gt;s.raw()&lt;/code&gt;). A collection binds a glob pattern to a schema. Here is the blog collection this site uses, trimmed to the fields that matter:&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;// velite.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineCollection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;velite&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;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineCollection&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;Post&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blog/**/*.mdx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;seoTitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;subtitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;220&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;keyphrase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isodate&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isodate&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dev&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;business&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dev&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;verb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;cta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;280&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;optional&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;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&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="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mdx&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;A few of these earn their place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;s.slug("posts")&lt;/code&gt; validates the slug is URL-safe &lt;strong&gt;and&lt;/strong&gt; unique across the &lt;code&gt;posts&lt;/code&gt; set. Two posts with the same slug is a build error, not a silent overwrite.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;s.isodate()&lt;/code&gt; rejects anything that isn't a real ISO date. The &lt;code&gt;2026-13-01&lt;/code&gt; case above dies here.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;s.enum(["dev", "business"]).default("dev")&lt;/code&gt; is how the editorial track is enforced. A post either declares one of two categories or defaults — you can't invent a third by typo.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;s.raw()&lt;/code&gt; keeps the untouched Markdown source around; &lt;code&gt;s.mdx()&lt;/code&gt; compiles the body to a function string the renderer executes. Having both means you can derive things from the raw text (word count, headings) that the compiled output has already thrown away.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;.max()&lt;/code&gt; limits aren't decoration. A &lt;code&gt;summary&lt;/code&gt; capped at 300 characters and a &lt;code&gt;seoTitle&lt;/code&gt; at 70 mean malformed metadata fails the build instead of quietly breaking a SERP snippet weeks later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharing Schema Fragments
&lt;/h2&gt;

&lt;p&gt;Tags on this site aren't a flat array — they're categorized into &lt;code&gt;stack&lt;/code&gt;, &lt;code&gt;libraries&lt;/code&gt;, &lt;code&gt;databases&lt;/code&gt;, &lt;code&gt;services&lt;/code&gt;, and &lt;code&gt;topics&lt;/code&gt;. That structure is identical across posts, projects, and reviews, so it lives in one schema fragment:&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;tagsSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;([]),&lt;/span&gt;
    &lt;span class="na"&gt;libraries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;([]),&lt;/span&gt;
    &lt;span class="na"&gt;databases&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;([]),&lt;/span&gt;
    &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;([]),&lt;/span&gt;
    &lt;span class="na"&gt;topics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="k"&gt;default&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;default&lt;/span&gt;&lt;span class="p"&gt;({});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each collection reuses &lt;code&gt;tags: tagsSchema&lt;/code&gt;. Change the categories once and every collection updates. This is the same DRY instinct you'd apply to a shared type — it just happens at the content-schema layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Computed Fields with &lt;code&gt;.transform()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Raw frontmatter is rarely what the app wants to consume. You want a permalink, a reading time, a flattened tag array, an extracted table of contents. Velite's &lt;code&gt;.transform()&lt;/code&gt; runs after validation and lets you derive all of it, so the emitted record is enriched and still fully typed.&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="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="cm"&gt;/* fields above */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&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;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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&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="na"&gt;tagCategories&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;tags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;tags&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="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;,&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;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;libraries&lt;/span&gt;&lt;span class="p"&gt;,&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;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;databases&lt;/span&gt;&lt;span class="p"&gt;,&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;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;services&lt;/span&gt;&lt;span class="p"&gt;,&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;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;topics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;permalink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/blog/&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;slug&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;readingTime&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;readingTime&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt;
        &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&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;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="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;200&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
      &lt;span class="na"&gt;wordCount&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;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;extractHeadings&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;raw&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nx"&gt;coverImage&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;What's happening here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;tags&lt;/code&gt; is flattened&lt;/strong&gt; from the categorized object into a single &lt;code&gt;string[]&lt;/code&gt; that components can iterate — while &lt;code&gt;tagCategories&lt;/code&gt; preserves the grouped shape for anything that needs it. One source, two views.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;readingTime&lt;/code&gt; falls back&lt;/strong&gt; to a word-count estimate (&lt;code&gt;words / 200&lt;/code&gt;) only when the frontmatter didn't set it. Author override wins; otherwise it's computed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;headings&lt;/code&gt; are extracted&lt;/strong&gt; from the raw Markdown by a small parser that walks the source, skips fenced code blocks, and slugs each &lt;code&gt;##&lt;/code&gt;/&lt;code&gt;###&lt;/code&gt; with &lt;code&gt;github-slugger&lt;/code&gt; — the same slugger &lt;code&gt;rehype-slug&lt;/code&gt; uses for anchors, so the table of contents links resolve.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The transform is &lt;code&gt;async&lt;/code&gt;, which is what makes the next part possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side Effects at Build Time: Cover Images
&lt;/h2&gt;

&lt;p&gt;Because the transform runs at build time and can be async, it's also the right place to do content-adjacent asset work. When a &lt;code&gt;cover.png&lt;/code&gt; sits next to &lt;code&gt;index.mdx&lt;/code&gt;, the transform copies it into &lt;code&gt;public/&lt;/code&gt;, generates a WebP for serving, and pre-pads OG and dev.to variants with &lt;code&gt;sharp&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;coverSrc&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content&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;blog&lt;/span&gt;&lt;span class="dl"&gt;"&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;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cover.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;coverImage&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;undefined&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="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;coverSrc&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;destDir&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public&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;images&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;blog&lt;/span&gt;&lt;span class="dl"&gt;"&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;slug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;mkdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;destDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursive&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;copyFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;coverSrc&lt;/span&gt;&lt;span class="p"&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;destDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cover.png&lt;/span&gt;&lt;span class="dl"&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;ensureWebp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;coverSrc&lt;/span&gt;&lt;span class="p"&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;destDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cover.webp&lt;/span&gt;&lt;span class="dl"&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;ensureCoverVariant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;coverSrc&lt;/span&gt;&lt;span class="p"&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;destDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cover-og.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;630&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;ensureCoverVariant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;coverSrc&lt;/span&gt;&lt;span class="p"&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;destDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cover-devto.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;420&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;coverImage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`/images/blog/&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;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/cover.webp`&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 variant helpers are idempotent — they check the destination's mtime against the source and skip work if nothing changed — so incremental builds stay cheap. That's the point of a build-time content layer: image derivation, format conversion, and metadata validation happen in one pass, and the running app just references paths that are guaranteed to exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Typed Import
&lt;/h2&gt;

&lt;p&gt;Velite writes the compiled collections to &lt;code&gt;.velite/&lt;/code&gt; (configured via &lt;code&gt;output.data&lt;/code&gt;), and the app imports them as &lt;code&gt;@/.velite&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;projects&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/.velite&lt;/span&gt;&lt;span class="dl"&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;posts&lt;/code&gt; is a fully typed array. The category is &lt;code&gt;"dev" | "business"&lt;/code&gt;, not &lt;code&gt;string&lt;/code&gt;. &lt;code&gt;permalink&lt;/code&gt;, &lt;code&gt;readingTime&lt;/code&gt;, &lt;code&gt;headings&lt;/code&gt; — all present, all typed, because the transform put them there and Velite inferred the shape. There is no &lt;code&gt;getPostBySlug()&lt;/code&gt; reading the filesystem at request time; there's an array in memory, generated at build.&lt;/p&gt;

&lt;p&gt;A worthwhile guardrail on top of this: you could add an ESLint &lt;code&gt;no-restricted-imports&lt;/code&gt; rule that forbids importing from &lt;code&gt;.velite&lt;/code&gt; in application code directly, routing everything through a thin &lt;code&gt;lib/&lt;/code&gt; layer instead. That would keep the raw generated output an implementation detail and give you one place to add filtering (drafts, future-dated posts) without touching every call site.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Codegen Step: A Related-Posts Graph
&lt;/h2&gt;

&lt;p&gt;Typed content unlocks work that's awkward to do at runtime. This site computes a tag-overlap graph — related posts, related projects, related services — in a script that runs &lt;strong&gt;after&lt;/strong&gt; &lt;code&gt;velite build&lt;/code&gt; and &lt;strong&gt;before&lt;/strong&gt; &lt;code&gt;next build&lt;/code&gt;:&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;"scripts"&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;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"velite build &amp;amp;&amp;amp; tsx scripts/generate-relations.ts &amp;amp;&amp;amp; next build"&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;That's the relevant slice of the build chain — the real &lt;code&gt;build&lt;/code&gt; script has one more codegen step after this (&lt;code&gt;generate-cv.tsx&lt;/code&gt;, which prebuilds a PDF), unrelated to the content pipeline and out of scope here. The script reads the JSON Velite just emitted, scores every pair by shared tags, and writes &lt;code&gt;.velite/relations.json&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;// scripts/generate-relations.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;services&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../lib/services&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;posts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VeliteItem&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&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;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&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;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.velite/posts.json&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;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&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;tagOverlap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tagsA&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;tagsB&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tagsA&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;tagsB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&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;findRelated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sourceSlug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sourceTags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;sourceSlug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&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="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;overlap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;tagOverlap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sourceTags&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overlap&lt;/span&gt; &lt;span class="o"&gt;&amp;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="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overlap&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overlap&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getTime&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;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="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&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 scoring is deliberately boring: count shared tags, sort by overlap descending, tie-break on newest first, take the top N. It runs across four relationship types — post→posts, post→projects, post→reviews, post→services — and because it reads the same categorized-then-flattened &lt;code&gt;tags&lt;/code&gt; array the schema produced, the graph is consistent with what the site renders everywhere else.&lt;/p&gt;

&lt;p&gt;Two design choices are worth calling out. First, &lt;strong&gt;services feed the graph from &lt;code&gt;lib/services.ts&lt;/code&gt;, not from MDX&lt;/strong&gt; — service definitions live in TypeScript with their own &lt;code&gt;tags&lt;/code&gt; field, and the script imports them as the single source of truth for that side of the relation. Second, the whole thing is a build artifact: &lt;code&gt;relations.json&lt;/code&gt; is written once, committed to nothing, and read as static data. No runtime tag-matching, no per-request cost, no way for the graph to disagree with the pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Pays Off and Where It Doesn't
&lt;/h2&gt;

&lt;p&gt;The build-time model earns its keep when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;content is large enough that manual consistency breaks down;&lt;/li&gt;
&lt;li&gt;you cross-link content by shared metadata (tags, categories, series);&lt;/li&gt;
&lt;li&gt;frontmatter mistakes should block a deploy, not surface as production 500s;&lt;/li&gt;
&lt;li&gt;you want derived data — reading time, TOC, related items — computed once, not per request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's overhead you don't need when the site is three static pages and a contact form, or when content changes so often that a rebuild per edit is friction rather than safety. A CMS with runtime rendering fits that better. There's also a real constraint: everything lives in the repo and requires a build to publish. If non-technical editors need to publish without a deploy, this isn't the shape for them — that's a headless CMS decision, not a Velite one.&lt;/p&gt;

&lt;p&gt;For a code-owned, developer-authored content set, the trade is clean: one schema, one typed import, validation at the build boundary, derived data generated in the same pass. The content layer stops being a source of runtime surprises and becomes just another typed module the app depends on.&lt;/p&gt;




&lt;p&gt;If you're building a content-heavy Next.js product and want the content layer typed and validated from the start — the schema, the codegen, the guardrails that keep it from rotting — that's the kind of foundation I put in before feature work begins. &lt;a href="https://iurii.rogulia.fi/contact" rel="noopener noreferrer"&gt;Get in touch&lt;/a&gt; if you're spinning up an &lt;a href="https://iurii.rogulia.fi/services/mvp-development" rel="noopener noreferrer"&gt;MVP&lt;/a&gt; and want it built on something that won't break silently as it grows.&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://velite.js.org/" rel="noopener noreferrer"&gt;Velite documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://iurii.rogulia.fi/blog/nextjs-dynamic-og-images" rel="noopener noreferrer"&gt;Generating dynamic OG images in Next.js&lt;/a&gt; — another build-time asset step in this pipeline&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://iurii.rogulia.fi/blog/ai-agent-codebase-prompts" rel="noopener noreferrer"&gt;Writing effective CLAUDE.md rules for AI coding agents&lt;/a&gt; — how this content architecture is documented for tooling&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>mdx</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Right-to-Work and I-9 Document Fraud: The Tampered PDF Your Compliance Check Misses</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Wed, 26 Aug 2026 10:00:41 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/right-to-work-and-i-9-document-fraud-the-tampered-pdf-your-compliance-check-misses-59kn</link>
      <guid>https://dev.to/iurii_rogulia/right-to-work-and-i-9-document-fraud-the-tampered-pdf-your-compliance-check-misses-59kn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/right-to-work-i9-document-fraud" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt;. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A new hire emails over their work-authorization paperwork before the start date. In the US, that’s a permanent resident card and a supporting document for the I-9. In the UK, it’s a screenshot or PDF of a Home Office share-code result, or a sponsor letter confirming the candidate’s visa. The name matches the offer. The dates are in range. The document looks exactly like every genuine one your team has processed. Your onboarding coordinator files it and clears the candidate to start.&lt;/p&gt;

&lt;p&gt;Every step of that review was done correctly. And the document could still have been edited.&lt;/p&gt;

&lt;p&gt;The reason is that the right-to-work (RTW) and I-9 verification process is built to check whether the document is &lt;em&gt;plausible and consistent&lt;/em&gt; — the right name, a valid-looking expiry date, the correct document type for the person’s status. It was never designed to check whether the &lt;em&gt;PDF file itself&lt;/em&gt; was altered after the issuing authority produced it. Those are two different questions. A candidate who opens a real share-code printout or a real sponsor letter, changes one expiry date or one name, and re-saves it defeats the second question completely — while passing the first without trouble.&lt;/p&gt;

&lt;p&gt;This is the shape of most right-to-work document fraud and fake I-9 document cases in practice: not a forgery built from nothing, but a real document edited in one field. This article walks through how employers and background-verification (BGV) operators actually verify work authorization today, exactly where that process breaks on a well-edited PDF, and the structural forensic layer that closes the gap. It is written for HR compliance, BGV operations, and sponsor-licence holders — not developers, though there is a short integration section at the end.&lt;/p&gt;

&lt;p&gt;One thing to be clear about up front, because this sits right next to a category HTPBE is &lt;strong&gt;not&lt;/strong&gt; in: HTPBE does not confirm a person’s identity and does not confirm their eligibility to work. It does not replace E-Verify, the Home Office online right-to-work check, or any identity-document validation service. Those checks answer “is this person who they say they are, and are they allowed to work?” HTPBE answers a single, narrow, different question: &lt;em&gt;was this PDF file edited after it was created?&lt;/em&gt; It is additive to your existing process, not a substitute for any part of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stakes: Civil and Criminal Liability Land on the Employer
&lt;/h2&gt;

&lt;p&gt;Work-authorization fraud is one of the few compliance areas where accepting a forged document exposes the &lt;em&gt;employer&lt;/em&gt;, not just the fraudster, to penalties.&lt;/p&gt;

&lt;p&gt;In the United States, employers must complete Form I-9 for every employee and examine documents that establish identity and employment authorization. Knowingly accepting fraudulent documents, or failing to properly verify, carries civil fines into the thousands of dollars per violation, and a pattern of knowing violations can escalate to criminal exposure — against a backdrop of steadily rising US Immigration and Customs Enforcement audit activity.&lt;/p&gt;

&lt;p&gt;In the United Kingdom, the regime is sharper still. An employer who hires someone without a valid right to work and cannot show a statutory excuse faces a civil penalty the Home Office raised to &lt;strong&gt;up to £60,000 per illegal worker&lt;/strong&gt; in 2024 — triple the previous maximum. For sponsor-licence holders, the consequence isn’t only the fine: a compliance failure can mean licence suspension or revocation, severing the right to sponsor &lt;em&gt;any&lt;/em&gt; overseas worker and collapsing a hiring pipeline overnight. And the statutory excuse depends on having carried out the prescribed check correctly. If a candidate hands over an edited document and the operator processes it at face value, that excuse can be undermined precisely because the underlying file was not what it appeared to be. The liability attaches to the organisation that accepted the paperwork.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Employers Verify Work Authorization Today
&lt;/h2&gt;

&lt;p&gt;Work-authorization verification is a layered process, and a competent one catches a great deal. Here is what the controls actually do — and they do their jobs well.&lt;/p&gt;

&lt;h3&gt;
  
  
  United States: I-9 and E-Verify
&lt;/h3&gt;

&lt;p&gt;For the I-9, the employer examines the documents the employee presents from the Lists of Acceptable Documents — a List A document that proves both identity and authorization (a permanent resident card, an Employment Authorization Document), or a List B identity document paired with a List C authorization document — confirming each reasonably appears genuine and relates to the person. Many employers then run the data through &lt;strong&gt;E-Verify&lt;/strong&gt;, which checks it against Social Security Administration and Department of Homeland Security records. When E-Verify runs, it is the right control — keep it. But under the remote alternative procedure available to qualified E-Verify employers, documents may be examined over video with copies transmitted in advance. That is where edited PDFs enter the workflow: the reviewer is looking at a transmitted file, not a physical card.&lt;/p&gt;

&lt;h3&gt;
  
  
  United Kingdom: Share Codes and Sponsor Compliance
&lt;/h3&gt;

&lt;p&gt;For candidates with a digital immigration status, the prescribed check is online: the candidate generates a &lt;strong&gt;share code&lt;/strong&gt;, the employer enters it with the candidate’s date of birth on the Home Office service, and the official result displays straight from government records. Done live, this check is robust — the data comes from the Home Office, not from a candidate-supplied file. Where an online check isn’t possible, the employer examines documents from the prescribed lists manually and retains a clear copy. Licensed &lt;strong&gt;sponsors&lt;/strong&gt; carry ongoing duties on top of this: tracking visa expiry and retaining right-to-work evidence — sponsor letters, Certificate of Sponsorship records, share-code printouts, passport and BRP copies — as PDFs in a compliance folder, ready to produce if the Home Office audits.&lt;/p&gt;

&lt;p&gt;Each of these is a real, valuable control, and the live share-code check in particular is excellent because it pulls from the source rather than trusting a supplied file. The problem is not that these checks are weak. The problem is &lt;em&gt;what happens when a file is processed in place of the live source.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The One Thing the Process Doesn’t Check
&lt;/h2&gt;

&lt;p&gt;Read those controls again and notice the common thread. They verify whether the document’s &lt;strong&gt;content is plausible and matches the person&lt;/strong&gt;: right name, valid-looking expiry, correct document type, data that corresponds to a real record. These are content questions. None of them ask the other question: “Was this PDF edited after the issuing authority produced it?”&lt;/p&gt;

&lt;p&gt;That is the gap a careful fraudster exploits. Consider the most common way work-authorization documents are faked — not forged from nothing, but &lt;em&gt;edited&lt;/em&gt; from a real one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The candidate obtains a genuine document as a PDF — their own expired share-code printout, a real sponsor letter issued to someone else, a permanent resident card scan, a visa grant letter.&lt;/li&gt;
&lt;li&gt;They open it in a desktop or online PDF editor.&lt;/li&gt;
&lt;li&gt;They change one field — push an expiry date forward, swap the name, alter a visa category, change a Certificate of Sponsorship number.&lt;/li&gt;
&lt;li&gt;They re-save the file and send it to the employer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result is a document built on a real template, with the real layout, the real fonts, the real official styling — and a candidate who had every chance to make the edited field &lt;em&gt;look&lt;/em&gt; correct. The expiry date is now in the future. The name now matches the offer. On screen, the edited field is indistinguishable from an original one, because the editor renders it in the same font at the same position. A visual review has nothing to catch.&lt;/p&gt;

&lt;p&gt;Where does this slip through the otherwise-strong checks? Precisely at the moments when a &lt;em&gt;file&lt;/em&gt; stands in for the live source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A US reviewer examining transmitted document copies under the remote procedure is looking at a PDF, not a card.&lt;/li&gt;
&lt;li&gt;A UK employer who relies on a candidate-supplied share-code &lt;em&gt;printout&lt;/em&gt; rather than running the code live on the Home Office service is trusting a file, not the government record. (Running the code live is the safer path — and exactly why a tampered printout is the fraudster’s workaround for candidates who can’t pass the live check.)&lt;/li&gt;
&lt;li&gt;A sponsor compliance folder full of retained PDFs has, by definition, no live source behind it — it’s the file or nothing when the auditor asks.&lt;/li&gt;
&lt;li&gt;A BGV operator processing thousands of documents at scale receives PDFs by email and upload, far downstream from any original.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In every one of these cases, the decision rests on an uploaded or emailed PDF, and the integrity of that PDF goes unchecked. The content review confirms the data is plausible; nothing confirms the file wasn’t altered to make it so.&lt;/p&gt;

&lt;h2&gt;
  
  
  What E-Verify and the Online Check Solve — and What They Don’t
&lt;/h2&gt;

&lt;p&gt;When E-Verify runs against DHS and SSA records, or a UK employer runs a share code live against the Home Office service, the data is validated against the authority directly — there is no supplied file to edit, so there is nothing for a PDF editor to defeat. For the candidates and moments those checks cover, they are the gold standard. HTPBE does not replace them, and you should keep every one of them.&lt;/p&gt;

&lt;p&gt;But those checks have a coverage and timing boundary, and document fraud concentrates exactly at that boundary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Documents that aren’t run live.&lt;/strong&gt; A share-code printout examined as a PDF, rather than entered live on the Home Office service, is a file — and a file can be edited. The same is true of any supporting document transmitted as a copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document types with no online equivalent.&lt;/strong&gt; Sponsor letters, internal Certificate of Sponsorship records, visa grant letters, and many supporting documents have no real-time validation service behind them. They exist only as PDFs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retained compliance evidence.&lt;/strong&gt; A sponsor’s audit folder is a collection of files captured at hire time. Nothing re-validates them later; their integrity is assumed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High-volume BGV intake.&lt;/strong&gt; Background-verification operators handle work-authorization documents in bulk, mostly as uploaded PDFs, where a manual live check on every single file isn’t feasible.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In each of these cases, an organisation is back to a human relying on a PDF — the exact scenario where content checks and even excellent live-lookup services can’t see an edit, because no file passed through them. Identity and eligibility services don’t fill this gap either: they confirm &lt;em&gt;who&lt;/em&gt; the person is and &lt;em&gt;whether&lt;/em&gt; they may work, not whether a particular submitted PDF was altered. That uploaded-file case is precisely where a structural layer earns its place. It is additive — it catches what the live checks sidestep and what the human reviewer can’t see.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Missing Layer: Structural File Forensics
&lt;/h2&gt;

&lt;p&gt;A PDF is not a flat picture. It is a structured file that carries an internal record of how it was built and saved — when it was created, what software produced it, whether it holds a digital signature, and how many times it was written to disk. When someone opens an authority’s original PDF and saves an edit, that act leaves traces in the file’s structure, no matter how convincing the visible field looks.&lt;/p&gt;

&lt;p&gt;Structural forensics reads that internal record and returns a verdict. It never asks whether an expiry date is plausible or whether a name belongs to a real person — that’s the job of E-Verify, the Home Office check, and your reviewer. It asks whether the file’s own construction is consistent with a clean, single-pass document from an issuing system, or whether it bears the marks of having been opened and re-saved in an editing tool after the fact.&lt;/p&gt;

&lt;p&gt;HTPBE is a self-serve API that performs exactly this analysis. You send it a PDF; it returns one of three verdicts plus a list of named markers describing what it found. To be unambiguous: it is &lt;strong&gt;not&lt;/strong&gt; an identity or eligibility vendor, and was never meant to be one. It does not tell you the candidate is who they claim or that they may legally work. It answers only: &lt;em&gt;was this file altered after it was created?&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The three verdicts
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;intact&lt;/code&gt;&lt;/strong&gt; — no evidence of post-creation modification, and the file looks like a genuine single-pass document from an institutional system. The structural layer found nothing to flag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;modified&lt;/code&gt;&lt;/strong&gt; — the file carries forensic evidence that it was changed after it was first generated. This is the case that matters most: a document that should be a clean export but instead shows the fingerprints of an editing session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;inconclusive&lt;/code&gt;&lt;/strong&gt; — the file was produced by consumer software, an online editor, or a scanner, so there is no institutional baseline to check integrity against. This is &lt;strong&gt;not a pass.&lt;/strong&gt; It is a routing signal, and in work-authorization checks it is often the most useful verdict of all (more on that below).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What it actually catches — in plain terms
&lt;/h3&gt;

&lt;p&gt;Without turning this into a how-to for forgers, here is the kind of structural evidence the analysis surfaces, described as outcomes rather than recipes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Editing-tool fingerprints.&lt;/strong&gt; Documents issued by government and institutional systems are produced server-side. When a file instead carries the signature of a consumer PDF editor or an online conversion service, that origin is inconsistent with a genuine issuing pipeline. The marker &lt;code&gt;HTPBE_EDITING_TOOL_FINGERPRINT&lt;/code&gt; flags this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disagreeing internal timestamps.&lt;/strong&gt; A clean export’s creation and modification timestamps line up. When they contradict each other — the file claims one creation date but was last written days later — that gap is recorded structurally even though it’s invisible on the page. &lt;code&gt;HTPBE_DATES_DISAGREE&lt;/code&gt; covers this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple revision layers.&lt;/strong&gt; Each time a PDF is edited and saved, the change is appended as a new layer on top of the original. A document that should have been generated in a single pass but instead shows several stacked write sessions reveals that it was modified after creation. The marker is &lt;code&gt;HTPBE_MULTIPLE_REVISION_LAYERS&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Producer identity that’s been overwritten or spoofed.&lt;/strong&gt; Some tools try to hide their involvement by rewriting the file’s stated generator. When that identity has been blanked or forged, the analysis flags it (&lt;code&gt;HTPBE_IDENTITY_OVERWRITTEN&lt;/code&gt;, &lt;code&gt;HTPBE_PRODUCER_IDENTITY_FORGED&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tampering around a digital signature.&lt;/strong&gt; Where a document is digitally signed — some official letters and certificates are — the analysis detects content changed after signing (&lt;code&gt;HTPBE_POST_SIGNATURE_EDIT&lt;/code&gt;) or a signature stripped off entirely (&lt;code&gt;HTPBE_SIGNATURE_REMOVED&lt;/code&gt;). These are among the most conclusive signals there are.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these depend on the visible field being implausible. A perfectly formatted, perfectly believable forged expiry date still sits inside a file that was edited — and the edit is what gets caught.&lt;/p&gt;

&lt;p&gt;
  Want to see it on a real document? You can drop a PDF into the free check on this site and get a
  verdict in a few seconds — no account needed. It’s the same engine the API runs, and
  it’s a fast way to check a single suspicious share-code printout or sponsor letter before
  you decide whether to add it to your onboarding.
&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;inconclusive&lt;/code&gt; Means for Work-Authorization Documents
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;inconclusive&lt;/code&gt; verdict is the one people most often misread, so it’s worth being precise.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;inconclusive&lt;/code&gt; means the file was created in software that anyone can use to build a document from scratch — Word, an online editor, a scanner. Because there’s no institutional baseline, the structural layer can’t say whether the content was tampered with. It’s telling you, honestly, “I can’t verify integrity here.”&lt;/p&gt;

&lt;p&gt;The power of that verdict comes from context — specifically, &lt;em&gt;what the document claims to be.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A Home Office share-code result printout, an official visa grant letter, a permanent resident card scan from a government system: these have an institutional origin. A genuine one should look like an institutional export. So if a candidate hands you a document claiming to be an official government-issued result, and the analysis returns &lt;code&gt;inconclusive&lt;/code&gt; because the file was actually built in a word processor or run through an online editor, the verdict is doing real work: a real official document would not look like that. That mismatch is a reason to escalate — and, ideally, to go run the share code live yourself rather than trust the printout at all.&lt;/p&gt;

&lt;p&gt;The same &lt;code&gt;inconclusive&lt;/code&gt; verdict on a document that’s &lt;em&gt;supposed&lt;/em&gt; to be a simple internal letter — something an HR team might legitimately produce in Word — is routine, and you’d treat it as such. The verdict is identical; the action depends on whether the claimed source is supposed to produce institutional files. Used this way, &lt;code&gt;inconclusive&lt;/code&gt; isn’t a dead end. It’s a fork in your workflow — and frequently a prompt to fall back to the authoritative live check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest Limits — What This Layer Cannot Catch
&lt;/h2&gt;

&lt;p&gt;Structural forensics is a powerful additional control, not a fraud oracle. Two scenarios sit outside its reach, and a serious compliance team should know them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Documents fabricated entirely from scratch in the right kind of software.&lt;/strong&gt; If a fraudster doesn’t edit a real document but instead builds a fake from zero using a tool that produces institutional-looking output, there is no “original” to compare against and no editing event to detect. The file may look clean structurally because, structurally, it &lt;em&gt;is&lt;/em&gt; a clean single-pass file — it just contains invented data. This is exactly why the structural layer complements rather than replaces source verification: running the share code live on the Home Office service, running E-Verify, and confirming details with the issuing authority remain the right controls for fabricated-from-scratch fraud. The structural layer’s strength is the far more common case — editing a real document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Legitimately consumer-generated documents.&lt;/strong&gt; Some genuine letters really are produced in Word or exported through generic print drivers. For those, &lt;code&gt;inconclusive&lt;/code&gt; is the correct and expected verdict, and reading it as guilt would generate false positives. The signal only carries weight when the claimed source has an institutional baseline to deviate from.&lt;/p&gt;

&lt;p&gt;And to restate the boundary that matters most here: HTPBE checks the file, not the person. It does not establish identity or the right to work, and it is not a statutory check on its own. A &lt;code&gt;modified&lt;/code&gt; verdict tells you a file was altered and should be escalated and re-sourced; an &lt;code&gt;intact&lt;/code&gt; verdict tells you the file wasn’t altered — not that the candidate is authorized to work. Your E-Verify run and your live Home Office check remain the controls that answer eligibility. The structural layer slots &lt;em&gt;alongside&lt;/em&gt; them, catching the altered-PDF case they can’t see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring It Into Your Onboarding or BGV Workflow
&lt;/h2&gt;

&lt;p&gt;For teams that decide the structural layer belongs in their workflow, integration is deliberately small. The pattern is three steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Analyze the uploaded document.&lt;/strong&gt; When a candidate uploads or emails a work-authorization PDF, send its URL to the analyze endpoint. There is no numeric risk score to interpret — you get a verdict and named markers.&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/onboarding/candidate-7821-rtw.pdf"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is just the check ID:&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="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;strong&gt;2. Fetch the verdict.&lt;/strong&gt; Retrieve the result by ID. The response is a flat object — the fields your policy cares about are &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;modification_markers&lt;/code&gt;, and &lt;code&gt;modification_confidence&lt;/code&gt;.&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/506a6b1b-1360-48a2-b389-abb346f85d04 &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;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;"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_markers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"HTPBE_EDITING_TOOL_FINGERPRINT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HTPBE_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;"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="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 Python version for a typical onboarding service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.htpbe.tech/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HTPBE_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;HEADERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;KEY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;screen_document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;submit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/analyze&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pdf_url&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;check_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;submit&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/result/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;check_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&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;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;                       &lt;span class="c1"&gt;# intact | modified | inconclusive
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;markers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;modification_markers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;         &lt;span class="c1"&gt;# e.g. ["HTPBE_DATES_DISAGREE"]
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;modification_confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;   &lt;span class="c1"&gt;# certain | high | none
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;check_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;check_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                            &lt;span class="c1"&gt;# store for your audit trail
&lt;/span&gt;    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Route on the verdict.&lt;/strong&gt; Wire the three outcomes into your existing review queue rather than auto-deciding on any single marker:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;modified&lt;/code&gt; → route to manual review, request a fresh document direct from the candidate, and re-run the authoritative check (run the share code live, run E-Verify). For the most conclusive markers — a post-signature edit on a signed letter — treat the finding as conclusive: decline to accept that file and require a fresh issuer-delivered copy before proceeding, rather than making the verdict the sole basis for an automatic decision about the person.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;inconclusive&lt;/code&gt; → branch on the claimed source. A consumer-software origin on a document claimed to be an official government result is worth escalating and re-sourcing live; the same verdict on a plain internal letter is routine.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;intact&lt;/code&gt; → no structural evidence of alteration; proceed with your normal identity and eligibility checks, which remain the controls that confirm the person can work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Store the check ID against the candidate record. If a hiring decision or a sponsor audit is ever questioned, you can retrieve the forensic result from your check history. It shows exactly which structural signals fired — useful both for demonstrating diligence and for defending a statutory-excuse position.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who Should Add This Layer
&lt;/h2&gt;

&lt;p&gt;If you run HR compliance, onboarding, or BGV operations — or you hold a sponsor licence and keep a folder of retained right-to-work evidence — this is the gap in your stack worth closing. Your team already runs E-Verify or the Home Office check well, and those controls already confirm identity and eligibility. What they cannot see, by design, is whether a particular uploaded PDF was edited after the authority issued it. That blind spot is exactly where a careful candidate operates, and exactly what a structural layer covers — without ever pretending to be an identity or eligibility check.&lt;/p&gt;

&lt;p&gt;For a deeper look at how this works as a workflow, see the &lt;a href="https://htpbe.tech/use-cases/immigration-document-fraud-detection/right-to-work-document-fraud-detection" rel="noopener noreferrer"&gt;right-to-work document fraud detection use case&lt;/a&gt; and the broader &lt;a href="https://htpbe.tech/use-cases/immigration-document-fraud-detection" rel="noopener noreferrer"&gt;immigration document fraud detection hub&lt;/a&gt;. When you’re ready to put a verdict behind your onboarding intake, the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;self-serve API&lt;/a&gt; is documented end-to-end with test keys you can wire up before you spend a credit — &lt;a href="https://htpbe.tech/auth/signup" rel="noopener noreferrer"&gt;create an account&lt;/a&gt; and screen your first document in minutes.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>fintech</category>
      <category>fraud</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What Is VIES? A Guide for Finance Teams</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Wed, 26 Aug 2026 09:00:41 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/what-is-vies-a-guide-for-finance-teams-o5</link>
      <guid>https://dev.to/iurii_rogulia/what-is-vies-a-guide-for-finance-teams-o5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/what-is-vies-for-business" 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 Is VIES? A Guide for Finance Teams
&lt;/h1&gt;

&lt;p&gt;VIES is the free European Commission service you use to confirm that an EU VAT number is registered. Type in a number, and it tells you whether that number is on the books in the country it belongs to. Finance and operations teams reach for it constantly — before invoicing a new EU customer, when a supplier gives you their VAT number, or whenever an intra-EU B2B transaction depends on the other side being VAT-registered.&lt;/p&gt;

&lt;p&gt;This guide is the plain-English version: what VIES actually does, what a "valid" or "invalid" answer really means for your invoices, and where the limits are. It stays inside the area VIES covers — the EU-27 plus XI (Northern Ireland). If you want the mechanics under the hood, &lt;a href="https://vatnode.dev/guides/what-is-vies" rel="noopener noreferrer"&gt;how VIES works, in depth&lt;/a&gt; goes further than we will here.&lt;/p&gt;

&lt;h2&gt;
  
  
  What VIES is
&lt;/h2&gt;

&lt;p&gt;VIES stands for the VAT Information Exchange System. The important thing to understand is what it is &lt;em&gt;not&lt;/em&gt;: it is not one big central EU database of VAT numbers. The European Commission describes it as a search engine, not a database — and that distinction matters more than it sounds.&lt;/p&gt;

&lt;p&gt;When you look up a VAT number, VIES takes the country prefix, works out which member state the number belongs to, and forwards your query to that country's own VAT database in real time. &lt;a href="https://vatnode.dev/check/ie" rel="noopener noreferrer"&gt;Ireland's&lt;/a&gt; tax authority answers for Irish numbers, &lt;a href="https://vatnode.dev/check/de" rel="noopener noreferrer"&gt;Germany's&lt;/a&gt; answers for German numbers, and so on. VIES relays the answer back to you. It owns none of the data; it routes the question to whoever does.&lt;/p&gt;

&lt;p&gt;That design has two consequences worth keeping in mind. First, the data is only as current as each national database — the Commission doesn't control it and disclaims its accuracy, so a change made by a national authority isn't always reflected the instant it happens. Second, because each country answers for itself, the countries can fail independently: one national node can be down while every other one works fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who actually runs a VIES check (a person, or your billing system)
&lt;/h2&gt;

&lt;p&gt;In practice a VIES check gets run one of two ways.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;person&lt;/strong&gt; runs it manually. Someone in finance opens the Commission's &lt;a href="https://ec.europa.eu/taxation_customs/vies/" rel="noopener noreferrer"&gt;public VIES page&lt;/a&gt;, types in a country and a number, and reads the result off the screen. This is perfect for a one-off — a new supplier, a single large invoice, a number that looks odd. It doesn't scale, and it leaves no record beyond a screenshot.&lt;/p&gt;

&lt;p&gt;Your &lt;strong&gt;billing or ordering system&lt;/strong&gt; runs it automatically. Instead of a human typing into a form, your software asks the same underlying service and gets a structured answer back — valid or invalid, plus whatever company detail the country returns. That's how you validate a VAT number on every signup or every invoice without anyone lifting a finger. The trade-off between the two is the subject of the last section; for now, the point is that both are asking the same thing of the same national databases.&lt;/p&gt;

&lt;p&gt;Either way, VIES itself is free. There's no login, no fee, and no contract. There's also no service-level guarantee behind it, which is exactly why the "who runs it" question turns into an engineering decision once your volume grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "valid" tells you — and what it doesn't
&lt;/h2&gt;

&lt;p&gt;A "valid" result means the number was present and active in the relevant national database at the moment you asked. That is genuinely useful: it's evidence that the business is VAT-registered, and for intra-EU B2B it's one of the conditions that supports zero-rating or &lt;a href="https://vatnode.dev/reverse-charge" rel="noopener noreferrer"&gt;reverse charge&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Here's where finance teams overreach. A valid reply is &lt;strong&gt;not&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A guarantee about the company.&lt;/strong&gt; VIES confirms a registration, not the counterparty's identity, solvency, or good standing. A number can be perfectly valid and belong to a business you still shouldn't extend credit to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A statement about the transaction.&lt;/strong&gt; Whether a specific sale qualifies for reverse charge depends on what's sold, where, and to whom — not on the VAT number alone. The valid number is one input, not the ruling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A promise that the data is complete.&lt;/strong&gt; Not every valid national VAT number is visible in VIES. Only numbers activated for intra-EU trade show up, so a business can be legitimately VAT-registered domestically and still not appear.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat a valid result as what it is: solid evidence of registration at a point in time, and a green light to move to the next check — not the final word on the deal.&lt;/p&gt;

&lt;p&gt;A couple of prefix details trip people up, since they change what you type in. Greece uses &lt;strong&gt;EL&lt;/strong&gt;, not GR. Northern Ireland uses &lt;strong&gt;XI&lt;/strong&gt;, and only for goods. Enter the wrong prefix and you'll get a confusing answer to the wrong question.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "invalid" means for an invoice
&lt;/h2&gt;

&lt;p&gt;An "invalid" result does not mean "fraud." It means the number is not registered — or not enabled for cross-border trade — in that country's database at the time you checked. There are ordinary reasons for it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a typo, or a domestic tax number entered where the VAT number belongs;&lt;/li&gt;
&lt;li&gt;a registration that has lapsed or been cancelled;&lt;/li&gt;
&lt;li&gt;a valid domestic number that was never activated for intra-EU use, so VIES can't see it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical rule: &lt;strong&gt;don't invoice on a bare invalid.&lt;/strong&gt; Before you apply reverse charge or zero-rate a supply, resolve why the number came back invalid. Go back to the customer, confirm the number, check the prefix, and re-run it. If it stays invalid, the safe default is to treat the supply as if the customer were not VAT-registered — charge VAT as normal — rather than assume a treatment you can't support.&lt;/p&gt;

&lt;p&gt;Getting this wrong is expensive in one specific way: if you zero-rate a supply on the strength of a VAT number that turns out to be invalid, the tax can land back on you. That's the whole reason the check exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limits of VIES (downtime, uneven company detail, point-in-time answers)
&lt;/h2&gt;

&lt;p&gt;Three limits shape how you should use it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Downtime is normal, not an emergency.&lt;/strong&gt; Because each country runs its own node, availability is per-country and there's no guaranteed uptime — no SLA, no promise it will answer. Nodes go offline for maintenance or load, and some are less reliable than others. When a node is down you get an error, not an answer, and that error is not the same as "invalid." A VIES outage should never silently turn into a blocked customer or a wrong VAT decision. (For the systems side of this, we wrote up &lt;a href="https://vatnode.dev/blog/vies-downtime-guide" rel="noopener noreferrer"&gt;handling VIES downtime&lt;/a&gt; separately.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Company detail is uneven.&lt;/strong&gt; Some countries return the registered business name and address alongside the valid/invalid flag; others return only the flag. So you can't count on getting a name to match against your records for every country — it depends who's answering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Answers are point-in-time.&lt;/strong&gt; A valid result describes the moment you asked and nothing else. Registrations lapse; businesses deregister. A number you validated at onboarding a year ago may not be valid today, which is why a single check is a snapshot, not a standing guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why keep proof of the check
&lt;/h2&gt;

&lt;p&gt;If you rely on a VIES result to zero-rate or reverse-charge a supply, you should be able to show later that you actually ran the check and what it returned. Auditors on intra-EU supplies do ask.&lt;/p&gt;

&lt;p&gt;A screenshot from a manual lookup is thin evidence. A stronger trail records, for each check, when it ran, what came back, and which source answered. VIES can also return a &lt;strong&gt;consultation number&lt;/strong&gt; — a reference tied to a specific query that shows you performed the check at that time. It's a useful piece of the audit trail, though it's not the whole story and it's not something only one provider can give you. We cover how to use it in &lt;a href="https://vatnode.dev/blog/consultation-number-for-finance" rel="noopener noreferrer"&gt;keep proof of the check&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The habit worth building: every VAT decision that depends on a check should have a stored record behind it, not a memory of having looked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Manual checking vs automating it
&lt;/h2&gt;

&lt;p&gt;So which way should you run checks?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual is fine when volume is low.&lt;/strong&gt; A handful of new EU customers or suppliers a month? The Commission's free page does the job. The costs are that it doesn't scale, it's easy to skip under pressure, and the evidence it leaves is weak.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automate once checks become routine.&lt;/strong&gt; If you're validating numbers on every signup, every invoice, or across an existing customer base, hand-checking stops being realistic — and that's exactly where things get missed. Connecting your billing or ordering system to a &lt;a href="https://vatnode.dev/vies-api" rel="noopener noreferrer"&gt;VIES API&lt;/a&gt; means every number gets checked the same way, downtime is handled for you instead of becoming a blocked order, and each result is logged automatically for the audit trail. The judgement call is simply where your volume crosses the line from "occasional" to "routine."&lt;/p&gt;

&lt;p&gt;Not sure yet? Start manual. You can &lt;a href="https://vatnode.dev/check" rel="noopener noreferrer"&gt;check a VAT number free&lt;/a&gt; right now with no setup, and move to automation when the manual approach starts to strain.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is VIES free to use?
&lt;/h3&gt;

&lt;p&gt;Yes. VIES is the European Commission's service and is free to query. It has no service-level guarantee, though — individual country nodes go down, so it is not something to treat as always-on.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does it mean if VIES says a VAT number is invalid?
&lt;/h3&gt;

&lt;p&gt;It means the number is not registered — or not enabled for cross-border trade — in the relevant national database at the time you checked. Investigate before you invoice; don't apply reverse charge on a bare invalid.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does VIES prove a company is legitimate?
&lt;/h3&gt;

&lt;p&gt;No. It confirms VAT registration at query time, not the company's identity, solvency, or good standing. A valid result is evidence of registration, not a guarantee about the counterparty.&lt;/p&gt;

&lt;h3&gt;
  
  
  How often should we check a VAT number?
&lt;/h3&gt;

&lt;p&gt;At onboarding and again before you rely on it — registrations lapse. There's no legally fixed interval; set a cadence that matches your risk.&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  Check an EU VAT number free
&lt;/h3&gt;

&lt;p&gt;Confirm a single number against VIES in seconds with the &lt;a href="https://vatnode.dev/check" rel="noopener noreferrer"&gt;free VAT number checker&lt;/a&gt; — no account needed. Running checks on every signup or invoice instead? &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/vies-api" rel="noopener noreferrer"&gt;VIES API&lt;/a&gt; from your own system — free plan, 100 requests/month.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>tax</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Is Your Software Quote Honest? A Buyer's Guide</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 24 Aug 2026 10:00:57 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/is-your-software-quote-honest-a-buyers-guide-45dl</link>
      <guid>https://dev.to/iurii_rogulia/is-your-software-quote-honest-a-buyers-guide-45dl</guid>
      <description>&lt;p&gt;You've asked three developers to build the same thing. One says €8,000 and six weeks. One says €25,000 and four months. One says "give me a couple of days, I'll knock it out for €2,000."&lt;/p&gt;

&lt;p&gt;You can't read the code. You can't judge the architecture. So you do the only thing you can: you compare the numbers, and the cheapest confident one wins.&lt;/p&gt;

&lt;p&gt;That's how buyers get burned. Not because they made a foolish choice, but because the one signal they can read — the price — is the one signal that's easiest to fake.&lt;/p&gt;

&lt;p&gt;The good news is that you don't need to read code to read a quote. A quote is a document written by a human being making judgments about uncertainty, and the way they handle that uncertainty tells you almost everything about how the project will go. You just have to know what to look at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Cheapest Quote Is Usually the Most Expensive
&lt;/h2&gt;

&lt;p&gt;There's a reason the lowball number feels safe: it removes the scariest variable, cost, and it does it decisively. No hedging, no ranges, no "it depends." Just a small, confident figure. Decisive feels competent.&lt;/p&gt;

&lt;p&gt;But in software, a small confident number for a non-trivial job is almost never competence. It's one of two things: the person doesn't yet understand what the work involves, or they understand it fine and they're pricing to win the deal, planning to make it back on change orders once you've already committed.&lt;/p&gt;

&lt;p&gt;Both end in the same place. The naive quote runs out of money halfway, because the work was always going to cost more and reality doesn't care what was promised. The strategic lowball invoices you for everything the original number quietly excluded. Either way, the €8,000 project becomes a €20,000 project — except now it's late, half-built, and you've lost the leverage you had before you signed.&lt;/p&gt;

&lt;p&gt;The expensive part of cheap software is never the invoice. It's what you build on top of it. A cheap quote buys you a system that works in the demo and breaks the first time a real customer does something the developer didn't picture. You find out in production, with your name on it. Rebuilding it costs more than doing it right would have — plus everything you lost while it was broken.&lt;/p&gt;

&lt;p&gt;I'll say the uncomfortable version plainly: when you pick the cheapest quote for real business software, you're usually not saving money. You're deferring the real cost to a moment when it's more expensive and less recoverable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "Two Weeks" Almost Always Means Six
&lt;/h2&gt;

&lt;p&gt;This isn't developers lying to you. It's structural, and understanding why makes you a much harder buyer to fool.&lt;/p&gt;

&lt;p&gt;When someone quotes a feature quickly, they're picturing the &lt;em&gt;happy path&lt;/em&gt; — the version where everything works. Customer clicks the button, the data's valid, the payment goes through, the page loads. That mental movie runs in a few seconds and feels like the whole job.&lt;/p&gt;

&lt;p&gt;It's maybe a third of the job.&lt;/p&gt;

&lt;p&gt;Everything the demo doesn't show sits underneath: what happens when the data's wrong, when the payment provider times out, when the customer double-clicks and you're charged twice, what the screen looks like while it's loading and when it's empty and when it errors. Then someone has to test it, find the problems, fix them, and re-test the things the fix broke. None of that is in the two-week picture, and none of it is optional. A feature that only handles the happy path isn't 90% done — it's a prototype that happens to demo well.&lt;/p&gt;

&lt;p&gt;There's a second trap hiding in every fast number. A developer estimates &lt;em&gt;effort&lt;/em&gt; — "that's about five days of work" — and it gets heard as &lt;em&gt;duration&lt;/em&gt; — done in a week. Those aren't the same thing. Five days of actual focused work don't fit into a five-day week, because a working day isn't eight hours of that one task. It's meetings, other people's questions, the context-switching, the admin. Five days of effort is closer to two weeks on the calendar before anything goes wrong.&lt;/p&gt;

&lt;p&gt;So when you hear a fast, small number, the honest translation is: &lt;em&gt;that's the best case, for the part they can see, if nothing surprises them.&lt;/em&gt; Something always surprises them. That's not pessimism — it's the base rate. If you want the longer engineering version of why this happens, I wrote it up in &lt;a href="https://iurii.rogulia.fi/blog/estimating-software-honestly" rel="noopener noreferrer"&gt;why "two weeks" always means six&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Questions That Expose a Padded or Naive Estimate
&lt;/h2&gt;

&lt;p&gt;You don't need technical vocabulary to test a quote. You need questions that force the person to reveal how they think about uncertainty. Ask these, and listen to the &lt;em&gt;shape&lt;/em&gt; of the answer more than the content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"What's your estimate, and what's the range?"&lt;/strong&gt; A single number is a red flag. Not because ranges are more honest as a style, but because uncertainty is real and a single number pretends it isn't. A good answer sounds like: &lt;em&gt;"Three weeks if the payment integration behaves the way its docs claim, five if it fights us the way these usually do, and my honest expectation is around four."&lt;/em&gt; That person has thought about what could go wrong. The one who insists on a single confident number either hasn't, or is hiding it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"What's not included in this price?"&lt;/strong&gt; The answer tells you whether they've thought past the happy path. "Nothing, it's all included" from a cheap quote means they haven't mapped the work. A real answer names things: data migration, third-party costs, revisions after your feedback, testing, the stuff that's genuinely out of scope. A quote that can't tell you what it &lt;em&gt;excludes&lt;/em&gt; hasn't defined what it &lt;em&gt;includes&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"What are you assuming to hit this number?"&lt;/strong&gt; Every estimate rests on assumptions. "Three weeks, &lt;em&gt;assuming&lt;/em&gt; your existing data is clean and the payment provider's system works as documented" is a real estimate — because when an assumption breaks, you get a named reason instead of an unexplained overrun. Someone who can't state their assumptions is giving you a number that stands on nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"What would make this take twice as long?"&lt;/strong&gt; This one's diagnostic. The person who says "nothing, it's straightforward" is telling you they haven't found the risks yet — which means you'll find them together, later, at your expense. The person who can immediately name two or three things that could blow up the timeline has actually looked at the problem.&lt;/p&gt;

&lt;p&gt;The pattern underneath all four: &lt;strong&gt;you're not testing their coding. You're testing whether they've thought about what they don't know.&lt;/strong&gt; A confident answer to every question is the worrying one. Some honest hesitation, some "it depends, here's what it depends on," is the sound of someone who's done this before.&lt;/p&gt;

&lt;p&gt;
  slug="technical-consultation"&lt;br&gt;
  text="If you've got a proposal in front of you and no way to tell whether the number is real, that's a single-session job. I read the quote as an engineer, ask the seller the questions you can't, and tell you what it's actually pricing — before you sign anything."&lt;br&gt;
/&amp;gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When a High Quote Is &lt;em&gt;Not&lt;/em&gt; Justified
&lt;/h2&gt;

&lt;p&gt;Filtering out lowballs is only half the skill. The other half is not overpaying, because expensive isn't automatically honest either. Some high quotes are padding, over-engineering, or someone charging you for complexity you don't need yet.&lt;/p&gt;

&lt;p&gt;Here's how to tell the difference. A justified high number comes with a justified high &lt;em&gt;scope&lt;/em&gt; — it's expensive because it's solving a genuinely hard problem, handling real edge cases, or building something that has to survive scale you actually have. The seller can point at what the money buys and it maps to something you recognize as real.&lt;/p&gt;

&lt;p&gt;An &lt;em&gt;unjustified&lt;/em&gt; high number is expensive because the seller is building for a future you don't have yet. Watch for these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;They're pricing for scale you don't need.&lt;/strong&gt; You have zero users and they're quoting a system that handles a million. Building for load you don't have is real work you're paying for and won't use for years, if ever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They're gold-plating the parts that don't matter.&lt;/strong&gt; A beautiful admin dashboard with analytics before you have anyone to analyse. Elaborate user roles and permissions before you have a second type of user. These sound responsible and are, at your stage, expensive guesses. I listed the specific ones that reliably kill early products in &lt;a href="https://iurii.rogulia.fi/blog/mvp-features-that-kill-products" rel="noopener noreferrer"&gt;features that kill MVPs&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They can't explain the number in your language.&lt;/strong&gt; If a seller can't tell you &lt;em&gt;why&lt;/em&gt; it costs what it costs without retreating into jargon, one of two things is true: they don't understand it well enough to explain, or they'd rather you didn't understand it well enough to question it. Neither is worth your money.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest expensive quote and the honest cheap quote have the same tell: the person can walk you through what the money buys, in plain words, and it holds up. The dishonest versions — padded high or naive low — both fall apart the moment you ask them to explain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The One Thing to Do Before You Sign
&lt;/h2&gt;

&lt;p&gt;If you take one action from this, make it this: &lt;strong&gt;ask each bidder to separate the estimate from the commitment.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The estimate is their honest technical guess at the effort. The commitment is the date and price they'll actually stand behind. These should be different, and the gap between them — the buffer for the things nobody can see yet — should be &lt;em&gt;visible&lt;/em&gt;, not hidden.&lt;/p&gt;

&lt;p&gt;A seller who says &lt;em&gt;"my estimate is six weeks, but given what we don't yet know about your existing data, I wouldn't commit to a customer-facing date tighter than nine"&lt;/em&gt; is doing the honest thing out loud. A seller who gives you one number that's secretly the estimate promised as a commitment with no buffer is setting up the overrun you'll live through in month three.&lt;/p&gt;

&lt;p&gt;And here's the filter, stated plainly, because it cuts both ways. If you punish the honest sellers — the ones who give you ranges, name their risks, and refuse to promise a comforting single number — and reward the confident lowballer, you are training your suppliers to lie to you. You will get exactly the quotes you selected for: small, confident, and wrong. The seller who won't compress an honest range into the number you want to hear is being useful, not evasive. That's the one to keep.&lt;/p&gt;

&lt;p&gt;You can't read the code. You don't have to. You can read whether the person in front of you is comfortable being honest about what they don't know — and in software, that's the thing that predicts whether the project ships.&lt;/p&gt;




&lt;p&gt;If you're staring at two quotes with a gap you can't explain, don't guess. &lt;a href="https://iurii.rogulia.fi/contact" rel="noopener noreferrer"&gt;Let's read the proposal together&lt;/a&gt; — I'll tell you which number is real and which one is a problem you haven't met yet.&lt;/p&gt;

</description>
      <category>business</category>
      <category>estimation</category>
      <category>consulting</category>
      <category>decision</category>
    </item>
    <item>
      <title>Expense Report Fraud Detection: The Altered Receipt PDF Your Controls Never Inspect</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 24 Aug 2026 10:00:41 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/expense-report-fraud-detection-the-altered-receipt-pdf-your-controls-never-inspect-da0</link>
      <guid>https://dev.to/iurii_rogulia/expense-report-fraud-detection-the-altered-receipt-pdf-your-controls-never-inspect-da0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/expense-receipt-fraud-altered-pdf" 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;The Association of Certified Fraud Examiners puts expense reimbursement fraud at a median loss of $40,000 per scheme, with a median duration of two years before detection. That two-year gap is not an accident. It is the predictable result of a control environment that validates everything about an expense claim except the one artifact the claim rests on: the receipt PDF itself.&lt;/p&gt;

&lt;p&gt;First, a quick boundary, because this site covers two different fraud classes that people often confuse. &lt;strong&gt;Vendor invoice fraud&lt;/strong&gt; — a supplier’s invoice intercepted and edited in transit, or a fake supplier billing for nothing — is a different document, a different submitter, and a different control. We cover it in the &lt;a href="https://htpbe.tech/blog/invoice-fraud-accounts-payable-pdf" rel="noopener noreferrer"&gt;accounts payable invoice fraud&lt;/a&gt; breakdown. &lt;strong&gt;This post is about the other case&lt;/strong&gt;: employee-submitted expense receipts and hotel folios in a travel-and-entertainment (T&amp;amp;E) reimbursement workflow. Same finance team, completely different blind spot. The vendor-invoice control is the three-way match. The expense-receipt control is the expense policy. Neither one looks at the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The T&amp;amp;E Control Environment, and Where It Stops
&lt;/h2&gt;

&lt;p&gt;Walk through how a typical mid-market expense claim is checked, and you can see exactly where the inspection ends.&lt;/p&gt;

&lt;p&gt;An employee returns from a trip, opens the expense tool — Concur, Expensify, Ramp, Brex, a homegrown form — and uploads receipt images and PDFs against each line. A manager approves. Finance spot-checks. If the company is large enough to care about SOX, the controllership layer adds &lt;strong&gt;detective controls&lt;/strong&gt;: sampling, duplicate-detection, policy-threshold flags, and the famous &lt;strong&gt;three-way match&lt;/strong&gt; — purchase order against invoice against goods receipt.&lt;/p&gt;

&lt;p&gt;Every one of those controls operates on &lt;em&gt;data extracted from&lt;/em&gt; the receipt, never on the receipt as a file. The three-way match confirms that an invoice amount agrees with a purchase order and a goods-received note. It is an excellent control for procurement. But it says nothing about whether the hotel folio an employee attached to a $312 line was the folio the hotel actually issued, or a copy of it with the room rate quietly raised from $189 to $312.&lt;/p&gt;

&lt;p&gt;This is the structural blind spot in detective controls for accounts payable: the three-way match was designed for &lt;em&gt;vendor-billed&lt;/em&gt; spend that flows through purchasing. Employee reimbursements flow through a different door. They are usually self-submitted, individually small, and — most important — almost always &lt;em&gt;below&lt;/em&gt; the approval and sampling thresholds that trigger review. A $312 dinner does not get a second look. A thousand $312 dinners across a sales team over two years is the $40,000 ACFE median walking out the door.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Receipt Fraud Survives the Standard Checks
&lt;/h2&gt;

&lt;p&gt;Four properties of T&amp;amp;E make the receipt PDF the softest target in the finance stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It sits below the threshold.&lt;/strong&gt; Sampling and dual approval start at amounts that most travel line items never reach. The fraud is sized to stay unnoticed — small edits, repeated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is self-submitted.&lt;/strong&gt; Unlike a vendor invoice that arrives over a (theoretically) controlled channel, the receipt is whatever the employee chooses to upload. There is no handshake with an issuing system. The finance team receives a file and a claim, and is asked to trust that the two match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is reviewed visually, if at all.&lt;/strong&gt; A manager glancing at a folio is checking that it looks like a hotel bill for roughly the right city on roughly the right dates. An edited room rate, an added minibar line, a date moved to bring a personal weekend inside the business-trip window — none of that is visible to a human approver who has no original to compare against. OCR-based duplicate detection reads the &lt;em&gt;displayed&lt;/em&gt; number; it cannot tell whether that number was the one the hotel printed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The genuine receipts are messy too.&lt;/strong&gt; Real receipts come as phone-camera photos, scanner output, airline emails saved to PDF, and folios generated by whatever property-management system the hotel runs. The finance team has no clean baseline to compare against, which is exactly the cover an edited receipt needs.&lt;/p&gt;

&lt;p&gt;The result: the receipt PDF is the only document in the entire reimbursement chain that nobody inspects structurally, and it is the one document the claim depends on entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an Altered Receipt Actually Looks Like
&lt;/h2&gt;

&lt;p&gt;The common T&amp;amp;E manipulations are simple and effective:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The inflated restaurant receipt.&lt;/strong&gt; A real $84 dinner becomes a $184 dinner by editing the total. The restaurant name, the date, and the card’s last four digits all stay correct — only the number changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The edited hotel folio.&lt;/strong&gt; A genuine folio for a discounted rate is opened, and the nightly rate or the number of nights is raised. Fake hotel folio fraud is a long-standing favorite, because folios are dense, full of line items, and rarely checked against the booking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The doctored rideshare or airfare receipt.&lt;/strong&gt; An Uber or airline receipt saved to PDF, then edited to raise the fare or change the route so a personal trip looks like business.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The date shift.&lt;/strong&gt; The amount does not change; a single date is moved so a non-reimbursable expense falls inside an approved travel window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In each case the fraudster starts from a &lt;em&gt;real&lt;/em&gt; document and changes a value. That detail matters a great deal, because it is exactly the pattern structural tamper detection is built to catch. The original file was generated by a hotel system, an airline, or a card terminal’s e-receipt service. The edit was made afterward, in a consumer PDF editor, and opening and re-saving that file leaves a structural record — the editor cannot make the change without disturbing the document’s internal construction.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Structural Tamper Detection Catches the Edit
&lt;/h2&gt;

&lt;p&gt;HTPBE is a file-integrity layer. It does not read the dollar amount on the receipt, decide whether $312 is reasonable, or know what the hotel charged. It is &lt;strong&gt;not&lt;/strong&gt; a KYC or identity tool, and it does not validate content. What it does is analyze how the PDF was &lt;em&gt;built&lt;/em&gt; — its cross-reference (xref) tables, its incremental update layers, its signature state, and the producer and date metadata that records which software touched the file and when — and return one of three verdicts: &lt;code&gt;intact&lt;/code&gt;, &lt;code&gt;modified&lt;/code&gt;, or &lt;code&gt;inconclusive&lt;/code&gt;, plus a list of named markers that describe what was found.&lt;/p&gt;

&lt;p&gt;When a genuine institutional receipt is opened in a desktop editor and a value is changed, the editor appends an &lt;strong&gt;incremental update&lt;/strong&gt; layer rather than rebuilding the file from scratch, and it rewrites the metadata to record itself as the last tool that saved the document. A folio that claims to come from a hotel property-management system but carries the modification fingerprints of a consumer PDF editor is internally inconsistent in a way the displayed page never reveals.&lt;/p&gt;

&lt;p&gt;Here is the response on a hotel folio that was issued cleanly, then opened in a desktop PDF editor to raise the nightly rate. First submit the file:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;That returns an &lt;code&gt;id&lt;/code&gt;. Retrieve the verdict:&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;"7c4a1f90-2b6e-4d3a-9f81-5e2c8a4b7d10"&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="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"HTPBE_MULTIPLE_REVISION_LAYERS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"HTPBE_EDITING_TOOL_FINGERPRINT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"HTPBE_DATES_DISAGREE"&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;"origin"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"consumer_software"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"software"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"desktop PDF editor"&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;"creator"&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;"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;"a desktop PDF editor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"xref_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"has_digital_signature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"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;1755302400&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;1755561600&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;Three things line up. &lt;code&gt;HTPBE_MULTIPLE_REVISION_LAYERS&lt;/code&gt; reports that the file carries more than one revision layer — it was modified after it was first created. &lt;code&gt;HTPBE_EDITING_TOOL_FINGERPRINT&lt;/code&gt; reports the fingerprints of a known PDF editing tool, which is not how a hotel system produces a folio. &lt;code&gt;HTPBE_DATES_DISAGREE&lt;/code&gt; reports that the modification timestamp is three days later than the declared creation date. On its own, any one of these can have an innocent explanation. Together, on a document that is supposed to be a freshly issued folio, they are a high-confidence signal that the file was opened and re-saved between issuance and submission.&lt;/p&gt;

&lt;p&gt;The verdict is &lt;code&gt;modified&lt;/code&gt; with named markers. That is enough to &lt;strong&gt;hold the line item for review&lt;/strong&gt; — not enough to fire the employee. A &lt;code&gt;modified&lt;/code&gt; verdict is a structural anomaly, not a confession. It justifies a reviewer asking the employee for the original folio, or calling the hotel. Other markers in the same family carry the same weight where they apply: &lt;code&gt;HTPBE_CHARACTER_OVERLAY_EDIT&lt;/code&gt; (targeted character-level overlays added by a desktop editor) and &lt;code&gt;HTPBE_TEXT_OVERLAY_ON_SCAN&lt;/code&gt; (values painted on top of a scanned receipt) describe the two most common ways a number gets changed on a receipt. The full id-to-outcome dictionary is published on &lt;a href="https://htpbe.tech/how" rel="noopener noreferrer"&gt;htpbe.tech/how&lt;/a&gt;, so you can branch your routing logic on the marker id and render the human-readable label from one place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Want to See It on Your Own Receipts?
&lt;/h2&gt;

&lt;p&gt;Before you integrate anything, pull a handful of receipts from a closed expense cycle — some you trust, ideally one or two you have already flagged — and run them through the &lt;a href="https://htpbe.tech/" rel="noopener noreferrer"&gt;free PDF check&lt;/a&gt; on the homepage. No API key, no signup. You will get the verdict and the markers in a few seconds. This is the fastest way to see whether this layer catches anything in &lt;em&gt;your&lt;/em&gt; actual receipt flow before you spend a line of engineering time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;inconclusive&lt;/code&gt; Verdict Is What Matters Most in T&amp;amp;E
&lt;/h2&gt;

&lt;p&gt;Here is where expense receipts differ sharply from vendor invoices, and where most teams misread the tool if they are not warned.&lt;/p&gt;

&lt;p&gt;A large share of &lt;em&gt;legitimate&lt;/em&gt; receipts are generated by consumer software. A genuine Uber receipt is an HTML email rendered to PDF. A genuine restaurant e-receipt comes from a card-terminal service. A photo of a paper receipt is a phone-camera scan. None of these are institutional documents in the way a bank statement or a notarized deed is. When HTPBE sees a file with no prior structure to compare against — a clean, single-revision document built in consumer software — it returns &lt;code&gt;inconclusive&lt;/code&gt;:&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;"b91e7d23-4c8a-4f15-8e6d-3a9f1c7b2e04"&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;"inconclusive"&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_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"consumer_software_origin"&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"origin"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"consumer_software"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"software"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"an HTML-to-PDF renderer"&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;"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;"an HTML-to-PDF renderer"&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;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;"has_digital_signature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"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;1755302400&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="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;inconclusive&lt;/code&gt; is &lt;strong&gt;not&lt;/strong&gt; a failure and it is &lt;strong&gt;not&lt;/strong&gt; a fraud signal. It means this file was built by consumer software, so there is no institutional baseline against which integrity can be proven or disproven. On a vendor invoice that claims to come from QuickBooks, &lt;code&gt;inconclusive&lt;/code&gt; is suspicious. On an Uber receipt, &lt;code&gt;inconclusive&lt;/code&gt; is &lt;em&gt;exactly what a genuine receipt looks like&lt;/em&gt;. If you auto-reject every &lt;code&gt;inconclusive&lt;/code&gt; in a T&amp;amp;E pipeline, you will reject most of your honest receipts and learn nothing.&lt;/p&gt;

&lt;p&gt;The correct way to use &lt;code&gt;inconclusive&lt;/code&gt; in expense workflows is to &lt;strong&gt;route on the claimed issuer, not on the verdict alone&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A receipt that claims to be a hotel folio or an airline e-ticket and returns &lt;code&gt;inconclusive&lt;/code&gt; deserves a second look. Those issuers normally produce structurally distinctive output, so a consumer-software origin is slightly off-profile.&lt;/li&gt;
&lt;li&gt;A receipt that claims to be a restaurant tab, a rideshare, or a phone photo of paper and returns &lt;code&gt;inconclusive&lt;/code&gt; is unremarkable. That is the expected result. Move on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the single most important operating instruction for T&amp;amp;E: &lt;code&gt;modified&lt;/code&gt; is your hold signal; &lt;code&gt;inconclusive&lt;/code&gt; is a routing hint that means something only when compared with what the receipt &lt;em&gt;claims&lt;/em&gt; to be. Treat them differently, or the tool is useless to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integrating Into the Reimbursement Workflow
&lt;/h2&gt;

&lt;p&gt;The check fits in at submission, before the claim enters the approval queue. Three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;At upload&lt;/strong&gt;, when the employee attaches a receipt, push the stored file URL to &lt;code&gt;POST /api/v1/analyze&lt;/code&gt;. You get back an &lt;code&gt;id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the verdict&lt;/strong&gt; from &lt;code&gt;GET /api/v1/result/{id}&lt;/code&gt;. Branch on &lt;code&gt;status&lt;/code&gt; and the &lt;code&gt;modification_markers[]&lt;/code&gt; array.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route the claim.&lt;/strong&gt; &lt;code&gt;intact&lt;/code&gt; → continue to normal approval. &lt;code&gt;modified&lt;/code&gt; → flag the line item and hold it for a reviewer with the marker list attached. &lt;code&gt;inconclusive&lt;/code&gt; → apply your claimed-issuer rule above.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_receipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_url&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.htpbe.tech/v1/analyze&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;HTPBE_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;submitted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;file_url&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;check_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;submitted&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.htpbe.tech/v1/result/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;check_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&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="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                 &lt;span class="c1"&gt;# intact | modified | inconclusive
&lt;/span&gt;    &lt;span class="n"&gt;markers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;modification_markers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# e.g. ["HTPBE_MULTIPLE_REVISION_LAYERS"]
&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;modified&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HOLD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;markers&lt;/span&gt;                &lt;span class="c1"&gt;# structural edit — reviewer follows up
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inconclusive&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ROUTE_BY_ISSUER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;markers&lt;/span&gt;     &lt;span class="c1"&gt;# apply claimed-issuer rule
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PASS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;markers&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store the &lt;code&gt;id&lt;/code&gt; against the expense line. There is no numeric risk score — the API returns a verdict plus named markers, never a 0–100 number — so your audit trail is a stable verdict and the specific markers that produced it. If a reimbursement is ever disputed or pulled into an internal-audit sample, &lt;code&gt;GET /api/v1/result/{id}&lt;/code&gt; retrieves the immutable forensic record that shows which markers triggered the hold and when. That is a detective control with an evidence trail, which is exactly what a SOX environment wants.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Cannot Catch — Stated Plainly
&lt;/h2&gt;

&lt;p&gt;Structural tamper detection has a hard boundary, and pretending otherwise would set you up to fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Receipts fabricated from scratch are out of scope.&lt;/strong&gt; If a fraudster does not edit a real folio but instead &lt;em&gt;builds&lt;/em&gt; a fake receipt from nothing — a convincing hotel bill generated in a design tool or on a receipt-generator site — the file has no prior structure to be inconsistent with. It is internally clean. HTPBE returns &lt;code&gt;inconclusive&lt;/code&gt; (consumer-software origin) on such a file, exactly as it would for a genuine consumer-software receipt. The structural layer cannot tell a well-made fake-from-scratch apart from an honest one, because &lt;em&gt;neither has anything to contradict&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI-generated receipts are out of scope for the same reason.&lt;/strong&gt; A receipt produced end-to-end by a generative model is born synthetic. There is no edit to detect, because there was no original to edit. Catching fabricated and AI-generated receipts is a &lt;em&gt;content-layer&lt;/em&gt; problem — cross-referencing the claimed transaction against issuer data, card-network records, or booking systems — which is a different category of product from file-integrity forensics.&lt;/p&gt;

&lt;p&gt;What HTPBE catches reliably is the &lt;strong&gt;alteration of a genuine document&lt;/strong&gt;: the real folio with the rate raised, the real airfare receipt with the route changed, the real restaurant tab with the total raised. That is the most common T&amp;amp;E manipulation, precisely because starting from a real receipt is easier than fabricating a convincing one — and it is the manipulation that structural forensics is built to surface. Use it for what it is: one layer in a defense that also includes policy controls, issuer cross-checks for high-value claims, and human review of what gets flagged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who This Is For
&lt;/h2&gt;

&lt;p&gt;If you own a T&amp;amp;E control environment — Head of Internal Audit, Controller, AP or expense operations lead, a finance team with a SOX detective-controls mandate — this closes a gap your existing stack genuinely does not cover. The three-way match protects procurement. Sampling and duplicate detection catch the obvious cases. Neither inspects the receipt PDF as a file, and the receipt is where reimbursement fraud lives.&lt;/p&gt;

&lt;p&gt;A practical starting point is the &lt;a href="https://htpbe.tech/use-cases/expense-reimbursement" rel="noopener noreferrer"&gt;expense reimbursement fraud&lt;/a&gt; use-case guide for routing patterns and policy integration, and the &lt;a href="https://htpbe.tech/use-cases/fake-invoice-detection/fake-receipt-detection" rel="noopener noreferrer"&gt;fake receipt detection&lt;/a&gt; deep-dive for the document-class detail. When you are ready to connect the check to your submission flow, the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;API reference&lt;/a&gt; lists the full request and response contract, and you can &lt;a href="https://htpbe.tech/auth/signup" rel="noopener noreferrer"&gt;create an account&lt;/a&gt; to generate a key. Run a sample of closed expense reports through it first. The math on a check that costs a fraction of a cent against a $40,000 median scheme only matters if the layer catches a meaningful share of &lt;em&gt;your&lt;/em&gt; actual receipt flow, and a sampling pass is how you find out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do you detect a fake or altered expense receipt?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;By analyzing the receipt PDF’s structure rather than its displayed content. When a genuine receipt is opened in a desktop editor and a value is changed, the editor appends a new revision layer and rewrites the metadata to record itself as the last tool that saved the file. HTPBE returns &lt;code&gt;modified&lt;/code&gt; with named markers such as &lt;code&gt;HTPBE_MULTIPLE_REVISION_LAYERS&lt;/code&gt; and &lt;code&gt;HTPBE_EDITING_TOOL_FINGERPRINT&lt;/code&gt; when those structural fingerprints are present on a document that is supposed to be a freshly issued receipt. It does not read the dollar amount and does not decide whether the amount is reasonable — that is a content question.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the three-way match catch expense receipt fraud?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The three-way match validates a vendor invoice against a purchase order and a goods receipt — it is a procurement control. Employee expense reimbursements are self-submitted, usually below sampling thresholds, and do not pass through purchasing, so the three-way match never sees them. It also never inspects the receipt PDF as a file under any circumstance. That is the structural blind spot in detective controls for accounts payable that file-integrity analysis fills.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does an &lt;code&gt;inconclusive&lt;/code&gt; verdict mean on an expense receipt?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It means the receipt was generated by consumer software — an HTML-to-PDF renderer, a phone-camera scan, a card-terminal e-receipt — so there is no institutional baseline to prove or disprove integrity against. A large share of &lt;em&gt;genuine&lt;/em&gt; receipts return &lt;code&gt;inconclusive&lt;/code&gt;, so it is not a fraud signal on its own. Route it by what the receipt claims to be: &lt;code&gt;inconclusive&lt;/code&gt; on a hotel folio is slightly off-profile and worth a look; &lt;code&gt;inconclusive&lt;/code&gt; on a rideshare or restaurant tab is the expected, normal result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can you catch a receipt that was fabricated from scratch or generated by AI?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No — that is the honest limit. A receipt built from nothing in a design tool or produced by a generative model has no original to be inconsistent with, so structural forensics returns &lt;code&gt;inconclusive&lt;/code&gt;, the same as a genuine consumer-software receipt. Catching fakes built from scratch and AI-generated fakes requires content-layer verification against issuer or card-network data, which is a different category of tool. HTPBE reliably catches the &lt;em&gt;alteration of a genuine document&lt;/em&gt;, which is the most common T&amp;amp;E manipulation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where does this fit in a SOX control environment?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As an automated detective control at the point of receipt submission, with an evidence trail. Each check produces a stable verdict plus the specific markers that justified it, retrievable later via &lt;code&gt;GET /api/v1/result/{id}&lt;/code&gt; if a reimbursement is disputed or sampled in an internal-audit review. It complements policy controls and sampling rather than replacing them — it adds inspection of the one artifact those controls historically never examine.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>fintech</category>
      <category>fraud</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
