<?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>When a Human Is Your Integration</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 10 Aug 2026 10:00:50 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/when-a-human-is-your-integration-2gk9</link>
      <guid>https://dev.to/iurii_rogulia/when-a-human-is-your-integration-2gk9</guid>
      <description>&lt;p&gt;Most businesses don't buy disconnected systems on purpose. They arrive one tool at a time. You start with a spreadsheet. You add a CRM because the spreadsheet stopped scaling. Accounting software because the taxman insisted. A shipping portal because you started sending parcels. Each choice was sensible on its own day.&lt;/p&gt;

&lt;p&gt;Then one morning you look up and realize that none of these tools talk to each other — and the thing holding them together is a person. Someone reads a number off one screen and types it into another. That person is your integration. And that's a cost you're paying every day without ever seeing it on an invoice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cost You Don't Get Billed For
&lt;/h2&gt;

&lt;p&gt;Here's the trap. The cost of a disconnected system doesn't show up anywhere you look. It's not a subscription. It's not a line item. It's hidden inside a job title.&lt;/p&gt;

&lt;p&gt;Someone on your team spends the first hour of every day taking yesterday's orders and copying them, one at a time, from the checkout into the accounting system, then into the CRM, then into the shipping portal. It feels like work, because it is. Nobody questions it, because it's always been done that way.&lt;/p&gt;

&lt;p&gt;But watch what actually happens as you grow. At ten orders a day, that copying is an hour. At thirty, it's most of a morning. At sixty, it's a full-time role whose entire purpose is retyping data that already exists somewhere else. You never made a decision to create that role. It grew on its own, one order at a time, and now it sets a limit: you can only take as many orders as one person can hand-transcribe before they burn out or start making mistakes.&lt;/p&gt;

&lt;p&gt;That's the real shape of the cost. It isn't the minutes. It's the ceiling. When a human is your integration, your growth is capped at the throughput of that human — and you'll experience the cap not as a cost, but as a vague sense that "we can't handle more right now."&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Errors Live
&lt;/h2&gt;

&lt;p&gt;The second cost is quieter and more expensive: the mistakes.&lt;/p&gt;

&lt;p&gt;Anything a person copies by hand between two systems will eventually be copied wrong. Not because they're careless — because they're human, and it's the fiftieth address of the day. A digit drops off a VAT number. A shipping address goes to the old one. A price on the invoice doesn't match the price in the contract. Each of these is small until it isn't: an order stuck at a border, a parcel sent to the wrong city, a customer who now trusts you a little less.&lt;/p&gt;

&lt;p&gt;I built a shipping automation for a manufacturer where, before the work, every parcel was booked by hand — a staff member copied the name, address and phone from one system into the carrier's portal, generated a label, and emailed the tracking number back to the customer. Five to seven minutes each, twenty to fifty times a day. The minutes were bad enough. But the real problem was the country codes: the UK arrives written as "UK" in one system and the carrier only accepts "GB". A human catches that most of the time. Most of the time is exactly the problem. Once the systems were connected, that translation happened the same way every time, and shipment errors dropped to a handful of isolated cases a month.&lt;/p&gt;

&lt;p&gt;The pattern generalizes. Every seam where a person hand-carries data between two tools is a place errors are born. Connecting the systems doesn't just save the copying — it removes the entire category of mistake.&lt;/p&gt;

&lt;p&gt;
  slug="api-integrations"&lt;br&gt;
  text="Most 'we can't handle more volume' problems are really a person hand-carrying data between two systems that were never introduced to each other. I connect them — checkout to accounting, CRM to shipping — so the data moves once, correctly, without anyone retyping it."&lt;br&gt;
/&amp;gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Connected" Actually Buys You
&lt;/h2&gt;

&lt;p&gt;When systems talk to each other directly, the human stops being the glue. The data moves once, on its own, correctly.&lt;/p&gt;

&lt;p&gt;On another project — an international e-commerce build for a manufacturer expanding across Europe — the order workflow used to be six manual steps across four tools: confirm the payment, create the deal in the CRM, log the accounting entry, book the shipment, generate the invoice, email it out. About thirty minutes per order, by hand, every time. Fine at a dozen orders a week. Impossible at the volume a European launch would bring.&lt;/p&gt;

&lt;p&gt;The fix wasn't a better spreadsheet or a faster typist. It was connecting the tools so that the moment a payment cleared, one flow fired: the CRM got the deal, the accounting system got the entry, the shipment got booked, the invoice generated, and the confirmation email landed — with no one touching any of it. Payment to invoice in the customer's inbox dropped from about thirty minutes to under two.&lt;/p&gt;

&lt;p&gt;Notice what that number really unlocks. The thirty-minute saving per order is nice. But the point is that opening a new market stopped requiring more people. A business that used to grow by hiring transcribers could now grow by changing a setting. The ceiling moved from "how many orders can one person process" to "how many can the system handle" — which is a much higher ceiling.&lt;/p&gt;

&lt;p&gt;That's what connecting systems actually buys: not saved minutes, but a removed limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Manual Is Genuinely Fine
&lt;/h2&gt;

&lt;p&gt;Here's the part most articles on this topic won't tell you, because they're usually written by someone selling the integration: not every gap between two tools is worth closing. Sometimes a human copying data by hand is exactly the right answer, and automating it would be a waste of your money.&lt;/p&gt;

&lt;p&gt;Leave it manual when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It happens rarely.&lt;/strong&gt; If you move data between two systems twice a month, connecting them is a hobby, not an investment. The build will cost more than a decade of copying ever would. A person and a spreadsheet is the correct tool for genuinely occasional work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The judgment is the point.&lt;/strong&gt; Some steps look like data entry but are actually a person deciding something — whether this order looks fraudulent, whether this customer gets an exception, whether these two records are really the same company. Automating the copying is fine; automating the judgment produces fast, confident mistakes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The process is still changing.&lt;/strong&gt; If you're not sure the workflow is right yet, don't cement it in code. You'll pay to automate a process you abandon in three months. Let it stabilize by hand first, then connect it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The volume is flat and low, and it's staying there.&lt;/strong&gt; A connection earns its cost by removing a ceiling. If there's no ceiling — if you're not trying to grow past what one person can handle — there's less to gain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The test is simple. A gap between two systems is worth closing when the copying is &lt;strong&gt;frequent, error-prone, and standing between you and more business.&lt;/strong&gt; If it's rare, judgment-heavy, or genuinely not blocking anything, leave it alone. Connecting it would be complexity for its own sake, and you'd be paying to maintain a machine that solves a problem you didn't have.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Find Yours
&lt;/h2&gt;

&lt;p&gt;You don't need an audit to spot the integration you're missing. You need to answer one question honestly: &lt;strong&gt;if your volume doubled next month, whose job would break first?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Whatever you just pictured — some person, some spreadsheet, some end-of-day ritual of moving numbers between screens — that's where a human is currently doing the work two systems should be doing for each other. It's usually the thing everyone has quietly accepted as "just how it works around here." Nobody's counting the hours, because the hours were never the point. The ceiling was.&lt;/p&gt;

&lt;p&gt;If that seam is frequent, error-prone, and capping how much business you can take, connecting the two systems is one of the highest-return things you can do — not because it saves time, but because it takes the limit off. And if the honest answer is "actually, that's fine by hand" — then you've just saved yourself a project. Knowing the difference is most of the value.&lt;/p&gt;




&lt;p&gt;If you can name the person who'd drown first when your volume doubles, you've already found the two systems that need to start talking. &lt;a href="https://iurii.rogulia.fi/contact" rel="noopener noreferrer"&gt;Let's look at what that gap is costing you&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>business</category>
      <category>integration</category>
      <category>operations</category>
      <category>decision</category>
    </item>
    <item>
      <title>PDF Generator Fingerprints: What Software Made This File (And Where It Lies)</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 10 Aug 2026 10:00:41 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/pdf-generator-fingerprints-what-software-made-this-file-and-where-it-lies-1m3f</link>
      <guid>https://dev.to/iurii_rogulia/pdf-generator-fingerprints-what-software-made-this-file-and-where-it-lies-1m3f</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/pdf-generator-tool-fingerprints" 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;Two bank statements land in your underwriting queue. Both look like they came from the same bank. Both open cleanly. Both show the account holder you expect. One was generated by the bank’s statement engine. The other was rebuilt in a desktop editor, with the closing balance quietly raised by a few thousand. From the outside, they are indistinguishable. From the inside, they were made by entirely different software — and that software left its name behind.&lt;/p&gt;

&lt;p&gt;Every PDF carries a fingerprint of the tool that produced it. Not a watermark you can see, but a set of structural habits: how the file lays out its objects, how it embeds fonts, what it writes into its own metadata, how it joins pages together. A risk team that learns to read these fingerprints gains a powerful, content-independent question to ask of any document: &lt;em&gt;does the software that claims to have made this file actually behave like that software?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This article walks through the major server-side PDF generation libraries, explains what the &lt;code&gt;Producer&lt;/code&gt; and &lt;code&gt;Creator&lt;/code&gt; fields really tell you (and where they lie), and shows how a structural analysis reads all of it automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two fields everyone looks at first
&lt;/h2&gt;

&lt;p&gt;Open any PDF’s properties and you will find two metadata fields that name software:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Creator&lt;/code&gt;&lt;/strong&gt; — the application a human used to author the document. Microsoft Word, Adobe InDesign, LaTeX, a bank’s internal reporting tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Producer&lt;/code&gt;&lt;/strong&gt; — the library that wrote the final PDF bytes. Adobe PDF Library, iText, ReportLab, the print-to-PDF subsystem of an operating system.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In a clean pipeline these tell a coherent story. A document authored in Word and saved to PDF reports &lt;code&gt;Creator: Microsoft Word&lt;/code&gt; and &lt;code&gt;Producer: Microsoft® Word&lt;/code&gt;. A LaTeX paper reports &lt;code&gt;Creator: TeX&lt;/code&gt; and &lt;code&gt;Producer: pdfTeX-1.40.26&lt;/code&gt;. The two fields together describe a real, plausible toolchain.&lt;/p&gt;

&lt;p&gt;The problem: both fields are just strings inside the file. Nothing in the PDF format forces them to be true. Anyone with a hex editor or a one-line script can set &lt;code&gt;Producer&lt;/code&gt; to whatever they like. A forger who knows their target bank uses a particular reporting engine can simply type that engine’s name into the field. So the naive check — “does the Producer string match what this institution uses?” — catches the lazy forger and misses the competent one.&lt;/p&gt;

&lt;p&gt;That is exactly why the &lt;em&gt;string&lt;/em&gt; is the beginning of the analysis, not the end. The far more interesting question is whether the rest of the file is consistent with the tool it names.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why generators are distinguishable in the first place
&lt;/h2&gt;

&lt;p&gt;A PDF is not a picture of a page. It is a small program: a tree of objects that describe fonts, text-positioning operators, images, and page structure, wrapped in a cross-reference table (the &lt;code&gt;xref&lt;/code&gt;) that tells a reader where each object lives. Turning a page into that object tree involves a thousand small decisions, and every generation library makes those decisions differently and &lt;em&gt;consistently&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Think of it like handwriting. Two people can both write the word “balance,” but the way they form their letters, space their words, and dot their i’s is habitual and hard to fake on purpose. PDF libraries are the same. The shape of the object tree, the way fonts are subset and embedded, the structure of the content stream, the defaults written into metadata — these are the library’s built-in habits. They are not settings the operator thinks about; they are byproducts of how that specific codebase was written.&lt;/p&gt;

&lt;p&gt;So when the &lt;code&gt;Producer&lt;/code&gt; field claims one library but the file is built with the structural habits of another, that contradiction is the signal. The claimed identity says “institutional reporting engine.” The construction says “desktop editor that re-saved this file.” They cannot both be true.&lt;/p&gt;

&lt;h2&gt;
  
  
  A field guide to the major generators
&lt;/h2&gt;

&lt;p&gt;Here is how the most common server-side and desktop pipelines behave, and what their presence tells a fraud team. This is descriptive — it is the same knowledge any developer who has shipped PDF features already has — not a recipe for evasion.&lt;/p&gt;

&lt;h3&gt;
  
  
  iText (Java / .NET)
&lt;/h3&gt;

&lt;p&gt;iText is a programmatic library: developers build the document object by object in code. It is heavily used inside enterprise systems — invoicing platforms, government portals, contract tools — precisely because it produces clean, predictable, single-pass files. A genuine iText document tends to be born in one operation, with no editing history layered on top. When you see iText behaving like iText, you are usually looking at machine-generated output from an institutional pipeline. The catch: iText is also freely available, so a forger can use it to build a fake document &lt;em&gt;from scratch&lt;/em&gt;. That is a different problem — fabrication, not modification — and we are honest about it below.&lt;/p&gt;

&lt;h3&gt;
  
  
  Apache PDFBox (Java)
&lt;/h3&gt;

&lt;p&gt;PDFBox both reads and writes PDFs, which makes it a common engine inside document-processing services and, unfortunately, inside re-save tools. A file that was loaded, altered, and written back out through PDFBox carries the structural signature of a &lt;em&gt;rebuild&lt;/em&gt; — the original construction is gone, replaced by PDFBox’s own layout. When an institution’s documents normally come from a specific reporting engine and one arrives bearing the habits of a generic read-write library, that mismatch is worth a closer look.&lt;/p&gt;

&lt;h3&gt;
  
  
  ReportLab (Python)
&lt;/h3&gt;

&lt;p&gt;ReportLab is the workhorse behind a huge share of Python-generated PDFs: invoices, reports, tickets, and — because it is so easy to script — a huge share of fabricated documents too. Like iText, a clean ReportLab file is single-pass and tidy. The forensic value is comparative: if you know the genuine issuer uses an enterprise reporting suite and the document in front of you is built the ReportLab way, the document did not come from where it claims.&lt;/p&gt;

&lt;h3&gt;
  
  
  Apache FOP
&lt;/h3&gt;

&lt;p&gt;FOP renders XSL-FO into PDF and is common in formal publishing pipelines — regulated statements, structured financial documents, legal filings. Its output is distinctive and rigid. FOP is rarely what a casual forger reaches for, so its presence is more often a sign of a genuine formatting pipeline than of tampering — but, as always, the question is whether the &lt;em&gt;rest&lt;/em&gt; of the file agrees.&lt;/p&gt;

&lt;h3&gt;
  
  
  wkhtmltopdf and Puppeteer / headless Chrome
&lt;/h3&gt;

&lt;p&gt;These render HTML to PDF. They power an enormous range of legitimate systems — payroll portals, HR platforms, SaaS invoice generators — and an equally enormous range of forgery operations, because building a fake payslip as an HTML template and rendering it is easy. This is the single most important honesty point in PDF forensics: &lt;strong&gt;an HTML-rendered PDF is structurally identical whether the data inside it is real or fabricated.&lt;/strong&gt; There is no byte-level signal that separates a genuine payroll PDF from a forged one when both came out of the same renderer. We return these as &lt;code&gt;inconclusive&lt;/code&gt; for exactly this reason — more on that below.&lt;/p&gt;

&lt;h3&gt;
  
  
  LibreOffice and Microsoft Office
&lt;/h3&gt;

&lt;p&gt;These are &lt;em&gt;consumer software&lt;/em&gt;. They exist so that anyone can author any document. A bank statement that reports &lt;code&gt;Creator: Microsoft Word&lt;/code&gt; is not evidence of tampering — but it is evidence that there is no meaningful “original” to verify against, because the whole document could have been typed from nothing. Real banks do not export your statement from Word. So an Office origin on a document that should have come from an institutional engine is itself a routing signal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ghostscript
&lt;/h3&gt;

&lt;p&gt;Ghostscript distills and rewrites PDFs. It is a legitimate part of many print and archival pipelines, but it is also a classic &lt;em&gt;laundering&lt;/em&gt; step: load a forged or modified file, run it through Ghostscript, and the output is a clean, single-xref file that no longer shows the incremental edits underneath. The rewrite itself leaves Ghostscript’s own habits in the file — which is why a re-distilled document claiming an institutional origin is a recognizable pattern rather than an invisible one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Born institutional vs. rebuilt by a consumer editor
&lt;/h2&gt;

&lt;p&gt;Step back from individual tools and the landscape splits cleanly in two.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;institutional&lt;/strong&gt; document is born from an automated pipeline that does the same thing for every customer, every day. The bank’s statement engine, the payroll system, the government portal’s certificate generator. These produce files with a consistent, single-pass construction and a &lt;code&gt;Producer&lt;/code&gt; that matches an enterprise toolchain. There is a genuine “original” — the file the institution actually emitted — and the question “was this modified after that institution made it?” has a real answer.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;consumer software&lt;/strong&gt; document is one anyone can make: Word, an online editor, a desktop PDF tool. There is no canonical original because the whole thing could have been authored from scratch. Asking “was this modified?” is the wrong question — there was never an authoritative version to deviate from.&lt;/p&gt;

&lt;p&gt;The fraud-relevant move is almost always a consumer tool &lt;em&gt;impersonating&lt;/em&gt; an institutional one. The forger takes a real statement, opens it in a desktop editor, changes a number, and saves. Or builds a lookalike from a template and renders it. The output claims an institutional &lt;code&gt;Producer&lt;/code&gt;, but it was built by consumer tools — and consumer tools have different habits than the engine they are pretending to be.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Want to see this on a real file right now? Drop any PDF into the &lt;a href="https://htpbe.tech/" rel="noopener noreferrer"&gt;free PDF check on the homepage&lt;/a&gt; and read back its origin classification — &lt;code&gt;institutional&lt;/code&gt;, &lt;code&gt;consumer software&lt;/code&gt;, &lt;code&gt;online editor&lt;/code&gt;, or &lt;code&gt;scanned&lt;/code&gt; — along with any modification markers. No signup needed for a single check.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What a structural analysis actually reads
&lt;/h2&gt;

&lt;p&gt;This is where HTPBE comes in. Instead of trusting the &lt;code&gt;Producer&lt;/code&gt; string, the analysis cross-examines it against the file’s construction and against the file’s own internal records. Several independent checks contribute. In outcome terms, HTPBE flags things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;HTPBE_TOOL_VS_STRUCTURE_MISMATCH&lt;/code&gt;&lt;/strong&gt; — the declared authoring software does not match how the file is actually built. The string says one thing; the bytes say another.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;HTPBE_FONT_VS_TOOL_MISMATCH&lt;/code&gt;&lt;/strong&gt; — font handling that is inconsistent with the tool the document claims to come from. Generators have characteristic ways of embedding type; a mismatch here is hard to fake.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;HTPBE_PRODUCER_IDENTITY_FORGED&lt;/code&gt;&lt;/strong&gt; — evidence that a declared institutional generator was spoofed by a third-party tool to disguise the file’s real origin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;HTPBE_RESIDUAL_PRIOR_GENERATOR&lt;/code&gt;&lt;/strong&gt; — residual identity left behind by a tool that tried to claim a different origin. The file remembers who really made it, even when the visible field was overwritten.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;HTPBE_EDITING_TOOL_FINGERPRINT&lt;/code&gt;&lt;/strong&gt; — fingerprints of a known PDF editing tool on a document that should never have passed through one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;HTPBE_ONLINE_EDITOR_ORIGIN&lt;/code&gt;&lt;/strong&gt; — the document carries the identity of an online editing or conversion service, which strips and rewrites original provenance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Underneath these, the timestamp and revision-history layers add more: &lt;strong&gt;&lt;code&gt;HTPBE_DATES_DISAGREE&lt;/code&gt;&lt;/strong&gt; when the modification date postdates the declared creation date, &lt;strong&gt;&lt;code&gt;HTPBE_MULTIPLE_REVISION_LAYERS&lt;/code&gt;&lt;/strong&gt; when the file was saved repeatedly after it claims to have been freshly generated, and the signature checks &lt;strong&gt;&lt;code&gt;HTPBE_POST_SIGNATURE_EDIT&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;HTPBE_SIGNATURE_REMOVED&lt;/code&gt;&lt;/strong&gt; when a digital signature was edited around or stripped.&lt;/p&gt;

&lt;p&gt;The important point for a risk team: you do not need to know what any of these mechanisms are internally. You read the named markers in the response and route on them. The detection logic stays on our side; your side stays a simple decision tree.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verdict you get back
&lt;/h2&gt;

&lt;p&gt;HTPBE returns one of three statuses, and the third is the one most people misread.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;intact&lt;/code&gt;&lt;/strong&gt; — no modification detected and the origin looks institutional. The file behaves like what it claims to be.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;modified&lt;/code&gt;&lt;/strong&gt; — forensic evidence of post-creation change. One or more markers fired. This takes priority over everything else: a modified Word document is still &lt;code&gt;modified&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;inconclusive&lt;/code&gt;&lt;/strong&gt; — the document came from consumer software, an online editor, an HTML renderer, or a scanner. &lt;strong&gt;This is not a failure and not a pass.&lt;/strong&gt; It is a routing signal that says: PDF structure alone cannot answer the integrity question here, because this kind of file can be created from scratch by anyone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the honest core of generator forensics. If you expected a bank statement straight from a banking system and you got back &lt;code&gt;inconclusive&lt;/code&gt; with a consumer-software or HTML-renderer origin, the document was &lt;em&gt;not&lt;/em&gt; generated the way a real statement is generated — so verify it directly with the issuer or reject it. The &lt;code&gt;inconclusive&lt;/code&gt; verdict is doing real work: it tells you the tooling is wrong for the claim, even when no tampering marker fired.&lt;/p&gt;

&lt;p&gt;There is no numeric risk score in the response. You get a &lt;code&gt;status&lt;/code&gt;, a &lt;code&gt;modification_confidence&lt;/code&gt; (&lt;code&gt;certain&lt;/code&gt;, &lt;code&gt;high&lt;/code&gt;, or &lt;code&gt;none&lt;/code&gt;), and an ordered &lt;code&gt;modification_markers&lt;/code&gt; array. That is deliberate — a named marker you can branch on is more useful than an opaque 0–100 number nobody can audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the fingerprint over the API
&lt;/h2&gt;

&lt;p&gt;Submitting a file is a two-step flow: post a URL, get a check ID, then fetch the result. Here is the whole thing in Python.&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;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="c1"&gt;# Step 1 — submit the PDF URL 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="p"&gt;{&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="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="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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://your-bucket.example.com/statements/applicant-42.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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="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="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="n"&gt;producer&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;producer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;creator&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;creator&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;origin&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;origin&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;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="c1"&gt;# institutional | consumer_software | online_editor | scanned | unknown
&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="s"&gt;status=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; origin=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; producer=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;producer&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="s"&gt; creator=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;creator&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&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="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;markers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the routing logic a fraud team actually cares about:&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="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="c1"&gt;# Generator identity contradicts construction, dates disagree,
&lt;/span&gt;    &lt;span class="c1"&gt;# signature stripped, etc. The named markers say which.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HTPBE_PRODUCER_IDENTITY_FORGED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;markers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Declared institutional generator was spoofed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HTPBE_TOOL_VS_STRUCTURE_MISMATCH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;markers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Claimed authoring tool does not match file construction&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;markers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# strongest marker first
&lt;/span&gt;    &lt;span class="nf"&gt;route_to_manual_review&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;reason&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;# Consumer/online-editor/HTML-renderer/scanned origin.
&lt;/span&gt;    &lt;span class="c1"&gt;# If you expected an institutional document, this is itself a red flag.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;origin&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;consumer_software&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;online_editor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;request_issuer_confirmation&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="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;flag_for_human_eyes&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="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# intact
&lt;/span&gt;    &lt;span class="nf"&gt;accept&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice that the consumer-vs-institutional split drives the workflow. A &lt;code&gt;modified&lt;/code&gt; verdict routes to review. An &lt;code&gt;inconclusive&lt;/code&gt; verdict on a document that &lt;em&gt;should&lt;/em&gt; have been institutional triggers an issuer callback rather than a silent pass. That is the generator fingerprint doing its job: not catching every forgery on its own, but telling you when the toolchain is wrong for the claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this approach cannot do — and we will not pretend otherwise
&lt;/h2&gt;

&lt;p&gt;Generator fingerprinting is structural. It reads how the file was built, not what the file says. That boundary matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It cannot validate the data.&lt;/strong&gt; If a forger builds a fake payslip in an HTML renderer that real payroll systems also use, the structure is genuine even though the numbers are invented. That is why those files come back &lt;code&gt;inconclusive&lt;/code&gt;, not &lt;code&gt;intact&lt;/code&gt;. PDF forensics is the wrong layer to catch a born-fake document; content verification with the issuer is the right one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It cannot read cryptographic signature &lt;em&gt;validity&lt;/em&gt;.&lt;/strong&gt; It detects whether a signature is present, was edited around, or was removed — not whether the certificate chains to a trusted authority. Pair it with a signature-validation library if you need that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It cannot OCR or compare visual content.&lt;/strong&gt; Two files that look identical but differ by a single pixel-painted digit are out of scope for structural analysis unless that edit also disturbed the file’s construction.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Being clear about these limits is the point. A tool that claims to catch everything catches your trust first and your fraud never. Generator fingerprinting catches a specific, common, and expensive class of fraud — consumer tools impersonating institutional pipelines — and is honest about the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who should be reading these signals
&lt;/h2&gt;

&lt;p&gt;If you run risk or fraud operations at a lender, an insurer, an HR or payroll platform, or a legal-tech company, your queue is full of documents claiming institutional origins. A meaningful share of the fraudulent ones were not made by the institution they name — they were rebuilt in a consumer editor and re-stamped. The generator fingerprint is the cheapest, fastest way to surface that mismatch before a human ever opens the file. For the lending-specific version of this problem, see how this plays out in &lt;a href="https://htpbe.tech/blog/bank-statement-fraud-in-lending" rel="noopener noreferrer"&gt;bank statement fraud in lending&lt;/a&gt;, or the dedicated &lt;a href="https://htpbe.tech/use-cases/fake-bank-statement-detection" rel="noopener noreferrer"&gt;fake bank statement detection&lt;/a&gt; workflow.&lt;/p&gt;

&lt;p&gt;The structural layer reads every one of these signals automatically and returns a verdict you can branch on in a few lines of code. Wire it into the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;HTPBE API&lt;/a&gt;, route on the named markers, and let the documents that claim to be institutional prove that the software which made them actually was.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Build vs Buy: VAT Validation via VIES or an API</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 10 Aug 2026 09:00:49 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/build-vs-buy-vat-validation-via-vies-or-an-api-53k</link>
      <guid>https://dev.to/iurii_rogulia/build-vs-buy-vat-validation-via-vies-or-an-api-53k</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/build-vs-buy-vat-validation" rel="noopener noreferrer"&gt;vatnode.dev&lt;/a&gt;. The version on vatnode.dev is the canonical source — refer to it for the latest content.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h1&gt;
  
  
  Build vs Buy: Should You Call VIES Yourself or Use an API?
&lt;/h1&gt;

&lt;p&gt;VIES is free and public, so the first instinct is almost always the same: we'll just call it ourselves. That instinct is not wrong. For some teams it is exactly the right call. But "free" describes the endpoint, not the integration, and the gap between the two is where the real decision lives.&lt;/p&gt;

&lt;p&gt;This is a build-vs-buy framework, not a pitch. The scope is the EU-27 plus XI (Northern Ireland), which is what VIES covers. The goal here is to enumerate the actual work honestly — what you own when you build, what you offload when you buy — so you can decide which side of the line your case falls on. For most low-stakes, low-volume cases, building is fine. The point is to know that before you commit, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "we'll just call VIES" assumption
&lt;/h2&gt;

&lt;p&gt;VIES is a system run by the European Commission that &lt;strong&gt;routes&lt;/strong&gt; a validation query to the relevant national VAT database and reports back what that database says. The Commission does not hold the data centrally — each member state answers for its own numbers. You can read the plain-language version on the Commission's &lt;a href="https://europa.eu/youreurope/business/taxation/vat/check-vat-number-vies/index_en.htm" rel="noopener noreferrer"&gt;Your Europe VIES page&lt;/a&gt;, and the integration details on the &lt;a href="https://ec.europa.eu/taxation_customs/vies/#/technical-information" rel="noopener noreferrer"&gt;VIES technical information page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The happy path really is simple: send a VAT number, get back valid or invalid, sometimes with a trader name and address. If that is all you need, and you need it rarely, you may not need anything else. The assumption only breaks down when you look at what happens off the happy path — when a national database is down, when you validate the same number a thousand times a day, or when an auditor asks you to prove a check you ran eight months ago. None of that is in the "just call VIES" mental model, and all of it is work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What building it yourself really includes
&lt;/h2&gt;

&lt;p&gt;Here is the honest inventory. None of these are dealbreakers on their own; together they are the actual scope of "building VAT validation."&lt;/p&gt;

&lt;h3&gt;
  
  
  The SOAP client and parsing
&lt;/h3&gt;

&lt;p&gt;VIES exposes two upstream interfaces: the legacy SOAP endpoint and, more recently, an unauthenticated REST endpoint that returns JSON. Picking REST saves you the XML parsing, but nothing else about the scope below changes — you still own the error mapping, the per-country downtime and throttling, the caching, and the audit capture. Either interface offers the same two checks. &lt;code&gt;checkVat&lt;/code&gt; gives you the basic yes/no (with a name and address depending on the member state). &lt;code&gt;checkVatApprox&lt;/code&gt; additionally accepts requester details — &lt;code&gt;requesterCountryCode&lt;/code&gt; and &lt;code&gt;requesterVatNumber&lt;/code&gt; — and, on a completed lookup, returns a &lt;code&gt;requestIdentifier&lt;/code&gt;, which is the consultation number you keep as evidence.&lt;/p&gt;

&lt;p&gt;So even the "simple" call, on the SOAP path, means building and maintaining a SOAP envelope, posting it as XML, and parsing an XML response into something your application can use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;soapenv:Envelope&lt;/span&gt;
  &lt;span class="na"&gt;xmlns:soapenv=&lt;/span&gt;&lt;span class="s"&gt;"http://schemas.xmlsoap.org/soap/envelope/"&lt;/span&gt;
  &lt;span class="na"&gt;xmlns:urn=&lt;/span&gt;&lt;span class="s"&gt;"urn:ec.europa.eu:taxud:vies:services:checkVat:types"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;soapenv:Body&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;urn:checkVatApprox&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;urn:countryCode&amp;gt;&lt;/span&gt;DE&lt;span class="nt"&gt;&amp;lt;/urn:countryCode&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;urn:vatNumber&amp;gt;&lt;/span&gt;123456789&lt;span class="nt"&gt;&amp;lt;/urn:vatNumber&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;urn:requesterCountryCode&amp;gt;&lt;/span&gt;FI&lt;span class="nt"&gt;&amp;lt;/urn:requesterCountryCode&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;urn:requesterVatNumber&amp;gt;&lt;/span&gt;12345678&lt;span class="nt"&gt;&amp;lt;/urn:requesterVatNumber&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/urn:checkVatApprox&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/soapenv:Body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/soapenv:Envelope&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the request. You then parse the response XML, pull out &lt;code&gt;valid&lt;/code&gt;, the trader fields, and &lt;code&gt;requestIdentifier&lt;/code&gt;, and map SOAP faults onto your own error model. The &lt;a href="https://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl" rel="noopener noreferrer"&gt;checkVatService WSDL&lt;/a&gt; is the contract. It is not hard code, but it is code you own, forever, in a serialization format most of your stack no longer speaks natively.&lt;/p&gt;

&lt;h3&gt;
  
  
  Downtime and retries
&lt;/h3&gt;

&lt;p&gt;This is the part that surprises people. VIES has &lt;strong&gt;no SLA and no uptime guarantee&lt;/strong&gt;, and because it routes per member state, availability is per-country: one national database can be down for maintenance while every other country answers fine. The Commission's own materials note that national databases go offline during backups. So "is VIES up?" is not a yes/no question — it is "is the specific country you need up right now?"&lt;/p&gt;

&lt;p&gt;Building this well means retries with backoff, distinguishing a transport failure from a genuine &lt;code&gt;invalid&lt;/code&gt;, and deciding what your application does when the answer is neither valid nor invalid but simply unavailable. Do you block the signup? Queue the check? Fail open or closed? Those are product decisions you have to make and encode. There is also undocumented rate/abuse throttling — hammer VIES and you can get soft-blocked, which turns "just retry" into its own failure mode. We wrote up the shape of this problem in the guide on &lt;a href="https://vatnode.dev/blog/vies-downtime-guide" rel="noopener noreferrer"&gt;handling VIES downtime&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Caching without breaking compliance
&lt;/h3&gt;

&lt;p&gt;At any real volume you cannot call VIES on every request — for latency, for throttling, and out of basic courtesy to a shared public system. So you cache. But a VAT validation result is time-sensitive evidence: a number that was valid in March can be deregistered by August. Cache too aggressively and your "proof" is stale; cache too little and you are back to hammering VIES.&lt;/p&gt;

&lt;p&gt;The compliance-safe version means a deliberate TTL, storing the original check timestamp (not the cache-hit time), and never letting a cached row masquerade as a fresh contemporaneous check when audit evidence is what matters. It is a small system, but it is a system with correctness rules, not a one-line memoize. More on the trade-offs in &lt;a href="https://vatnode.dev/blog/caching-vies-responses-compliance" rel="noopener noreferrer"&gt;caching VIES responses&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Audit evidence you have to persist
&lt;/h3&gt;

&lt;p&gt;If you validate VAT IDs to support intra-EU B2B treatment, the check is not the deliverable — the &lt;strong&gt;evidence&lt;/strong&gt; is. The strongest piece VIES itself gives you is the consultation number: the &lt;code&gt;requestIdentifier&lt;/code&gt; returned by &lt;code&gt;checkVatApprox&lt;/code&gt;, which is contemporaneous proof that you checked a specific number, at a specific time, and got a specific reply, in a form a tax administration can accept.&lt;/p&gt;

&lt;p&gt;Worth being precise about what it is and is not. It is evidence of the lookup. It is &lt;strong&gt;not&lt;/strong&gt; a tax certification, it does not adjudicate the transaction, and it does not by itself guarantee exemption — that treatment depends on the full set of conditions for the supply, not on one reference string. Getting the consultation number requires the requester-qualified &lt;code&gt;checkVatApprox&lt;/code&gt; path, and storing it correctly (immutable rows, requester VAT ID alongside, source recorded, nullable where a national source answered) is more work than the call that produced it. The full breakdown is in &lt;a href="https://vatnode.dev/blog/vies-consultation-number-explained" rel="noopener noreferrer"&gt;audit evidence&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  National-registry fallback and enrichment
&lt;/h3&gt;

&lt;p&gt;When VIES can't answer for a given country, some national tax-authority and company-registry APIs can — either as a fallback while VIES is down, or as enrichment where they return richer trader data. Each of those sources is its own interface, its own auth, its own field mapping, and its own reliability profile. Coverage changes over time as authorities publish or retire endpoints, so this is not a "write once" component either. Building it means a per-source client layer and a policy for reconciling a national answer with a VIES answer — including the fact that a national confirmation is not the same evidence as a VIES consultation number.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ongoing maintenance
&lt;/h3&gt;

&lt;p&gt;Everything above is not a one-time build. VIES changes; national endpoints appear and disappear; throttling behaviour shifts; the SOAP contract gets revised. Someone on your team owns this indefinitely, and it is the kind of infrastructure that is invisible until it breaks during a customer's checkout or an audit. The honest cost of building is not the week you spend writing it — it is the year you spend keeping it correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  When building in-house is the right call
&lt;/h2&gt;

&lt;p&gt;Building is genuinely the right answer when the shape of your need is narrow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You validate &lt;strong&gt;rarely&lt;/strong&gt; — occasional one-off checks, not a hot path.&lt;/li&gt;
&lt;li&gt;You do &lt;strong&gt;not&lt;/strong&gt; need audit evidence, or your risk on a wrong answer is low.&lt;/li&gt;
&lt;li&gt;You can tolerate VIES being down, because a failed check just means "try again later" with no business consequence.&lt;/li&gt;
&lt;li&gt;You have the in-house appetite to own a small SOAP integration and keep it alive.&lt;/li&gt;
&lt;li&gt;You want zero external dependencies for regulatory, procurement, or data-residency reasons.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even here, "build" rarely means what it sounds like. If you validate rarely, don't need audit evidence, and can shrug off VIES being down, you could write a thin &lt;code&gt;checkVat&lt;/code&gt; call — but the &lt;a href="https://vatnode.dev/register" rel="noopener noreferrer"&gt;free plan&lt;/a&gt; does the exact same job at this volume for nothing (100 checks a month, no SOAP client to keep alive, a consultation number and source on every result, no card), so you get the resilient version without maintaining anything. The one case where you genuinely should wire &lt;a href="https://vatnode.dev/guides/what-is-vies" rel="noopener noreferrer"&gt;VIES&lt;/a&gt; up yourself is a hard zero-external-dependency, data-residency, or procurement constraint that forbids an external call at all — there the public, free endpoint is the end of the story. For everyone else, "the right call" still starts with registering.&lt;/p&gt;

&lt;h2&gt;
  
  
  When buying wins
&lt;/h2&gt;

&lt;p&gt;Buying starts to pay off when the off-happy-path work stops being hypothetical:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Validation is on a &lt;strong&gt;hot path&lt;/strong&gt; — every signup, every invoice, every checkout — where VIES downtime becomes your downtime.&lt;/li&gt;
&lt;li&gt;You need &lt;strong&gt;audit evidence&lt;/strong&gt; you can produce months later: consultation number, timestamp, and source, stored reproducibly.&lt;/li&gt;
&lt;li&gt;Per-country availability gaps are unacceptable, so you need &lt;strong&gt;national fallback&lt;/strong&gt; rather than a shrug when one member state is offline.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;maintenance time&lt;/strong&gt; — SOAP, retries, caching correctness, fallback clients — costs more than the API does.&lt;/li&gt;
&lt;li&gt;You want a plain REST interface your whole stack can call without anyone learning SOAP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An API does not replace VIES — VIES is still the underlying source. What it does is wrap VIES (and national registries where available) with reliability, caching, evidence, and a REST contract, so the six sections above become someone else's problem. Against the vatnode contract the same lookup is three 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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.vatnode.dev/v1/vat/DE123456789&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VATNODE_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is structured JSON — &lt;code&gt;valid&lt;/code&gt;, &lt;code&gt;vatId&lt;/code&gt;, &lt;code&gt;countryCode&lt;/code&gt;, &lt;code&gt;countryName&lt;/code&gt;, &lt;code&gt;companyName&lt;/code&gt;, &lt;code&gt;companyAddress&lt;/code&gt;, &lt;code&gt;checkId&lt;/code&gt;, &lt;code&gt;verifiedAt&lt;/code&gt;, &lt;code&gt;source&lt;/code&gt;, and &lt;code&gt;consultationNumber&lt;/code&gt; — with no SOAP envelope to build or parse. The differentiators are narrow and worth stating plainly rather than overselling: a zero-config path (set your own EU VAT once as requester in dashboard Settings and every subsequent call returns the consultation number, without wiring up &lt;code&gt;checkVatApprox&lt;/code&gt; yourself), and a never-null result contract (you always get a structured answer with a &lt;code&gt;source&lt;/code&gt;, even when the call fell back to a national database). That is it. If you want to size it against the build cost, here is &lt;a href="https://vatnode.dev/pricing" rel="noopener noreferrer"&gt;what an API costs&lt;/a&gt;; if you want a broader look at the reliability layer, there's &lt;a href="https://vatnode.dev/guides/vies-api-alternative" rel="noopener noreferrer"&gt;a production VIES alternative&lt;/a&gt; and the &lt;a href="https://vatnode.dev/vies-api" rel="noopener noreferrer"&gt;VIES API&lt;/a&gt; reference.&lt;/p&gt;

&lt;h2&gt;
  
  
  A simple decision checklist
&lt;/h2&gt;

&lt;p&gt;Run down this list. More boxes on the left, build. More on the right, buy.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Volume&lt;/strong&gt; — occasional lookups → build; hot path → buy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evidence&lt;/strong&gt; — no audit need → build; you must produce proof later → buy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Downtime tolerance&lt;/strong&gt; — a failed check is harmless → build; VIES down means revenue or signups blocked → buy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Country gaps&lt;/strong&gt; — EU-average coverage is fine → build; you can't accept one member state being offline → buy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maintenance appetite&lt;/strong&gt; — you'll own SOAP and fallback indefinitely → build; that time is better spent elsewhere → buy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data residency / procurement&lt;/strong&gt; — external dependency is disallowed → build; no such constraint → buy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The checklist is honest, but notice where it actually lands in 2026. The only boxes that point cleanly at building are the hard constraints — a zero-external-dependency, data-residency, or procurement rule that forbids calling anyone. Everywhere else, the maintenance you would be signing up for outweighs what an API costs, because for most teams that API costs nothing: vatnode is free at low volume (100 checks a month, no card) and low cost above it, with company-data enrichment, national fallback, and the consultation number in a single request. So unless you have that hard constraint, the pragmatic answer is not "it depends" — it is register, wire up one REST call, and stop thinking about VIES.&lt;/p&gt;

&lt;p&gt;This is general information about EU VAT and VIES, not tax advice. Whether a specific transaction qualifies for zero-rating, exemption, or reverse charge depends on facts we can't assess here — confirm the treatment of your own transactions with a qualified tax adviser.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Can I just call VIES directly for free?
&lt;/h3&gt;

&lt;p&gt;Yes, VIES is free and public, but "free" only covers the endpoint — you still own the SOAP client, downtime handling, caching, audit storage, and national fallback, which is where the real cost sits.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the hardest part of building VAT validation in-house?
&lt;/h3&gt;

&lt;p&gt;Usually resilience and evidence — VIES nodes go down per-country regularly, and storing reproducible audit proof (consultation number, timestamp, source) correctly is more work than the happy-path call.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a VAT validation API worth it for low volume?
&lt;/h3&gt;

&lt;p&gt;If you validate rarely and don't need audit evidence or uptime guarantees, a thin in-house call may be enough; the API pays off once downtime, compliance evidence, or maintenance time start to matter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does an API replace VIES?
&lt;/h3&gt;

&lt;p&gt;No — VIES is the underlying source. An API wraps VIES (and national registries where available) to add reliability, caching, evidence, and a simple REST interface on top.&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  Try the buy side before you build it
&lt;/h3&gt;

&lt;p&gt;If you're weighing the maintenance cost, the fastest way to price it is to call the API once and see what you'd otherwise be building. &lt;a href="https://vatnode.dev/register" rel="noopener noreferrer"&gt;Try it free&lt;/a&gt; — free plan, 100 requests/month, consultation number and source on every result.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>tax</category>
      <category>webdev</category>
    </item>
    <item>
      <title>PDF Integrity Report: July 2026</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Sat, 08 Aug 2026 10:00:39 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/pdf-integrity-report-july-2026-1m4h</link>
      <guid>https://dev.to/iurii_rogulia/pdf-integrity-report-july-2026-1m4h</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/pdf-integrity-report-july-2026" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt;. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every month we look at aggregate, anonymized data from checks processed by HTPBE and write up what the structural signals tell us about the state of PDF tampering. No file contents, no personally identifiable information — only the structural and metadata patterns the algorithm uses to classify documents.&lt;/p&gt;

&lt;p&gt;This report is about &lt;strong&gt;proportions and movement&lt;/strong&gt;, not raw counts. What share of documents came back flagged, which signals fired more or less often than the month before, which origins shifted, and what the recurring tampering shapes looked like. Those are the numbers that mean something; an absolute file count for a single month is noise by comparison.&lt;/p&gt;

&lt;p&gt;July is the month where that distinction earns its keep. The flagged share moved up sharply — and almost none of that movement is about documents getting more tampered.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Denominator Is, Before Any Number
&lt;/h2&gt;

&lt;p&gt;Every share below is a share of &lt;strong&gt;processed checks&lt;/strong&gt; — completed analyses, not unique documents and not unique users. That denominator &lt;strong&gt;includes our own internal self-check runs, owner testing, fixture regressions, and retries&lt;/strong&gt;; it is not deduped. On top of that, the classifier itself &lt;strong&gt;changed repeatedly within July&lt;/strong&gt; (see the algorithm section — it was our second-heaviest release month on record), so the same file submitted early and late in the month can land on different sides of the line. A July detector-output number is therefore a blend of a moving population and a moving ruler.&lt;/p&gt;

&lt;p&gt;So this is &lt;strong&gt;not a population fraud rate&lt;/strong&gt;, and no single figure here should be read as one. It is a description of what reached our pipeline and how our pipeline classified it. Keep that in view for the headline, which needs it most.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Shape of the Verdicts
&lt;/h2&gt;

&lt;p&gt;The flagged share &lt;strong&gt;rose to approaching six in ten&lt;/strong&gt;, reversing June's dip back below half — but the two things that moved it are both instrumentation, not tampering. First, July was a &lt;strong&gt;record release cadence&lt;/strong&gt;: twenty-seven algorithm versions shipped across the month, several of them redefining the "modified" and confidence classes mid-month, which mechanically expands what gets flagged. Second, the &lt;strong&gt;traffic mix flipped back to API-heavy testing&lt;/strong&gt; after June's web-dominated month, and API traffic skews toward files that are already suspected — integration tests against known-bad documents and uploads that appear to be testing whether a fake gets caught. A wider net over a more pre-selected population lifts the flagged share on its own. Neither lever says anything about how often documents in the world are being altered.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Direction vs. June&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Not flagged&lt;/td&gt;
&lt;td&gt;▼ eased to just over two in five&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High-confidence modification&lt;/td&gt;
&lt;td&gt;► flat, around three in ten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Certain modification&lt;/td&gt;
&lt;td&gt;▲ up — but a calibration effect, see below&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same forensic questions, a heavier and differently-aimed instrument, a different-looking headline. Read the flagged share every month as a statement about &lt;em&gt;who submitted and how the pipeline changed&lt;/em&gt; — and in July, more than any month we have published, both of those changed at once.&lt;/p&gt;

&lt;p&gt;A note on the &lt;strong&gt;"certain" tier&lt;/strong&gt;, which also moved up this month: treat it as a calibration reading, not a trend. "Certain" describes how confidently the engine made the &lt;strong&gt;modification&lt;/strong&gt; call — never certainty about intent or fraud — and this month's shift is a direct product of the same two forces above. A heavy run of coverage-broadening releases adds converging second signals to files that previously scraped a "high," and an API-skewed population stacks more unambiguous evidence per file. The confidence mix followed the releases and the population; it is not an independent finding.&lt;/p&gt;




&lt;h2&gt;
  
  
  Source &amp;amp; Origin Mix
&lt;/h2&gt;

&lt;p&gt;The submission channel &lt;strong&gt;flipped back to a near-even split, with the API nominally the largest&lt;/strong&gt; — a near-reversal of June, when roughly four in five checks came through the browser-based free checker. Read this as processed checks including internal testing, not as organic API-customer growth: our own automated self-check and fixture runs go through the API path, and July was a heavy build month, so a chunk of that API weight is us exercising the engine, not the market discovering it.&lt;/p&gt;

&lt;p&gt;On origin, &lt;strong&gt;consumer-software exports overtook institutional documents as the largest class&lt;/strong&gt;, with institutional slipping from the plurality it held in June. This is a classifier output on a testing-inclusive population, and it is &lt;strong&gt;not "more consumer fraud."&lt;/strong&gt; Consumer-software and scanned files both fall into a &lt;strong&gt;"Cannot Verify"&lt;/strong&gt; bucket, where the structural layer deliberately returns a conservative inconclusive verdict rather than forcing an intact-or-modified call — a larger consumer-software slice means more files we decline to certify either way, not more tampering. Several July releases also &lt;strong&gt;re-routed origin classification&lt;/strong&gt; (sharpening scan and fake-scanner recognition in both directions), so part of the reshuffle is the ruler moving, not the population.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scanned documents held at roughly a ninth&lt;/strong&gt; of submissions — essentially flat, within noise, and itself touched by the reclassification work above. Treat that as marginal, not a decline. A scan can still never earn an "intact" verdict here: capture-origin formats simply carry too little structural history to certify either way, so they route to a conservative inconclusive rather than a clean pass.&lt;/p&gt;




&lt;h2&gt;
  
  
  Signals That Moved
&lt;/h2&gt;

&lt;p&gt;The cleanest reads this month are the ones that &lt;strong&gt;do not depend on the detector at all&lt;/strong&gt; — pure structural composition of what showed up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing creation date eased again.&lt;/strong&gt; Files arriving with no creation timestamp at all slipped &lt;strong&gt;from about a fifth to about a sixth&lt;/strong&gt;. This is a detector-independent carrier — it is just what the submitted files contained — and it reflects the changed submission mix, not a real-world change in how documents are authored. Still worth watching, but the direction this month is down.&lt;/p&gt;

&lt;p&gt;The suppressed slices stayed suppressed: signed documents, post-signature edits, signature removals, embedded files and JavaScript were each too thin a base this month to quote a rate, so we keep them qualitative. The one signed-document pattern worth repeating is the standing one — a signature valid in the viewer does not guarantee the bytes were not altered, because incremental updates appended after signing fall outside the signed scope. Integrity checked at the structural layer, not the signature-validation layer, is what catches that.&lt;/p&gt;




&lt;h2&gt;
  
  
  Incremental Updates: Two Rates, Kept Apart
&lt;/h2&gt;

&lt;p&gt;The incremental-update signal splits into two separate numbers this month, and they should not be blurred together.&lt;/p&gt;

&lt;p&gt;The first is &lt;strong&gt;prevalence&lt;/strong&gt; — how many of &lt;em&gt;all&lt;/em&gt; files carried incremental updates at all. That ran about &lt;strong&gt;one in ten&lt;/strong&gt;, a structural share that ticked up modestly and is safe to read at face value. The average revision chain on those files also &lt;strong&gt;shortened, to under three appends&lt;/strong&gt;, down from a little over three in June — fewer post-write layers stacked per file.&lt;/p&gt;

&lt;p&gt;The second is the &lt;strong&gt;flag rate among them&lt;/strong&gt; — of the files that did carry incremental updates, how many came back flagged. In &lt;strong&gt;July&lt;/strong&gt; that sat at very nearly all of them. Read this as a &lt;strong&gt;July snapshot only&lt;/strong&gt;: it was lower in June, so this is not a "near-total all along" continuity claim, and it is not a real-world trend. The mechanism is unchanged — incremental updates let content be appended after the original write, and while legitimate workflows produce them (signature application, annotation, form-fill), those clean cases are a small minority of the population that reaches a tamper-detection tool.&lt;/p&gt;




&lt;h2&gt;
  
  
  Representative Cases
&lt;/h2&gt;

&lt;p&gt;These are composite, anonymized illustrations of the recurring shapes the engine resolved this month — not specific files. Each maps to the structural markers that actually drove the verdict, and each describes a &lt;strong&gt;modification, not proven fraud&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reconstructed statement (verdict: modified).&lt;/strong&gt; A "bank statement" looks like one coherent document. Structurally, its pages were assembled from more than one separate source rather than produced as a single original — so it can be reported as an assembled file, but never certified as an untouched original. July broadened this multi-source coverage to catch a wider range of assembled packages that previously slipped through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rebuilt-elsewhere document (verdict: modified).&lt;/strong&gt; A file presented as an untouched institutional original was, structurally, &lt;strong&gt;re-saved and rebuilt by a second tool after its original creation&lt;/strong&gt;, rather than issued and released once by an institution. The public outcome is exactly that: rebuilt by a second tool after the file was first created. It reads as an original; the structure records the later rebuild.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cover-and-replace patch (verdict: modified).&lt;/strong&gt; A page reads correctly, line by line. Structurally, the page shows content added after the original was produced — for example, a blackout or cover box placed over part of the page. That is reported as a modification &lt;strong&gt;regardless of why it was applied&lt;/strong&gt;: the fact of the edit is what we record, not the intent behind it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The render dressed as a scan (verdict: modified).&lt;/strong&gt; A file arrives looking like a scanner or OCR capture, but structurally it is a software-rendered document presented as a capture origin it cannot genuinely have. Genuine device scans and ordinary phone captures are unaffected — those route to a conservative inconclusive and are never flagged on this basis; only a render &lt;em&gt;claiming&lt;/em&gt; to be a scan trips it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Algorithm Development
&lt;/h2&gt;

&lt;p&gt;July was a &lt;strong&gt;very heavy release month — twenty-seven versions shipped&lt;/strong&gt;, second only to May's twenty-nine and well above June's sixteen. The work spanned new coverage and false-positive reduction at once, described here in outcome terms only.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New and broadened detection categories&lt;/strong&gt; — a software render dressed as a scanner or OCR capture; content added over the original page, such as blackout or cover boxes; fabricated and assembled documents dressed up as institutional originals; documents rebuilt by a second tool after their original creation; broadened multi-source page assembly reaching a wider range of assembled packages; and strengthened detection of post-creation edits. Several of these closed cases that had previously passed, certified as originals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;False-positive reduction and confidence demotion&lt;/strong&gt; — releases that pushed the other way, narrowing misfires on genuine templated documents, signed-document workflows, and minor date discrepancies, and demoting the confidence of borderline calls rather than flagging them outright.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is the part that matters for the headline: &lt;strong&gt;wider coverage pushes the flagged share up.&lt;/strong&gt; A share of the documents flagged in July would have passed under the algorithm as it stood on the first of the month. That compounds the traffic-mix effect from the top of the report — and it is precisely why the rise in the flagged share is a statement about our instrument and our intake, not a fraud trend.&lt;/p&gt;




&lt;h2&gt;
  
  
  PDF Version Landscape
&lt;/h2&gt;

&lt;p&gt;Concentration kept loosening. &lt;strong&gt;PDF 1.7 fell from roughly 45% to about a third of the sample&lt;/strong&gt;, with &lt;strong&gt;1.4 a close second&lt;/strong&gt;, and &lt;strong&gt;1.3 and 1.6 tied for third&lt;/strong&gt;. PDF 1.5 took a smaller slice, and PDF 2.0, despite nearly a decade of availability, stayed a rounding-error share. Like the missing-creation-date read, this is a detector-independent carrier — it reflects the changed submission mix this month, not a shift in real-world version adoption.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;July 2026, in relative terms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The flagged share rose to approaching six in ten — but the drivers are a record twenty-seven-version release cadence broadening what gets caught and a traffic mix that flipped back to API-heavy testing, not more tampering.&lt;/li&gt;
&lt;li&gt;The channel split returned to near-even with the API nominally largest — processed checks including internal testing, not organic API growth — and consumer-software origin overtook institutional inside a "Cannot Verify" population, which is not a consumer-fraud signal.&lt;/li&gt;
&lt;li&gt;Scanned share held at roughly a ninth, flat and reclassification-affected; the "certain" tier moved up as a calibration effect of the releases and population, not a standalone trend.&lt;/li&gt;
&lt;li&gt;Incremental-update files ran about one in ten of all files, with shorter chains, and their July flag rate was near-total — read as a snapshot, not a continuity claim.&lt;/li&gt;
&lt;li&gt;The clean, detector-independent carriers pointed down and loosened: missing creation dates eased from about a fifth to about a sixth, and PDF 1.7 loosened from roughly 45% to about a third — both reflecting the submission mix, not real-world adoption.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every pattern here comes from the same forensic engine that teams run on their own intake stream through the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;PDF tamper detection API&lt;/a&gt;. If you want to run a single document through the same analysis by hand, the &lt;a href="https://htpbe.tech/pdf-tamper-detection" rel="noopener noreferrer"&gt;free checker&lt;/a&gt; does it in the browser.&lt;/p&gt;

&lt;p&gt;See the full aggregate dataset on the &lt;a href="https://htpbe.tech/statistics" rel="noopener noreferrer"&gt;PDF statistics page&lt;/a&gt;. Previous report: &lt;a href="https://htpbe.tech/blog/pdf-integrity-report-june-2026" rel="noopener noreferrer"&gt;PDF Integrity Report: June 2026&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This report covers checks processed by HTPBE in July 2026. We analyze only file structure, never document content; web uploads may be retained in anonymized form to improve detection. All figures are aggregate and anonymized.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>fraud</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Preventing Overselling: Inventory Locks Under Concurrent Checkouts</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Fri, 07 Aug 2026 10:00:49 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/preventing-overselling-inventory-locks-under-concurrent-checkouts-3m7e</link>
      <guid>https://dev.to/iurii_rogulia/preventing-overselling-inventory-locks-under-concurrent-checkouts-3m7e</guid>
      <description>&lt;p&gt;Two customers are looking at the same product. One unit left. Within the same second, both click Pay. If your checkout reads the stock count, decides there's enough, and then writes the decrement, both requests pass the check and both succeed. You've now sold two units of something you had one of.&lt;/p&gt;

&lt;p&gt;That's overselling, and it's not a rare edge case — it's the default behaviour of any checkout that treats "check stock" and "reduce stock" as two separate steps. The window is small, but on a product that's nearly sold out, or during a launch when everyone hits the same SKU at once, small windows fire constantly.&lt;/p&gt;

&lt;p&gt;I've built the order pipeline for two production e-commerce platforms — &lt;a href="https://iurii.rogulia.fi/projects/pikkuna-ecommerce-platform" rel="noopener noreferrer"&gt;pikkuna.fi&lt;/a&gt; and &lt;a href="https://iurii.rogulia.fi/projects/pi-pi-b2b-ecommerce" rel="noopener noreferrer"&gt;pi-pi.ee&lt;/a&gt; — where concurrent webhooks and concurrent checkouts hit the same order and product rows. This is the layer I reach for when a store sells finite stock. I covered the bare &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; primitive briefly in &lt;a href="https://iurii.rogulia.fi/blog/postgresql-production-patterns" rel="noopener noreferrer"&gt;PostgreSQL Production Patterns&lt;/a&gt;; this article is the whole system built on top of it — reservations, multi-line carts, the payment window, and the parts that actually bite you in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  When You Don't Need Any of This
&lt;/h2&gt;

&lt;p&gt;Start with the honest disclaimer, because it decides everything downstream.&lt;/p&gt;

&lt;p&gt;Both pikkuna.fi and pi-pi.ee are &lt;strong&gt;made-to-order&lt;/strong&gt;. A vinyl curtain is cut to the customer's dimensions; a waterless urinal system ships from a supply chain, not a shelf with a hard unit count. When there's no fixed quantity to run out of, overselling isn't a failure mode — you can't sell the tenth unit of something you manufacture on demand. So neither of those platforms needs a row lock on a stock column, and I didn't build one there.&lt;/p&gt;

&lt;p&gt;You need this article when you sell &lt;strong&gt;discrete, finite stock&lt;/strong&gt;: limited runs, event tickets, one-off items, anything where "5 left" is a real number and selling the sixth is a promise you can't keep. If your catalogue is print-on-demand, made-to-order, or backed by effectively unlimited supply, stop here — the locking below is complexity you'd be maintaining for a race that can't happen. Building the reservation layer for a store that can't run out of stock is exactly the kind of over-engineering I'd talk a client out of.&lt;/p&gt;

&lt;p&gt;The rest of this assumes you genuinely have finite stock and concurrent buyers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Race, Precisely
&lt;/h2&gt;

&lt;p&gt;Here's the naive version. It looks correct in every code review and passes every test that runs requests one at a time.&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;// DO NOT SHIP THIS&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findFirst&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Out of stock&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Another request can run this exact block between the read above&lt;/span&gt;
  &lt;span class="c1"&gt;// and the write below. Both read stock = 1. Both pass the check.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;productId&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 bug lives in the gap between the read and the write. Two requests interleave: both read &lt;code&gt;stock = 1&lt;/code&gt;, both pass &lt;code&gt;stock &amp;lt; 1&lt;/code&gt; as false, both compute &lt;code&gt;1 - 1 = 0&lt;/code&gt;, both write &lt;code&gt;0&lt;/code&gt;. Two orders, one unit. The database did exactly what you told it — you told it wrong.&lt;/p&gt;

&lt;p&gt;There are two independent things to fix here, and people often conflate them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The decrement must be atomic&lt;/strong&gt; — the new value has to be computed from the value &lt;em&gt;at write time&lt;/em&gt;, not from a value you read earlier and held in a variable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The availability check and the decrement must be serialized&lt;/strong&gt; — no two transactions may both pass the "is there stock?" check for the last unit.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Fix One: Never Decrement From a Read Value
&lt;/h2&gt;

&lt;p&gt;The first fix is cheap and you should do it unconditionally. Let the database compute the new value in the &lt;code&gt;UPDATE&lt;/code&gt;, and put the guard in the &lt;code&gt;WHERE&lt;/code&gt; clause so the write itself refuses to go below zero:&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;// Atomic conditional decrement — the check and the write are one statement&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; - 1`&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;gte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stock&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;returning&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stock&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// WHERE matched nothing: stock was already 0 at write time&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Out of stock&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;This is a single atomic statement. Postgres takes a row lock for the duration of the &lt;code&gt;UPDATE&lt;/code&gt; automatically, so two concurrent writers can't both satisfy &lt;code&gt;stock &amp;gt;= 1&lt;/code&gt; on the last unit — one wins, the other's &lt;code&gt;WHERE&lt;/code&gt; no longer matches and it updates zero rows. The &lt;code&gt;.returning()&lt;/code&gt; tells you which happened.&lt;/p&gt;

&lt;p&gt;For a single-line, decrement-at-checkout store, this alone prevents overselling. No explicit &lt;code&gt;FOR UPDATE&lt;/code&gt;, no transaction block. &lt;strong&gt;Reach for the atomic conditional &lt;code&gt;UPDATE&lt;/code&gt; before anything heavier&lt;/strong&gt; — it's the smallest thing that's correct.&lt;/p&gt;

&lt;p&gt;So why does the rest of this article exist? Because two realities break the one-statement approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Carts have multiple lines&lt;/strong&gt;, and you need all-or-nothing across them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payment isn't instant.&lt;/strong&gt; You confirm availability at checkout, but the money lands seconds — or with SEPA and bank transfer, &lt;em&gt;days&lt;/em&gt; — later. What happens to the stock in between?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fix Two: Reservations vs Hard Decrements
&lt;/h2&gt;

&lt;p&gt;There are two models for holding stock, and choosing between them is the real design decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hard decrement&lt;/strong&gt; subtracts from &lt;code&gt;stock&lt;/code&gt; the moment the order is placed. Simple, one column, no background jobs. It works when payment is synchronous and near-instant — card payments that succeed or fail in the same request. Its weakness: if the payment then fails, or the customer abandons a redirect-based method, you've decremented stock for a sale that never happened. You need a compensating restock, and if that compensation is missed, the unit is silently locked away forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reservation&lt;/strong&gt; splits the count in two. You don't decrement &lt;code&gt;stock&lt;/code&gt;; you increment &lt;code&gt;reserved&lt;/code&gt;. Available stock is a derived value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;stock&lt;/span&gt;       &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stock&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="n"&gt;reserved&lt;/span&gt;    &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reserved&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="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;reserved_within_stock&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reserved&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- available = stock - reserved&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A reservation raises &lt;code&gt;reserved&lt;/code&gt;. On successful payment, you convert it: &lt;code&gt;stock&lt;/code&gt; down, &lt;code&gt;reserved&lt;/code&gt; down, net available unchanged. On failure or expiry, you just drop &lt;code&gt;reserved&lt;/code&gt; and the unit is available again — no separate restock path to forget. The &lt;code&gt;reserved &amp;lt;= stock&lt;/code&gt; check constraint is your last line of defence: even if the application logic has a bug, the database physically cannot record more reservations than you have units.&lt;/p&gt;

&lt;p&gt;Reservation is more moving parts — a &lt;code&gt;reserved&lt;/code&gt; column, an expiry mechanism, a sweep job. But it's the model that survives asynchronous payment, and asynchronous payment is normal in European B2B. On &lt;a href="https://iurii.rogulia.fi/projects/pi-pi-b2b-ecommerce" rel="noopener noreferrer"&gt;pi-pi.ee&lt;/a&gt; the checkout accepts SEPA Direct Debit, bank transfer, and Multibanco alongside cards. Those settle later: the Stripe webhook arrives as &lt;code&gt;payment_intent.requires_action&lt;/code&gt; first and &lt;code&gt;payment_intent.succeeded&lt;/code&gt; only once the money moves. A hard decrement can't model "held but not yet paid" — a reservation can.&lt;/p&gt;

&lt;p&gt;For a store with scarce stock and any async payment method, use reservations. If you're card-only and want the simplest thing that's correct, the atomic conditional decrement above is enough — just make sure your payment-failure webhook restocks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reserving a Multi-Line Cart Atomically
&lt;/h2&gt;

&lt;p&gt;A real cart is several products at once, and the requirement is all-or-nothing: reserve every line or none. Reserving three of four items and failing the fourth leaves you holding stock for a sale that can't complete.&lt;/p&gt;

&lt;p&gt;This is where you need an explicit transaction and row locking. Lock each product row, verify availability, then reserve — all inside one transaction that either commits whole or rolls back whole:&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/reservations.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;reserveCart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;qty&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="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ttlMinutes&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="c1"&gt;// Lock rows in a deterministic order to avoid deadlocks (see below)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cart&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;l&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;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productId&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Lock all involved product rows up front, in sorted order&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;locked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reserved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reserved&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;inArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;update&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;byId&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;Map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locked&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;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;

    &lt;span class="c1"&gt;// Verify availability for every line before writing anything&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;cart&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;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;byId&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="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OutOfStockError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productId&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reserved&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OutOfStockError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// rolls back the whole tx&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// All lines fit — now reserve&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expiresAt&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="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;ttlMinutes&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;reserved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reserved&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;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qty&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="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reservations&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;qty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;expiresAt&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things are doing the work here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.for("update")&lt;/code&gt; locks every product row for the transaction's lifetime.&lt;/strong&gt; Any concurrent &lt;code&gt;reserveCart&lt;/code&gt; touching the same product blocks at the &lt;code&gt;SELECT&lt;/code&gt; until this transaction commits or rolls back — so it reads &lt;em&gt;post-reservation&lt;/em&gt; numbers, never stale ones. The availability check and the reservation write can't be interleaved by another cart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We check all lines before writing any.&lt;/strong&gt; If line four doesn't fit, the exception rolls the transaction back and the reservations we already wrote for lines one through three never persist. All-or-nothing falls out of transaction semantics for free.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Deadlock You Will Otherwise Hit
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;.sort()&lt;/code&gt; on &lt;code&gt;ids&lt;/code&gt; is not cosmetic. If two carts lock overlapping products in &lt;em&gt;different&lt;/em&gt; orders, you deadlock: cart A locks product X and waits for Y; cart B locked Y and waits for X. Postgres detects the cycle and kills one transaction with a deadlock error — a checkout failure you caused yourself.&lt;/p&gt;

&lt;p&gt;The fix is a discipline, not a feature: &lt;strong&gt;always acquire row locks in a consistent global order.&lt;/strong&gt; Sorting by primary key before locking guarantees every transaction grabs shared rows in the same sequence, so a cycle can't form. This is the single most common way a "correct" locking implementation still falls over under load, and it never shows up in single-threaded tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Payment-Confirmation Window
&lt;/h2&gt;

&lt;p&gt;Here's the part the naive decrement can't touch at all. Between reserving stock at checkout and confirming payment, there's a window. For cards it's seconds. For SEPA or bank transfer it can be days. During that window the unit is held but not sold, and three things can happen: payment succeeds, payment fails, or nothing happens because the customer walked away.&lt;/p&gt;

&lt;p&gt;You handle all three, and the reservation model makes each one a small, obvious operation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Payment succeeds.&lt;/strong&gt; The Stripe webhook fires &lt;code&gt;payment_intent.succeeded&lt;/code&gt;. Convert the reservation into a real decrement, atomically:&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;// In the Stripe webhook, on payment_intent.succeeded&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reservations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reservations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reservations&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;held&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;update&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stock&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qty&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;reserved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reserved&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qty&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reservations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;committed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reservations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;orderId&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;stock&lt;/code&gt; and &lt;code&gt;reserved&lt;/code&gt; drop together, so available stock is unchanged — the unit was already accounted for at reservation time. This is the moment the sale becomes real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Payment fails&lt;/strong&gt; (&lt;code&gt;payment_intent.payment_failed&lt;/code&gt;, or the async method is declined). Release the reservation — drop &lt;code&gt;reserved&lt;/code&gt;, leave &lt;code&gt;stock&lt;/code&gt; alone — and the unit is instantly available to the next buyer. No restock arithmetic, no risk of double-restocking, because you never touched &lt;code&gt;stock&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing happens.&lt;/strong&gt; The customer closed the tab. This is why reservations carry &lt;code&gt;expiresAt&lt;/code&gt;. A background sweep releases anything past its expiry:&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;// Runs on a schedule — release stale reservations&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reservations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reservations&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;held&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reservations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;())))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;update&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;skipLocked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// don't fight the webhook for rows it's committing&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;reserved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reserved&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qty&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="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reservations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expired&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nf"&gt;inArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;reservations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;stale&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;r&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="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;SKIP LOCKED&lt;/code&gt; matters here. The sweep and the success-webhook can race for the same reservation: the customer pays at the very moment the sweep runs. &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; tells the sweep to skip any row another transaction is already holding, rather than block on it. The webhook wins, commits the sale, and the sweep simply moves on — it never expires a reservation that's mid-commit. Without &lt;code&gt;SKIP LOCKED&lt;/code&gt; you either block the sweep behind the webhook (fine, but slower) or, worse, if you got the ordering wrong, expire a paid order.&lt;/p&gt;

&lt;p&gt;Match the reservation TTL to the payment method. Fifteen minutes is reasonable for cards. For bank transfer, where settlement legitimately takes days, a 15-minute reservation would release stock out from under a paying customer — you either extend the TTL for those methods or don't reserve scarce stock for them at all and accept the backorder. A backorder the buyer agrees to up front is a business choice; silently overselling stock you don't have is not. That decision belongs to the business, not to the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Still Bites
&lt;/h2&gt;

&lt;p&gt;I'd rather name the limits than pretend the pattern is bulletproof.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock contention on a single hot SKU.&lt;/strong&gt; If ten thousand people hit one product at launch, they all queue on that one row's lock and serialize. Correct, but slow — checkout latency climbs as the queue grows. Row locking prevents overselling; it does not make a flash sale fast. Genuinely extreme concurrency wants a different tool: decrement a Redis counter first as a fast admission gate, and treat Postgres as the durable source of truth behind it. That's a real increase in moving parts, and only worth it when you've measured the contention — not by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The webhook must be idempotent.&lt;/strong&gt; Stripe retries webhooks. If &lt;code&gt;payment_intent.succeeded&lt;/code&gt; is delivered twice and you decrement twice, you've corrupted your stock in the opposite direction. The &lt;code&gt;status&lt;/code&gt; transition (&lt;code&gt;held → committed&lt;/code&gt;) above is the guard: a second delivery finds no &lt;code&gt;held&lt;/code&gt; reservation and does nothing. Getting that exactly right is its own problem — I wrote it up in &lt;a href="https://iurii.rogulia.fi/blog/idempotency-keys-api-retries" rel="noopener noreferrer"&gt;Idempotency Keys for API Retries&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reservations leak if the sweep dies.&lt;/strong&gt; If your background job stops running, expired reservations pile up and &lt;code&gt;reserved&lt;/code&gt; creeps toward &lt;code&gt;stock&lt;/code&gt;, choking availability for real buyers. The sweep is infrastructure, and it needs the same monitoring as anything else you depend on. A reservation system without a working expiry sweep is worse than a hard decrement, not better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read replicas lie.&lt;/strong&gt; If you check availability against a read replica for speed, it may lag behind the primary and show stock that's already reserved. Availability checks that gate a purchase must hit the primary. Display counts on a product page can tolerate lag; the checkout decision cannot.&lt;/p&gt;




&lt;p&gt;Overselling is a race condition, not an inventory problem. The fix is the same discipline every time: make the decision and the write one indivisible operation, serialize the transactions that compete for the last unit, and model the gap between "reserved" and "paid" explicitly so no unit is ever both sold and available. Start with the atomic conditional decrement, move to reservations only when async payment or multi-line carts force it, and don't build any of it for a store that can't run out of stock.&lt;/p&gt;

&lt;p&gt;This is the correctness work under a checkout that sells real, finite stock — the difference between a store that quietly holds its promises and one that emails customers to apologise for a unit it can't ship. It's the kind of thing I build into &lt;a href="https://iurii.rogulia.fi/services/e-commerce" rel="noopener noreferrer"&gt;e-commerce projects&lt;/a&gt; from the start, because retrofitting it after the first oversold order is always more expensive than getting it right up front. If your store sells scarce stock and you want the checkout to survive its own busiest day, &lt;a href="https://iurii.rogulia.fi/contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>node</category>
      <category>postgres</category>
    </item>
    <item>
      <title>How Lenders Verify Bank Statements — and the Forgery Layer They Miss</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Fri, 07 Aug 2026 10:00:40 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/how-lenders-verify-bank-statements-and-the-forgery-layer-they-miss-2a95</link>
      <guid>https://dev.to/iurii_rogulia/how-lenders-verify-bank-statements-and-the-forgery-layer-they-miss-2a95</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/how-lenders-verify-bank-statements" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt;. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A borrower submits a three-month bank statement to support a loan application. The deposits line up with the stated salary. The running balance never dips below the threshold. The bank logo is crisp, the columns are aligned, the totals add up. Your underwriter cross-checks the numbers, sees nothing out of place, and clears the file.&lt;/p&gt;

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

&lt;p&gt;The reason is that the manual verification process checks whether the &lt;em&gt;content&lt;/em&gt; of a statement is plausible. It was never designed to check whether the &lt;em&gt;file itself&lt;/em&gt; was edited after the bank produced it. Those are two different questions, and a borrower who opens the real PDF in an editor and types over one number defeats the second one completely — while passing the first without trouble.&lt;/p&gt;

&lt;p&gt;This article walks through how lenders and underwriters actually verify bank statements today, exactly where that process breaks on a well-altered PDF, and the structural forensic layer that closes the gap. It is written for risk and fraud operations teams, not developers — though there is a short integration section at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Lenders Verify Bank Statements Today
&lt;/h2&gt;

&lt;p&gt;Bank statement verification in lending is a layered process. No single check carries the decision; underwriters build confidence by stacking several. Here is what a competent manual review actually does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-check deposits against the pay cycle.&lt;/strong&gt; The underwriter confirms that the income claimed on the application actually lands in the account, on a regular cadence, in amounts consistent with the stated salary. A borrower claiming $6,000/month in W-2 income should show roughly that, roughly twice a month, on predictable dates. Irregular timing or amounts that don’t match the employer’s pay schedule are flagged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trace large or irregular deposits.&lt;/strong&gt; Most lenders require any non-payroll deposit above a set threshold — often around $200 for mortgages — to be sourced and seasoned. &lt;em&gt;Sourced&lt;/em&gt; means the borrower can prove where the money came from. &lt;em&gt;Seasoned&lt;/em&gt; means it has been in the account long enough (commonly 60 days) to rule out a last-minute borrowed lump sum dressed up as savings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the running balance math.&lt;/strong&gt; The closing balance of one day should equal the opening balance of the next. Each line should move the balance by exactly the transaction amount. Underwriters scan for arithmetic that doesn’t reconcile — a balance that jumps without a matching transaction, or a total that doesn’t follow from the lines above it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Look for round numbers and patterns that look wrong.&lt;/strong&gt; Genuine spending is messy: $4.37 here, $112.94 there. Suspiciously clean figures — a deposit of exactly $5,000.00, a balance that always ends in round hundreds — get a second look, because fabricated numbers tend to be tidy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify against the source.&lt;/strong&gt; The strongest manual checks reach outside the document. The underwriter may call the issuing bank, request a verification of deposit, or confirm employment directly with the employer. Increasingly, lenders skip the document entirely and pull the data through an open-banking connection like Plaid — connecting straight to the bank’s own API so the borrower never hands over a PDF at all.&lt;/p&gt;

&lt;p&gt;Each of these is a real, valuable control. Stacked together they catch a great deal of fraud — clumsy fakes, inconsistent stories, deposits that don’t reconcile. The question is not whether this process works. It is &lt;em&gt;what it was built to verify.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The One Thing the Checklist Doesn’t Verify
&lt;/h2&gt;

&lt;p&gt;Read that list again and notice the common thread: every check verifies whether the &lt;strong&gt;numbers on the page are plausible&lt;/strong&gt;. Do the deposits match the pay cycle? Does the balance reconcile? Is the large deposit sourced? These are content questions. They ask, “Could a real account have produced these figures?”&lt;/p&gt;

&lt;p&gt;None of them ask the other question: “Was this file edited after the bank generated it?”&lt;/p&gt;

&lt;p&gt;That is the gap a competent forger lives in. Consider what actually happens in the most common bank statement fraud case in lending:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The borrower logs into their real bank portal and downloads their real statement as a PDF.&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 number — inflate a balance, raise a deposit, delete an overdraft line.&lt;/li&gt;
&lt;li&gt;They re-save and upload.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result is a document where the layout is the bank’s real template, the logo is the bank’s real logo, the fonts are right, and — critically — the forger had every opportunity to make the edited numbers &lt;em&gt;internally consistent&lt;/em&gt;. They can recompute the running balance by hand so it reconciles. They can pick a deposit amount that matches the stated pay cycle. They can avoid round numbers. 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.&lt;/p&gt;

&lt;p&gt;Visual review fares no better. On screen, an edited number is indistinguishable from an original one — the editor renders it in the same font, at the same position. The human eye has nothing to catch.&lt;/p&gt;

&lt;p&gt;This is why bank statement fraud is so persistent. According to fraud-analytics firm SEON, bank statements are the single most commonly falsified document in lending applications, cited in a majority of fraudulent loan files. They are easy to obtain, trivial to edit with free tools, and — once edited carefully — invisible to a process designed to verify content rather than file integrity.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Open Banking Solves, and What It Doesn’t
&lt;/h2&gt;

&lt;p&gt;Open-banking connectors like Plaid are the strongest answer to this problem, and it would be dishonest to pretend otherwise. When a borrower links their account through Plaid, the lender pulls transaction data straight from the bank’s API. There is no PDF in the loop, so there is nothing to edit. For applicants whose institution is supported and who consent to connect, this is the gold standard for income source-of-truth checks. HTPBE does not replace it, and you should keep it.&lt;/p&gt;

&lt;p&gt;But open banking has a coverage boundary, and fraud concentrates exactly at that boundary.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unsupported institutions.&lt;/strong&gt; Plaid and its peers connect to a large but finite set of banks. Borrowers at smaller credit unions, regional banks, or non-US institutions often can’t connect at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;International income.&lt;/strong&gt; Lenders serving immigrants, expats, and cross-border earners routinely see statements from banks no open-banking network reaches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Applicants who simply won’t connect.&lt;/strong&gt; Some borrowers decline the bank link — out of privacy concern, friction, or because they have something to hide — and fall back to uploading a PDF.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Product types that lean on documents.&lt;/strong&gt; BNPL, merchant cash advance, and many alternative-lending products still take PDF statements as a primary input.&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 PDF — the exact scenario where content checks can’t see an edit. Open banking removes the document for the applicants it covers. For everyone else, the document is the only evidence, and its integrity goes unchecked. KYC and identity platforms don’t fill this gap either: they confirm &lt;em&gt;who&lt;/em&gt; is applying — face match, ID document, watchlist — not whether a submitted financial PDF was altered.&lt;/p&gt;

&lt;p&gt;That uploaded-PDF case is precisely where a structural layer earns its place. It is additive: it catches what open banking sidesteps and what the manual checklist 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 with an internal record of how it was built and saved — when it was created, what software produced it, whether it carries a digital signature, and how many times it was written to disk. When someone opens a bank’s original PDF and saves an edit, that act leaves traces in the file’s structure, regardless of how plausible the visible numbers are.&lt;/p&gt;

&lt;p&gt;Structural forensics reads that internal record and returns a verdict. It never looks at whether $6,000 is a believable salary — that’s the underwriter’s job and the content layer’s job. It looks at whether the file’s own construction is consistent with a clean, single-pass export from an institutional banking 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. Crucially, 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;&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 statement that should be a clean bank 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 lending 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 bank statements are produced by server-side institutional 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 bank 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 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 disguise 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 statement is digitally signed, 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 numbers being implausible. A perfectly reconciled, perfectly tidy forged balance 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 sanity-check a single suspicious statement 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 Lending Context
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;inconclusive&lt;/code&gt; verdict is the one that trips people up, 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, Excel, 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 bank the borrower claims it’s from.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Major retail banks generate their statements on server-side institutional systems. A statement that genuinely came from one of them should look like an institutional export. So if a borrower hands you a statement claiming to be from a large bank, 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 statement from that bank would not look like that. That mismatch is a reason to escalate, not to wave through.&lt;/p&gt;

&lt;p&gt;The same &lt;code&gt;inconclusive&lt;/code&gt; verdict on a statement from a small credit union or a fintech neobank — where consumer-style export is genuinely normal — is routine, and you’d treat it as such. The verdict is identical; the action depends on whether the claimed issuer 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.&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 fraud 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 doesn’t edit a real bank export but instead builds a fake statement 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 why the layer complements rather than replaces content checks and source verification: open-banking data, verification of deposit, and calling the bank 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 statements.&lt;/strong&gt; Some smaller institutions really do export through generic print drivers or consumer tools. 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 issuer 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 verification process — catching the altered-PDF case that content checks and open banking can’t — not to be a single switch that decides applications on its own. The same mechanics apply to income documents: &lt;a href="https://htpbe.tech/blog/altered-paystub-w2-mortgage-underwriting" rel="noopener noreferrer"&gt;altered paystubs and W-2s&lt;/a&gt; slip past underwriting in exactly the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring It Into Your Intake
&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 PDF.&lt;/strong&gt; When a borrower uploads a statement, 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/statements/applicant-7821.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, request a fresh download direct from the bank portal, or — for the most conclusive markers like a post-signature edit — reject.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;inconclusive&lt;/code&gt; → branch on the claimed issuer. A consumer-software origin on a statement claimed to be from a major bank is worth escalating; the same verdict from a neobank is routine.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;intact&lt;/code&gt; → no structural evidence of alteration; proceed with your normal content verification.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Store the check ID against the application record. If a credit decision is ever disputed, the forensic result is retrievable as a permanent audit trail showing exactly which structural signals fired — useful for both compliance and chargeback defense.&lt;/p&gt;

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

&lt;p&gt;If you run risk or fraud operations at an alternative lender, a fintech, a BNPL or MCA provider — anywhere a meaningful share of applicants upload PDF bank statements instead of connecting through open banking — this is the gap in your stack worth closing. Your underwriters are already verifying content well. What they cannot see, by design, is whether the file in front of them was edited after the bank made it. That blind spot is exactly where careful borrowers operate, and it’s exactly what a structural layer covers.&lt;/p&gt;

&lt;p&gt;For a deeper look at how this works as a workflow rather than a single call, see the &lt;a href="https://htpbe.tech/use-cases/fake-bank-statement-detection" rel="noopener noreferrer"&gt;fake bank statement detection use case&lt;/a&gt;. When you’re ready to put a verdict behind your 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;/p&gt;

</description>
      <category>pdf</category>
      <category>fintech</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Bulk Re-Validate Customer VAT IDs</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:00:50 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/how-to-bulk-re-validate-customer-vat-ids-2fec</link>
      <guid>https://dev.to/iurii_rogulia/how-to-bulk-re-validate-customer-vat-ids-2fec</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/bulk-revalidate-customer-vat-ids" 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;
  
  
  How to Bulk Re-Validate Your Customer VAT IDs (and Keep Them Fresh)
&lt;/h1&gt;

&lt;p&gt;Most teams validate a customer's VAT number once — at signup, or the first time they issue a reverse-charge invoice — and then never look at it again. That is fine until an auditor asks whether the number was still valid at the time of each supply, or until a customer who deregistered eighteen months ago is still being invoiced without VAT. A VAT registration is not a permanent fact. This post is the practical version of fixing that: how to validate a whole existing customer base in one backfill pass, survive VIES going down partway through, spot the numbers that quietly flipped from valid to invalid, and then stop doing it by hand.&lt;/p&gt;

&lt;p&gt;The scope here is the EU-27 plus Northern Ireland (&lt;code&gt;XI&lt;/code&gt;), which is what VIES covers. Everything below uses the vatnode &lt;a href="https://vatnode.dev/vat-api" rel="noopener noreferrer"&gt;VAT API&lt;/a&gt; — a single &lt;code&gt;GET /v1/vat/:vatId&lt;/code&gt; call with a Bearer key — but the batching and requeue logic applies to any VIES client you build yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a stored VAT ID goes stale
&lt;/h2&gt;

&lt;p&gt;A number that returned &lt;code&gt;valid&lt;/code&gt; last year can return &lt;code&gt;invalid&lt;/code&gt; today. Businesses deregister, get struck off, restructure into a new entity, or have their intra-EU (VIES) registration disabled while the domestic registration lives on. None of that generates a notification. The value sitting in your &lt;code&gt;customers&lt;/code&gt; table is a snapshot of a moment that has already passed.&lt;/p&gt;

&lt;p&gt;This matters because the customer's valid VAT ID is a &lt;em&gt;substantive&lt;/em&gt; condition for zero-rating intra-Community supplies of goods — Article 138 of the &lt;a href="https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:02006L0112-20240101" rel="noopener noreferrer"&gt;EU VAT Directive (2006/112/EC)&lt;/a&gt;, as amended by the 2020 Quick Fixes. For goods, the linked condition is the valid VAT ID &lt;em&gt;plus&lt;/em&gt; a correct recapitulative statement (your EC Sales List). "Reverse charge" in the strict sense is the services case. Either way, the defensible position is the same: you want timestamped evidence that the condition held at the time of each supply, not just at signup.&lt;/p&gt;

&lt;p&gt;There is no single EU-mandated re-check frequency. Cadence is a risk decision — set it with your tax adviser. But the mechanism for keeping the evidence current is the same regardless of the interval you land on, and that mechanism is what the rest of this post builds.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is general information about EU VAT and VIES, not tax advice. Whether a specific transaction&lt;br&gt;
  qualifies for zero-rating, exemption, or reverse charge depends on facts we can't assess here —&lt;br&gt;
  confirm the treatment of your own transactions with a qualified tax adviser.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 1 — dedupe and format-filter before you call anything
&lt;/h2&gt;

&lt;p&gt;The first pass never touches the network. A real customer base has the same VAT ID stored against multiple contacts, the same number written five different ways (&lt;code&gt;DE123456789&lt;/code&gt;, &lt;code&gt;DE 123 456 789&lt;/code&gt;, &lt;code&gt;de123456789&lt;/code&gt;), and a long tail of junk that will never validate — blank strings, phone numbers pasted into the wrong field, national tax numbers that are not VIES-format at all.&lt;/p&gt;

&lt;p&gt;Every one of those is a wasted VIES call if you send it. Normalise first, dedupe second, then discard anything that cannot be a well-formed EU/XI VAT ID before you spend a single request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;CustomerRow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;rawVatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Uppercase, strip everything that isn't a letter or digit.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizeVatId&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&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;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;A-Z0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Cheap structural gate: two-letter country prefix + at least one alphanumeric.&lt;/span&gt;
&lt;span class="c1"&gt;// This is a coarse filter, NOT full per-country validation — the API does that.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VAT_SHAPE&lt;/span&gt; &lt;span class="o"&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;A-Z&lt;/span&gt;&lt;span class="se"&gt;]{2}[&lt;/span&gt;&lt;span class="sr"&gt;A-Z0-9&lt;/span&gt;&lt;span class="se"&gt;]{2,}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;prepareBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CustomerRow&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;byVatId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&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;// vatId -&amp;gt; customerIds&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;skipped&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CustomerRow&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rows&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;vatId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalizeVatId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rawVatId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;VAT_SHAPE&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;vatId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;skipped&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// malformed — flag for manual review, don't call VIES&lt;/span&gt;
      &lt;span class="k"&gt;continue&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;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;byVatId&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="nx"&gt;vatId&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="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;byVatId&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="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// One network call per distinct number; fan the result back out to customers.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toCheck&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;byVatId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;()].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;customerIds&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;customerIds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}))&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;toCheck&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;skipped&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;On a base of any real size this collapses the work dramatically — it is common to see tens of thousands of customer rows resolve to a few thousand distinct numbers. You validate each distinct number once and fan the answer back out to every customer that shares it. The &lt;code&gt;skipped&lt;/code&gt; list is not garbage; it is a work queue for a human, because a malformed stored VAT ID is often a data-entry bug worth fixing at the source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — batch the base at a sane throughput
&lt;/h2&gt;

&lt;p&gt;VIES is not one service. It is a thin EC gateway in front of national tax-authority nodes, and those nodes are per-country and rate-sensitive. Firing ten thousand requests in parallel is the fastest way to get throttled and turn a clean backfill into a mess of transient failures. The goal is steady, bounded throughput — a small concurrency limit, running continuously — not a thundering herd.&lt;/p&gt;

&lt;p&gt;A simple worker-pool pattern keeps a fixed number of checks in flight and refills as each one finishes:&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;API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.vatnode.dev/v1/vat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VATNODE_API_KEY&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;CheckJob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;customerIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Outcome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checked&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VatResult&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="na"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;requeue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;reason&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CheckJob&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;concurrency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Outcome&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;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Outcome&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CheckJob&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&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;checkOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// A handful of workers, not thousands of parallel fetches.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;concurrency&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;worker&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;results&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the concurrency low — a handful of workers is plenty for a background job that is allowed to take minutes or hours. There is no prize for finishing a backfill in ten seconds, and being polite to the national nodes is what keeps your error rate near zero. If you are running your own VIES client rather than the &lt;a href="https://vatnode.dev/vat-api" rel="noopener noreferrer"&gt;VAT API&lt;/a&gt;, the same discipline applies, plus you own the caching and retry layer that the API otherwise handles for you — see &lt;a href="https://vatnode.dev/blog/caching-vies-responses-compliance" rel="noopener noreferrer"&gt;re-validation cadence and TTLs&lt;/a&gt; for how to think about that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — surviving a VIES outage mid-run
&lt;/h2&gt;

&lt;p&gt;This is the step people get wrong, and it is the one that does real damage. Partway through a long backfill, a country node will go down. Those rows cannot be confirmed in this run — and that is &lt;em&gt;all&lt;/em&gt; it means. A could-not-check outcome is &lt;strong&gt;not&lt;/strong&gt; a negative result. If you store &lt;code&gt;VIES_UNAVAILABLE&lt;/code&gt; or a timeout as &lt;code&gt;valid: false&lt;/code&gt;, you have just marked a batch of real, paying customers as having invalid VAT numbers, and someone downstream will act on it.&lt;/p&gt;

&lt;p&gt;The rule is simple: only &lt;code&gt;valid: true&lt;/code&gt; and &lt;code&gt;valid: false&lt;/code&gt; are real outcomes. Everything else — &lt;code&gt;VIES_UNAVAILABLE&lt;/code&gt; (503), &lt;code&gt;UPSTREAM_TIMEOUT&lt;/code&gt; (504), &lt;code&gt;RATE_LIMITED&lt;/code&gt; (429) — is &lt;em&gt;pending&lt;/em&gt;. Requeue it and try again later. Never let it touch the stored status.&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;type&lt;/span&gt; &lt;span class="nx"&gt;VatResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
  &lt;span class="na"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;countryCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;countryName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;companyName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="na"&gt;companyAddress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="na"&gt;checkId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;verifiedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="c1"&gt;// 'VIES' | 'CACHE' | national registry code&lt;/span&gt;
  &lt;span class="na"&gt;consultationNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CheckJob&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Outcome&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;let&lt;/span&gt; &lt;span class="na"&gt;res&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API&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="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Network/transport error — temporary, requeue.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;requeue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NETWORK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// A node being down is not the customer's fault. Requeue, never store invalid.&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;504&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;requeue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`HTTP_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Genuine client errors (malformed input etc.) — surface, don't loop forever.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;requeue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`HTTP_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;VatResult&lt;/span&gt;
  &lt;span class="c1"&gt;// Only here do we have a real valid/invalid answer worth persisting.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checked&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the requeue list as a second pass after a backoff — a down node is usually back within minutes to hours. Because VIES failures are country-scoped, a single unreachable member state should not stall the rest of the run; the healthy nodes keep answering while the affected prefix cools off. The full retry-and-backoff pattern, including per-country failure tracking, is in the &lt;a href="https://vatnode.dev/blog/vies-downtime-guide" rel="noopener noreferrer"&gt;handling VIES downtime&lt;/a&gt; guide. The one non-negotiable: a pending row keeps its previous stored status until a real answer replaces it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — recording what changed (and what to do about it)
&lt;/h2&gt;

&lt;p&gt;A backfill that overwrites the old value with the new one and moves on has thrown away the interesting part. The point of re-validation is the &lt;em&gt;diff&lt;/em&gt; — which numbers flipped since the last time you checked. Compare each fresh result against the last stored outcome and act only on the transitions:&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;type&lt;/span&gt; &lt;span class="nx"&gt;StoredOutcome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;consultationNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="na"&gt;verifiedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;diffOutcome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;StoredOutcome&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VatResult&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unchanged&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;went_invalid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;became_valid&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="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unchanged&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;became_valid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;went_invalid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Persist an APPEND-ONLY row for every real check — never mutate the old one.&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;recordCheck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customerId&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;fresh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VatResult&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vat_checks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 'VIES' vs a national registry code — keep it&lt;/span&gt;
    &lt;span class="na"&gt;consultationNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;consultationNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// null on national-fallback rows&lt;/span&gt;
    &lt;span class="na"&gt;checkId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;verifiedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verifiedAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things hold up in practice here. Store an append-only row per check, never an overwrite — the timestamped history &lt;em&gt;is&lt;/em&gt; the audit trail. Keep the &lt;code&gt;source&lt;/code&gt; on every row: a &lt;code&gt;VIES&lt;/code&gt; result and a national-registry result are not equivalent evidence. National-fallback rows carry &lt;strong&gt;no&lt;/strong&gt; &lt;code&gt;consultationNumber&lt;/code&gt; — that field is a VIES-only artefact — so do not treat a populated and a null consultation number as the same weight. When &lt;code&gt;source&lt;/code&gt; is &lt;code&gt;VIES&lt;/code&gt;, the consultation number is your externally-attested &lt;a href="https://vatnode.dev/blog/vies-consultation-number-explained" rel="noopener noreferrer"&gt;evidence for each re-check&lt;/a&gt;; when the answer came from a national registry, you still have a timestamped result, just of a different kind.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;went_invalid&lt;/code&gt; list is your action queue. Those are the customers to stop reverse-charging until the number is fixed — but do it as a quiet outreach, not an automatic billing change. A flip to invalid can mean a genuine deregistration or a customer who restructured and simply needs to give you the new number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — from one-off backfill to continuous monitoring
&lt;/h2&gt;

&lt;p&gt;Steps 1–4 are a batch job you run once to clean up the base you already have. The obvious next question is how often to run it again — and the better answer is usually "don't run it by hand at all". A backfill is the one-time version of a loop that wants to be continuous: re-check on a cadence, diff against the last stored outcome, and get told when something flips.&lt;/p&gt;

&lt;p&gt;That is exactly what monitoring is. Instead of scheduling a manual bulk pass, you register the numbers you care about and let re-checks run automatically, with a webhook firing when a number's status changes so the &lt;code&gt;went_invalid&lt;/code&gt; case lands in your systems the day it happens rather than at your next quarterly sweep. The setup and payload shape are in the &lt;a href="https://vatnode.dev/guides/vat-monitoring-webhooks" rel="noopener noreferrer"&gt;VAT monitoring and webhooks&lt;/a&gt; guide.&lt;/p&gt;

&lt;p&gt;The practical split: run the bulk backfill once to establish a clean baseline and catch everything that already went stale, then hand the ongoing job to monitoring so you never have to schedule the manual cadence again. Bulk re-validation answers "is my existing base clean right now?"; monitoring answers "tell me the moment it stops being clean."&lt;/p&gt;

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

&lt;h3&gt;
  
  
  How often should I re-validate stored VAT numbers?
&lt;/h3&gt;

&lt;p&gt;There's no legal fixed interval, but because registrations get cancelled, re-checking on a periodic cadence (and before high-value events like renewals or invoicing) keeps your data defensible; continuous monitoring removes the manual cadence entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I validate thousands of VAT numbers at once?
&lt;/h3&gt;

&lt;p&gt;Yes, but pace the run — dedupe and format-filter first to avoid wasted calls, then batch at a steady throughput rather than firing everything in parallel, since the underlying VIES nodes are per-country and rate-sensitive.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if VIES goes down partway through a bulk run?
&lt;/h3&gt;

&lt;p&gt;Treat unreachable results as pending, not invalid, and requeue them; a per-country node being offline is temporary and should not mark real customers as failed.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I know which customers' VAT IDs went invalid?
&lt;/h3&gt;

&lt;p&gt;Compare each re-check against the last stored outcome and flag only the numbers whose status flipped, keeping the timestamp and evidence for each so the change is auditable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is bulk re-validation different from monitoring?
&lt;/h3&gt;

&lt;p&gt;Bulk re-validation is a one-time backfill of your existing base; monitoring is the ongoing version that re-checks automatically and notifies you (via webhooks) when a number changes.&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  Validate your whole base against one endpoint
&lt;/h3&gt;

&lt;p&gt;vatnode is a single &lt;code&gt;GET /v1/vat/:vatId&lt;/code&gt; call with a Bearer key — it runs the requester-qualified VIES lookup, falls back to national registries when a node is down, and returns structured errors so your requeue logic works exactly as above. Free plan, 100 requests/month.&lt;/p&gt;

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

</description>
      <category>tax</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Programmatic SEO with hreflang: One Joke, 17 Languages, Server-Rendered</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Wed, 05 Aug 2026 10:00:49 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/programmatic-seo-with-hreflang-one-joke-17-languages-server-rendered-3eei</link>
      <guid>https://dev.to/iurii_rogulia/programmatic-seo-with-hreflang-one-joke-17-languages-server-rendered-3eei</guid>
      <description>&lt;p&gt;People type &lt;code&gt;2+2&lt;/code&gt; into Google. They type &lt;code&gt;9+10&lt;/code&gt;. They type &lt;code&gt;7*8&lt;/code&gt; when they can't remember whether it's 54 or 56. Each of those is a real, high-volume search query — and most of the results are identical calculator widgets. So when I built &lt;a href="https://wrongulator.com" rel="noopener noreferrer"&gt;Wrongulator&lt;/a&gt;, a calculator that returns a confidently wrong answer on purpose, I had a question worth asking: what if every expression were its own page, ranking for the exact arithmetic people already search?&lt;/p&gt;

&lt;p&gt;That is programmatic SEO — generating a page per parameter instead of writing pages by hand. And doing it across 17 languages means programmatic SEO with hreflang, where each generated page also declares its 16 translated siblings. The trap is that most programmatic surfaces are thin, duplicative, and get buried by Google. This one isn't, for a specific reason: every page has a real, unique answer baked into the HTML before any JavaScript runs. This post is about how — and the honest costs nobody mentions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Permalink Per Expression Is Even Possible
&lt;/h2&gt;

&lt;p&gt;A page per expression only works if &lt;code&gt;/2+2&lt;/code&gt; reproduces the same result for everyone, forever, with no database behind it. That property isn't free — it's the result of one design decision I cover in detail in &lt;a href="https://iurii.rogulia.fi/blog/wrongulator-deterministic" rel="noopener noreferrer"&gt;why a viral toy must be wrong the same way every time&lt;/a&gt;: the wrong answer is a &lt;strong&gt;pure function&lt;/strong&gt; of the expression, seeded by a stable hash, with no per-user state.&lt;/p&gt;

&lt;p&gt;The relevant consequence here is what that property unlocks for SEO. Because &lt;code&gt;f("2+2")&lt;/code&gt; always returns the same wrong answer, the server can compute that answer on demand for &lt;em&gt;any&lt;/em&gt; expression in the URL, with zero storage. There's no &lt;code&gt;pages&lt;/code&gt; table, no CMS, no pre-generation job. A request for &lt;code&gt;/64+5&lt;/code&gt; runs the engine, gets &lt;code&gt;67&lt;/code&gt; ("the only correct number"), and renders a complete page around it. The programmatic surface is, in effect, infinite — but it costs nothing to hold, because nothing is stored. The pure function is what makes thousands of unique pages possible without a database.&lt;/p&gt;

&lt;p&gt;That's the foundation. Everything below is about putting real, crawlable content on top of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the SEO Has to Be Server-Side
&lt;/h2&gt;

&lt;p&gt;Here's the thing people building client-side toys get wrong: &lt;strong&gt;search crawlers and social unfurlers don't run your JavaScript.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When Googlebot fetches &lt;code&gt;/9+10&lt;/code&gt;, it reads the raw HTML response. Modern Googlebot &lt;em&gt;can&lt;/em&gt; render JS in a second pass, but it's queued, delayed, and unreliable — and social crawlers (Twitterbot, &lt;code&gt;facebookexternalhit&lt;/code&gt;, Slack's unfurler) don't render JS at all. They read the HTML once and stop. If your wrong answer only appears after the engine runs in the browser, then to every crawler the page is blank — a generic splash with no content to index and no answer to unfurl.&lt;/p&gt;

&lt;p&gt;So the entire indexable payload has to exist in the server's first response: the localized &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; and description, the canonical URL, &lt;code&gt;hreflang&lt;/code&gt; alternates for all 17 languages, OpenGraph and Twitter tags, structured data, and — critically — the actual wrong answer as real text in the body. The page has to be &lt;em&gt;done&lt;/em&gt; the moment it arrives.&lt;/p&gt;

&lt;p&gt;The OG &lt;em&gt;image&lt;/em&gt; is part of this story too, but it's its own problem — rendering the share card identically in the browser and on the server is covered in &lt;a href="https://iurii.rogulia.fi/blog/wrongulator-isomorphic-canvas" rel="noopener noreferrer"&gt;isomorphic Canvas rendering&lt;/a&gt;. This article is about the &lt;em&gt;text&lt;/em&gt; side: the meta tags, the alternates, the structured data, and the indexable answer in the HTML body.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Programmatic Surface: Meta, hreflang, and JSON-LD
&lt;/h2&gt;

&lt;p&gt;Three things get computed server-side, per link, before the response goes out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, the structured data.&lt;/strong&gt; Each expression page carries a JSON-LD &lt;code&gt;FAQPage&lt;/code&gt; with the real (wrong) answer as the accepted answer. This is what can earn a rich result in search and gives the crawler an unambiguous, machine-readable statement of what the page says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// server.js — JSON-LD FAQ with the real (wrong) answer, per expression&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jsonld&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@context&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;https://schema.org&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;@type&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;FAQPage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;mainEntity&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&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;Question&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`What is &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;disp&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;acceptedAnswer&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;@type&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;Answer&lt;/span&gt;&lt;span class="dl"&gt;"&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="s2"&gt;`Wrongulator says &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;disp&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;answer&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&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="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;Note &lt;code&gt;r.answer&lt;/code&gt; and &lt;code&gt;r.reason&lt;/code&gt; come straight from the engine, run server-side for this expression. The structured data isn't a static template — it contains the specific wrong answer and its deadpan justification, unique to this page. That's the difference between a &lt;code&gt;FAQPage&lt;/code&gt; that helps and one Google ignores as boilerplate. (The &lt;a href="https://schema.org/FAQPage" rel="noopener noreferrer"&gt;&lt;code&gt;FAQPage&lt;/code&gt; type&lt;/a&gt; is defined by schema.org; whether it shows as a rich result is up to Google.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, the localized meta and &lt;code&gt;hreflang&lt;/code&gt; set.&lt;/strong&gt; A request resolves a locale, and the server emits a localized &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;/description plus a canonical and a full &lt;code&gt;hreflang&lt;/code&gt; block pointing at all 17 language variants of the same expression. The shape, reconstructed from the server's response, is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- per-expression head, computed server-side (illustrative reconstruction) --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"canonical"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://wrongulator.com/9+10?lang=en"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://wrongulator.com/9+10?lang=en"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"th"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://wrongulator.com/9+10?lang=th"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"ja"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://wrongulator.com/9+10?lang=ja"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="c"&gt;&amp;lt;!-- … 13 more locales … --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"fi"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://wrongulator.com/9+10?lang=fi"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"x-default"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://wrongulator.com/9+10"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule &lt;a href="https://developers.google.com/search/docs/specialty/international/localized-versions" rel="noopener noreferrer"&gt;Google documents for &lt;code&gt;hreflang&lt;/code&gt;&lt;/a&gt; is that the set must be &lt;em&gt;reciprocal&lt;/em&gt; — every variant has to list every other variant, including itself, or Google ignores the whole cluster. With 17 languages that's a 17-entry block on each of 17 pages, all generated from one function so they can't fall out of sync. The &lt;code&gt;x-default&lt;/code&gt; is the fallback Google serves when none of the listed locales match the user's language.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third, the sitemap that seeds the surface.&lt;/strong&gt; Crawlers won't discover &lt;code&gt;/9+10&lt;/code&gt; on their own — nothing links to every arithmetic expression. So a curated &lt;code&gt;sitemap.xml&lt;/code&gt; lists the highest-search expressions (&lt;code&gt;2+2&lt;/code&gt;, &lt;code&gt;9+10&lt;/code&gt;, &lt;code&gt;7*8&lt;/code&gt;, and so on), each with its &lt;code&gt;hreflang&lt;/code&gt; alternates. That's the deliberate part: I don't list &lt;em&gt;every&lt;/em&gt; possible expression — that's a combinatorial explosion and a quality problem (more on that below). I list the ones people actually search, and let the long tail be reachable but unseeded.&lt;/p&gt;

&lt;p&gt;The payoff of the whole stack: the toy can rank for the exact arithmetic queries people type, in their language, with a result that unfurls into the joke. The pure-function engine is what makes each of those pages cheap; the server-side meta is what makes them indexable.&lt;/p&gt;

&lt;p&gt;
  slug="seo-audit"&lt;br&gt;
  text="Programmatic SEO lives or dies on details crawlers see and you don't — reciprocal hreflang, canonical consistency, whether your generated content reads as unique or thin. A technical SEO audit checks exactly that, with a written report."&lt;br&gt;
/&amp;gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Localizing a Joke Without Killing It
&lt;/h2&gt;

&lt;p&gt;Seventeen languages is where most products either skip localization or ruin it. A joke run through a translation API isn't funny — it's a sentence that used to be funny. So the i18n layer doesn't translate uniformly. It splits the copy by &lt;strong&gt;what the text does.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Prose that merely &lt;em&gt;carries&lt;/em&gt; the joke gets translated: the deadpan justifications ("the only correct number"), the UI prompts, the void and malfunction lines. Those need to read naturally in the reader's language, because their job is to set up the punchline, not be it.&lt;/p&gt;

&lt;p&gt;But text that &lt;em&gt;is&lt;/em&gt; the joke gets left alone. The universal number-memes — skibidi, Ohio, &lt;code&gt;6-7&lt;/code&gt;, &lt;code&gt;300&lt;/code&gt;, &lt;code&gt;9+10=21&lt;/code&gt; — travel as-is and are funnier untouched. Translating "skibidi" into Finnish would be vandalism. These memes are internet-native; they already crossed languages on their own, and the engine respects that by never touching them.&lt;/p&gt;

&lt;p&gt;Then there's the third category, the one that makes the localization feel &lt;em&gt;native&lt;/em&gt; rather than merely translated: &lt;strong&gt;locale-gated memes&lt;/strong&gt; that only fire in their own language, because the number means something only in that culture.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// public/i18n.js — split by what the text DOES&lt;/span&gt;
&lt;span class="c1"&gt;// translate: deadpan/ai reasons, void/malfunction lines, UI prompts&lt;/span&gt;
&lt;span class="c1"&gt;// leave alone: universal number-memes; brand labels ("100% WRONG", "HALL OF FAME")&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MEMES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;meme555&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="s1"&gt;ห้า ห้า ห้า. ตลกจนเครื่องคิดเลขขำ.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...],&lt;/span&gt; &lt;span class="c1"&gt;// th — only ever shown for th&lt;/span&gt;
  &lt;span class="na"&gt;meme39&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="s1"&gt;サンキュー。3-9。電卓からの感謝です。&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...],&lt;/span&gt;     &lt;span class="c1"&gt;// ja&lt;/span&gt;
  &lt;span class="na"&gt;meme666&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="s1"&gt;PERKELE. Tämä on ainoa oikea vastaus.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...],&lt;/span&gt;  &lt;span class="c1"&gt;// fi&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each of those is a number-pun that lands in exactly one language. Thai &lt;code&gt;555&lt;/code&gt; reads as "hahaha" because 5 is pronounced &lt;em&gt;ha&lt;/em&gt;. Japanese &lt;code&gt;39&lt;/code&gt; reads as "thank you" (&lt;em&gt;san-kyū&lt;/em&gt;). Finnish &lt;code&gt;666&lt;/code&gt; triggers &lt;em&gt;perkele&lt;/em&gt;, the national curse word, written natively — not "the devil's number" politely translated. A non-speaker would never know these are jokes; a speaker recognizes them instantly. That's the whole point: the toy feels like it was made by someone who actually speaks your language, in 17 languages, because for the locale-gated memes, it effectively was. All 17 — including RTL Arabic — went through a native-speaker, meme-aware review pass. This is the same discipline I apply to production EU storefronts in &lt;a href="https://iurii.rogulia.fi/blog/nextjs-i18n-30-languages" rel="noopener noreferrer"&gt;Next.js i18n at 30 languages&lt;/a&gt;: translate the prose, respect what doesn't translate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Honest Cost
&lt;/h2&gt;

&lt;p&gt;Programmatic SEO and 17-language localization both sound like pure leverage. They aren't. Here's what they actually cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The thin-pages risk is real.&lt;/strong&gt; Google penalizes mass-produced, low-value pages — "doorway pages" in its terms — and a page-per-parameter surface is exactly the pattern that triggers it. The only reason Wrongulator's pages aren't doorways is that each one has genuinely unique content: a specific wrong answer, a specific deadpan reason, a real &lt;code&gt;FAQPage&lt;/code&gt; describing them. If the pages were empty shells with only the expression swapped in the title, this would be a textbook thin-content surface and deserve to be buried. Programmatic SEO is not a trick for ranking empty pages — it's a way to scale pages that would each be worth publishing on their own. Get that backwards and Google is right to punish you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Seventeen &lt;code&gt;hreflang&lt;/code&gt; sets are fragile and expensive.&lt;/strong&gt; Reciprocity means every page references every locale; one broken or non-reciprocal link can invalidate the cluster. Generating the block from a single function is the only sane way to keep it consistent — hand-maintaining 17×17 alternates would be a guaranteed source of silent bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Native review doesn't scale and doesn't stay done.&lt;/strong&gt; A locale-gated meme is only funny if a native speaker confirmed it's funny — and idioms drift, slang ages, and a pun that lands today can feel dated in a year. You need a real speaker per language, both to write the joke and to catch the cultural misfire that a translation tool would happily ship. That's 17 people's worth of cultural knowledge, and it's a recurring cost, not a one-time one. Skip it and you don't get a worse joke; you get a dead one, and the reader can tell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The curated sitemap is manual, on purpose.&lt;/strong&gt; I could generate a sitemap of millions of expressions. I don't, because most of them have zero search volume and listing them would dilute the surface into exactly the thin-content pattern above. Choosing which expressions to seed is editorial work — you have to know (or look up) what people actually search — and it has to be revisited as search trends shift. The combinatorial space is infinite; the worthwhile slice is small and hand-picked.&lt;/p&gt;

&lt;p&gt;None of these are reasons not to do it. They're the reasons it works when most programmatic-SEO attempts don't: the surface is built on real per-page content, the alternates are machine-generated for consistency, and the human parts (memes, sitemap curation) are done by humans who know the domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Page per&lt;/td&gt;
&lt;td&gt;Expression — &lt;code&gt;/2+2&lt;/code&gt;, &lt;code&gt;/9+10&lt;/code&gt;, &lt;code&gt;/7*8&lt;/code&gt; each its own indexable URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;None — pure-function engine computes each page on demand, no database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Indexable content&lt;/td&gt;
&lt;td&gt;Real wrong answer + deadpan reason in the HTML body before any JS runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structured data&lt;/td&gt;
&lt;td&gt;JSON-LD &lt;code&gt;FAQPage&lt;/code&gt; per expression, with the actual wrong answer as the answer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Localized meta&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, description, canonical computed server-side per locale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hreflang&lt;/td&gt;
&lt;td&gt;Reciprocal set × 17 languages + &lt;code&gt;x-default&lt;/code&gt;, generated from one function&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sitemap&lt;/td&gt;
&lt;td&gt;Curated — highest-search expressions only, each with hreflang alternates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;i18n split&lt;/td&gt;
&lt;td&gt;Translate carrier prose; leave universal memes; locale-gate native puns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Locale-gated memes&lt;/td&gt;
&lt;td&gt;Thai &lt;code&gt;555&lt;/code&gt;, Japanese &lt;code&gt;39&lt;/code&gt;, Finnish &lt;code&gt;666&lt;/code&gt; — fire only in their own language&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Review&lt;/td&gt;
&lt;td&gt;Native-speaker, meme-aware pass on all 17 languages, including RTL Arabic&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A pure function is what makes programmatic SEO cheap.&lt;/strong&gt; If each page's content is computed deterministically from the URL, you need no database and no pre-generation — the surface is effectively infinite and costs nothing to hold. The hard part isn't volume; it's making each page genuinely worth indexing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;If a crawler can't see it without JS, it isn't indexed.&lt;/strong&gt; Social unfurlers run no JavaScript and search rendering is queued and unreliable. Anything you need ranked — meta, structured data, the actual answer — has to be in the server's first HTML response.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;hreflang&lt;/code&gt; must be reciprocal and generated, not hand-written.&lt;/strong&gt; Every locale variant lists every other, including itself, or Google drops the cluster. With many languages, the only maintainable approach is to emit the whole block from one function so it can't drift.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Localize by what the text &lt;em&gt;does&lt;/em&gt;, not uniformly.&lt;/strong&gt; Translate the prose that carries the joke; leave the universal memes alone; locale-gate the puns that only work in one language. Uniform translation flattens humor — and the same principle applies to any voice-driven content, not just jokes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Programmatic SEO is a quality multiplier, not a quantity trick.&lt;/strong&gt; It scales pages that would each be worth publishing. Point it at empty shells and you've built a doorway-page farm Google is right to bury. Curate the sitemap, keep each page real, and accept that the native-review cost is the price of doing it well.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full project — the deterministic Wrong Engine, isomorphic Canvas rendering, this programmatic-SEO surface, and a spoof-proof Hall of Fame — is written up in the &lt;a href="https://iurii.rogulia.fi/projects/wrongulator" rel="noopener noreferrer"&gt;Wrongulator project card&lt;/a&gt;. For the engine that makes every permalink reproducible, see &lt;a href="https://iurii.rogulia.fi/blog/wrongulator-deterministic" rel="noopener noreferrer"&gt;deterministic wrong&lt;/a&gt;; for the share image that unfurls beside this meta, see &lt;a href="https://iurii.rogulia.fi/blog/wrongulator-isomorphic-canvas" rel="noopener noreferrer"&gt;isomorphic Canvas rendering&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>express</category>
      <category>programmaticseo</category>
    </item>
    <item>
      <title>PDF Tamper Detection API for Ruby on Rails: Integration Guide</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Wed, 05 Aug 2026 10:00:40 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/pdf-tamper-detection-api-for-ruby-on-rails-integration-guide-lfd</link>
      <guid>https://dev.to/iurii_rogulia/pdf-tamper-detection-api-for-ruby-on-rails-integration-guide-lfd</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/pdf-verification-ruby-on-rails-integration-guide" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt;. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A large share of fintech still ships on Rails. Stripe, Gusto, GitHub, Shopify, Instacart — the generation of companies that defined modern payments and payroll built their backends on Ruby, and the startups following them keep reaching for the same stack. So when a forged bank statement, an altered payslip, or a doctored invoice lands in an underwriting queue, more often than you would guess it lands on a Rails controller. Your KYC provider already confirmed the applicant is a real person with a valid identity. It said nothing about whether the PDF they uploaded was edited after the bank generated it. That structural-tampering layer is invisible to identity verification, and the right place to catch it is at ingress — before your &lt;code&gt;Document&lt;/code&gt; model saves, before the row reaches underwriting, before any downstream system trusts the file.&lt;/p&gt;

&lt;p&gt;This guide walks through integrating the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;PDF tamper detection API&lt;/a&gt; into a Ruby on Rails application: from the first curl command to an idiomatic &lt;code&gt;HtpbeClient&lt;/code&gt; service object built on Faraday, a &lt;code&gt;Data&lt;/code&gt;-class result struct, configuration-bound credentials, a typed error class, an &lt;code&gt;ActiveJob&lt;/code&gt; that analyzes an uploaded document and routes on the verdict, and a request spec that stubs the API with WebMock. The patterns target Rails 7.x and Ruby 3.x, but they map cleanly onto Sinatra, Hanami, or a plain Ruby worker. Treat the code as a reference architecture: it runs the real request flow against the documented error codes, but you should adapt and harden it for your own traffic profile and threat model. If you want the conceptual overview first, start with &lt;a href="https://htpbe.tech/blog/detect-pdf-tampering-programmatically" rel="noopener noreferrer"&gt;How to Detect PDF Tampering Programmatically&lt;/a&gt;. Integrating from another stack? See the &lt;a href="https://htpbe.tech/blog/pdf-verification-api-python-integration-guide" rel="noopener noreferrer"&gt;Python&lt;/a&gt;, &lt;a href="https://htpbe.tech/blog/pdf-verification-nodejs-integration-guide" rel="noopener noreferrer"&gt;Node.js&lt;/a&gt;, &lt;a href="https://htpbe.tech/blog/pdf-verification-go-integration-guide" rel="noopener noreferrer"&gt;Go&lt;/a&gt;, &lt;a href="https://htpbe.tech/blog/pdf-verification-java-spring-boot-integration-guide" rel="noopener noreferrer"&gt;Java / Spring Boot&lt;/a&gt;, &lt;a href="https://htpbe.tech/blog/pdf-verification-laravel-php-integration-guide" rel="noopener noreferrer"&gt;Laravel / PHP&lt;/a&gt;, and &lt;a href="https://htpbe.tech/blog/pdf-verification-csharp-dotnet-integration-guide" rel="noopener noreferrer"&gt;C# / .NET&lt;/a&gt; guides.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Two API calls, three verdicts: &lt;code&gt;POST /analyze&lt;/code&gt; returns a top-level &lt;code&gt;id&lt;/code&gt;, then &lt;code&gt;GET /result/{id}&lt;/code&gt; returns the flat verdict object whose &lt;code&gt;status&lt;/code&gt; is one of &lt;code&gt;intact&lt;/code&gt;, &lt;code&gt;modified&lt;/code&gt;, or &lt;code&gt;inconclusive&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The minimum integration is Faraday and two calls. No dependency beyond the gem you already have.&lt;/li&gt;
&lt;li&gt;Production shape: an &lt;code&gt;HtpbeClient&lt;/code&gt; service object that reads the key from Rails credentials, parses the response into an immutable &lt;code&gt;Data&lt;/code&gt; result, raises a typed &lt;code&gt;HtpbeError&lt;/code&gt; carrying the HTTP status, and retries on 5xx and 429 only.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;ActiveJob&lt;/code&gt; that analyzes an uploaded document off the request thread and maps the verdict to an accept / reject / review decision.&lt;/li&gt;
&lt;li&gt;This is structural PDF tamper and forgery detection — not KYC, not OCR, not AI-text detection. It complements an identity stack; it does not replace one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Ruby 3.x (for &lt;code&gt;Data.define&lt;/code&gt;, pattern matching, and endless methods) and Rails 7.x&lt;/li&gt;
&lt;li&gt;An HTPBE API key (Dashboard → copy key)&lt;/li&gt;
&lt;li&gt;The Faraday gem: &lt;code&gt;gem "faraday"&lt;/code&gt; in your &lt;code&gt;Gemfile&lt;/code&gt;, then &lt;code&gt;bundle install&lt;/code&gt;. Faraday is already a transitive dependency of many Rails apps, and it is the de facto standard HTTP client in the Ruby ecosystem. If you would rather not add it, the standard-library &lt;code&gt;Net::HTTP&lt;/code&gt; works too — this guide uses Faraday for its cleaner middleware and timeout handling, and keeps to one client throughout.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Test the API with curl
&lt;/h2&gt;

&lt;p&gt;Before writing any Ruby, confirm your key works. The API uses a two-step flow: &lt;code&gt;POST /analyze&lt;/code&gt; submits a PDF URL and returns a check id, then &lt;code&gt;GET /result/{id}&lt;/code&gt; retrieves the full verdict. (For a language-agnostic overview of what the API detects, see &lt;a href="https://htpbe.tech/how" rel="noopener noreferrer"&gt;how PDF tamper detection works&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1a — submit for analysis:&lt;/strong&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 &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://api.htpbe.tech/v1/test/clean.pdf"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will receive a flat object with a single field: &lt;code&gt;{"id": "00000000-0000-4000-8000-000000000001"}&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1b — retrieve the result:&lt;/strong&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/YOUR_CHECK_ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_API_KEY"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will receive a flat JSON object with &lt;code&gt;"status": "intact"&lt;/code&gt; and the full set of analysis fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"00000000-0000-4000-8000-000000000001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"filename"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"clean.pdf"&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;"intact"&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;"institutional"&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="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;span class="nl"&gt;"creator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Adobe Acrobat Pro DC"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"producer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Adobe PDF Library 15.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"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;"none"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"has_incremental_updates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;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;"update_chain_length"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;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;"signature_removed"&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;"modifications_after_signature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"modification_markers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="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 same shape comes back for &lt;code&gt;modified&lt;/code&gt; and &lt;code&gt;inconclusive&lt;/code&gt; verdicts — only the values change. Two fields are conditional: &lt;code&gt;status_reason&lt;/code&gt; appears only when &lt;code&gt;status&lt;/code&gt; is &lt;code&gt;inconclusive&lt;/code&gt;, and &lt;code&gt;outdated_warning&lt;/code&gt; only when the check ran against an older algorithm version. Note that there is no &lt;code&gt;data&lt;/code&gt; wrapper and no numeric risk score — the response is the flat object above, and the whole signal is the verdict plus the named markers.&lt;/p&gt;

&lt;p&gt;The URL &lt;code&gt;https://api.htpbe.tech/v1/test/clean.pdf&lt;/code&gt; is a test mock — it returns a predictable response without consuming quota. Test keys (prefix &lt;code&gt;htpbe_test_&lt;/code&gt;) accept only these mock URLs; live keys (prefix &lt;code&gt;htpbe_live_&lt;/code&gt;) accept any public PDF URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Bind the Key to Credentials, Not Constants
&lt;/h2&gt;

&lt;p&gt;Keep the key and base URL out of code. Rails ships an encrypted credentials store for exactly this. Run &lt;code&gt;rails credentials:edit&lt;/code&gt; and add a block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;htpbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;api_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;htpbe_live_your_key_here&lt;/span&gt;
  &lt;span class="na"&gt;base_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://api.htpbe.tech/v1&lt;/span&gt;
  &lt;span class="na"&gt;timeout_seconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;35&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The encrypted file is safe to commit; the &lt;code&gt;config/master.key&lt;/code&gt; that decrypts it is not, and Rails already gitignores it. In containers where mounting the master key is awkward, set &lt;code&gt;RAILS_MASTER_KEY&lt;/code&gt; as an environment variable instead. Read the values through a small config object so a missing key fails fast at boot rather than on the first document that arrives at 2 a.m.:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/initializers/htpbe.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Htpbe&lt;/span&gt;
  &lt;span class="no"&gt;Config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:base_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:timeout_seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;
    &lt;span class="vi"&gt;@config&lt;/span&gt; &lt;span class="o"&gt;||=&lt;/span&gt; &lt;span class="k"&gt;begin&lt;/span&gt;
      &lt;span class="n"&gt;creds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;application&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;htpbe&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
      &lt;span class="n"&gt;api_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:api_key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"HTPBE_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="s2"&gt;"HTPBE api_key is not configured (credentials.htpbe.api_key or ENV['HTPBE_API_KEY'])"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blank?&lt;/span&gt;

      &lt;span class="no"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="ss"&gt;api_key: &lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;base_url: &lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:base_url&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;"https://api.htpbe.tech/v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;timeout_seconds: &lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:timeout_seconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading from &lt;code&gt;ENV&lt;/code&gt; as a fallback keeps the same code working in CI and on platforms that prefer environment variables. Either way the key never lands in source control as a bare literal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The Result Struct
&lt;/h2&gt;

&lt;p&gt;Model the &lt;code&gt;GET /result/{id}&lt;/code&gt; response as an immutable &lt;code&gt;Data&lt;/code&gt; class. Ruby 3.2’s &lt;code&gt;Data.define&lt;/code&gt; gives you a frozen value object with keyword initialization — the right shape for a response you parse once and never mutate. Map only the fields you branch on; the API returns more, and ignoring the rest means a newly added field never breaks your parser.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/services/htpbe/result.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Htpbe&lt;/span&gt;
  &lt;span class="no"&gt;Result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;:filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;:status&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="ss"&gt;:status_reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                &lt;span class="c1"&gt;# present only when status == "inconclusive"&lt;/span&gt;
    &lt;span class="ss"&gt;:origin_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;:origin_software&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;:modification_confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;# "certain" | "high" | "none" | nil&lt;/span&gt;
    &lt;span class="ss"&gt;:creator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;:producer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;:signature_removed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;:modifications_after_signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;:has_incremental_updates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;:update_chain_length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;:modification_markers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;# array of stable HTPBE_* ids&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"origin"&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="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="ss"&gt;filename: &lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"filename"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="ss"&gt;status_reason: &lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"status_reason"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="ss"&gt;origin_type: &lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="ss"&gt;origin_software: &lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"software"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="ss"&gt;modification_confidence: &lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"modification_confidence"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="ss"&gt;creator: &lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"creator"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="ss"&gt;producer: &lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"producer"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="ss"&gt;signature_removed: &lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"signature_removed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="ss"&gt;modifications_after_signature: &lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"modifications_after_signature"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="ss"&gt;has_incremental_updates: &lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"has_incremental_updates"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="ss"&gt;update_chain_length: &lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"update_chain_length"&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="ss"&gt;modification_markers: &lt;/span&gt;&lt;span class="no"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"modification_markers"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;intact?&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"intact"&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;modified?&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"modified"&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;inconclusive?&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"inconclusive"&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two fields deserve a closer look. &lt;code&gt;status_reason&lt;/code&gt; is populated only when &lt;code&gt;status&lt;/code&gt; is &lt;code&gt;inconclusive&lt;/code&gt;, and it carries one of several values — &lt;code&gt;consumer_software_origin&lt;/code&gt;, &lt;code&gt;online_editor_origin&lt;/code&gt;, &lt;code&gt;scanned_document&lt;/code&gt;, and a few more. The difference matters: a scanned document is benign for a user-submitted handwritten form, but a &lt;code&gt;consumer_software_origin&lt;/code&gt; on something that claims to be a payslip is the kind of origin you would not expect from a real payroll system — that class covers both consumer apps and freely available HTML-to-PDF renderers, so it is a strong signal to route for review. Branch on the specific reason, not just on the top-level &lt;code&gt;inconclusive&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;modification_markers&lt;/code&gt; returns stable, machine-readable ids prefixed &lt;code&gt;HTPBE_&lt;/code&gt; — for example &lt;code&gt;HTPBE_SIGNATURE_REMOVED&lt;/code&gt;, &lt;code&gt;HTPBE_DATES_DISAGREE&lt;/code&gt;, &lt;code&gt;HTPBE_POST_SIGNATURE_EDIT&lt;/code&gt;, and &lt;code&gt;HTPBE_MULTIPLE_REVISION_LAYERS&lt;/code&gt;. Branch your integration logic on the id; render the human-readable label from the dictionary published on &lt;a href="https://htpbe.tech/how" rel="noopener noreferrer"&gt;htpbe.tech/how&lt;/a&gt;. These ids are part of the public contract and never change once shipped. The API does not return a numeric risk score — the verdict plus the named markers are the whole signal, by design, so there is no threshold to tune on your side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: A Typed Error
&lt;/h2&gt;

&lt;p&gt;A 401 means your key is wrong; a 402 means the credit pool is dry; a 500 is transient. Both the retry layer and your business logic need to branch on the status code, so wrap every non-success response in one error type that carries it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/services/htpbe/error.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Htpbe&lt;/span&gt;
  &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HtpbeError&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;StandardError&lt;/span&gt;
    &lt;span class="nb"&gt;attr_reader&lt;/span&gt; &lt;span class="ss"&gt;:status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:retry_after&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="ss"&gt;code: &lt;/span&gt;&lt;span class="s2"&gt;"unknown"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;retry_after: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="vi"&gt;@status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;
      &lt;span class="vi"&gt;@code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;
      &lt;span class="vi"&gt;@retry_after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;retry_after&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="c1"&gt;# Only 5xx and 429 are transient. Every other 4xx is permanent —&lt;/span&gt;
    &lt;span class="c1"&gt;# retrying it burns latency and, for 402, can never succeed until&lt;/span&gt;
    &lt;span class="c1"&gt;# the account is topped up.&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;retryable?&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 5: The Service Object
&lt;/h2&gt;

&lt;p&gt;Here is the complete client. It is a plain service object — instantiate it once and reuse it, or resolve it per request. It builds a Faraday connection with the base URL, the &lt;code&gt;Authorization&lt;/code&gt; header, and a JSON request/response middleware, exposes one public method &lt;code&gt;verify&lt;/code&gt;, and converts every non-success response into an &lt;code&gt;HtpbeError&lt;/code&gt; in one place.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/services/htpbe/client.rb&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"faraday"&lt;/span&gt;

&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Htpbe&lt;/span&gt;
  &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;
    &lt;span class="no"&gt;MAX_RESULT_POLLS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Htpbe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="vi"&gt;@config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;
      &lt;span class="vi"&gt;@conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Faraday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;url: &lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;base_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt; &lt;span class="ss"&gt;:json&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;response&lt;/span&gt; &lt;span class="ss"&gt;:json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;content_type: &lt;/span&gt;&lt;span class="sr"&gt;/\bjson$/&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Bearer &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;api_key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Accept"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"application/json"&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout_seconds&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="c1"&gt;# Submits a PDF URL and returns a parsed Result. The two steps are kept&lt;/span&gt;
    &lt;span class="c1"&gt;# separate on purpose: POST /analyze is the billable, job-creating call;&lt;/span&gt;
    &lt;span class="c1"&gt;# GET /result/{id} is a free read.&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify&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="ss"&gt;original_filename: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;submit_analysis&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;original_filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;fetch_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="kp"&gt;private&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;submit_analysis&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;original_filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;url: &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;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:original_filename&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;original_filename&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;original_filename&lt;/span&gt;

      &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;with_retries&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="vi"&gt;@conn&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="s2"&gt;"analyze"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="n"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;success?&lt;/span&gt;

      &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;HtpbeError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"analyze response missing id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;code: &lt;/span&gt;&lt;span class="s2"&gt;"bad_response"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;empty?&lt;/span&gt;

      &lt;span class="nb"&gt;id&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="c1"&gt;# POST /analyze runs the analysis synchronously, so the result is normally&lt;/span&gt;
      &lt;span class="c1"&gt;# ready on the first GET. The bounded poll below is defensive: it tolerates&lt;/span&gt;
      &lt;span class="c1"&gt;# a brief replication lag and re-reads on a transient 404 before giving up.&lt;/span&gt;
      &lt;span class="n"&gt;last_error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;

      &lt;span class="no"&gt;MAX_RESULT_POLLS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;times&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;with_retries&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="vi"&gt;@conn&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="s2"&gt;"result/&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;success?&lt;/span&gt;

        &lt;span class="n"&gt;last_error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;error_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# Only a 404 is worth re-reading (the row may not be visible yet).&lt;/span&gt;
        &lt;span class="c1"&gt;# Every other error is terminal — surface it without burning attempts.&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;last_error&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;

        &lt;span class="nb"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;last_error&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="no"&gt;HtpbeError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"result not ready after polling"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="mi"&gt;504&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;code: &lt;/span&gt;&lt;span class="s2"&gt;"result_timeout"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="c1"&gt;# Retry transient failures (5xx, 429, connection errors) with backoff.&lt;/span&gt;
    &lt;span class="c1"&gt;# Permanent 4xx codes short-circuit immediately — a 402 can never succeed&lt;/span&gt;
    &lt;span class="c1"&gt;# until the account is topped up.&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;with_retries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;max: &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
      &lt;span class="k"&gt;begin&lt;/span&gt;
        &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;success?&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;transient_status?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt;
          &lt;span class="nb"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry_delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
          &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;Faraday&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"retrying transient &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;end&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt;
      &lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;Faraday&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;Faraday&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ConnectionFailed&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;HtpbeError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;code: &lt;/span&gt;&lt;span class="s2"&gt;"network_error"&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;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt;

        &lt;span class="nb"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;retry&lt;/span&gt;
      &lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;Faraday&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Error&lt;/span&gt;
        &lt;span class="k"&gt;retry&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transient_status?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;retry_delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parse_retry_after&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"retry-after"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
      &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# 1s, 2s, 4s&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&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;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;raise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&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;error_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_a?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
      &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;"unknown"&lt;/span&gt;
      &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parse_retry_after&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"retry-after"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

      &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;
        &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="s2"&gt;"Bad request: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;"check the url field"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="s2"&gt;"Invalid API key — check credentials.htpbe.api_key"&lt;/span&gt;
        &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="mi"&gt;402&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="s2"&gt;"No credits available for this key — top up or subscribe"&lt;/span&gt;
        &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="s2"&gt;"Test key sent to a live URL, or vice versa"&lt;/span&gt;
        &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="mi"&gt;413&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="s2"&gt;"PDF exceeds the 10 MB size limit"&lt;/span&gt;
        &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="mi"&gt;422&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="s2"&gt;"The URL did not return a valid PDF file"&lt;/span&gt;
        &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="s2"&gt;"Server is at analysis capacity — honour Retry-After before retrying"&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;"HTPBE error &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="no"&gt;HtpbeError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;code: &lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;retry_after: &lt;/span&gt;&lt;span class="n"&gt;retry_after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="c1"&gt;# RFC 7231 Retry-After: delay-seconds or an HTTP-date. Clamped to [1, 600]&lt;/span&gt;
    &lt;span class="c1"&gt;# so a hostile or misconfigured value cannot stall a worker indefinitely.&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse_retry_after&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blank?&lt;/span&gt;

      &lt;span class="n"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/\A\d+\z/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_i&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;
          &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;httpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="no"&gt;Time&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="nf"&gt;round&lt;/span&gt; &lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;end&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nil?&lt;/span&gt;

      &lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clamp&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="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two status codes deserve explicit handling in your own code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;402&lt;/code&gt; (Payment Required)&lt;/strong&gt; — the key has no credit source left. Credits are universal: a subscription’s monthly quota, a one-time top-up batch, and the welcome credits all draw from one pool. A 402 means all three are exhausted (or there is no active plan on a live key). &lt;code&gt;HtpbeError#retryable?&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt; for it — surface it to your billing path rather than retrying, because retrying fails identically until the account is topped up at &lt;a href="https://htpbe.tech/pricing" rel="noopener noreferrer"&gt;the pricing page&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;429&lt;/code&gt; (Too Many Requests)&lt;/strong&gt; — this is server-wide concurrency, not per-key rate limiting. The response carries a &lt;code&gt;Retry-After&lt;/code&gt; header, which &lt;code&gt;parse_retry_after&lt;/code&gt; reads (both delay-seconds and HTTP-date forms, clamped to &lt;code&gt;[1, 600]&lt;/code&gt;) so the retry loop waits the server-suggested interval before falling back to exponential backoff.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 6: The Verdict Gate
&lt;/h2&gt;

&lt;p&gt;The client returns facts. Turning those facts into an accept / reject / review decision is a policy choice that depends on what the document claims to be. A bank statement, a payslip, or a diploma claims institutional origin, so anything other than &lt;code&gt;intact&lt;/code&gt; should stop the automated path. A user-generated form is held to a looser standard. Ruby’s pattern matching makes the mapping readable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/services/htpbe/document_gate.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Htpbe&lt;/span&gt;
  &lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;DocumentGate&lt;/span&gt;
    &lt;span class="c1"&gt;# Maps a verdict to a decision for documents that claim institutional&lt;/span&gt;
    &lt;span class="c1"&gt;# origin (bank statements, payslips, diplomas). For these, "inconclusive"&lt;/span&gt;
    &lt;span class="c1"&gt;# is treated as strictly as "modified": a document that should have come&lt;/span&gt;
    &lt;span class="c1"&gt;# from a bank's own system but looks like it was built in Word does not&lt;/span&gt;
    &lt;span class="c1"&gt;# get the benefit of the doubt.&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;for_institutional&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="k"&gt;case&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;status&lt;/span&gt;
      &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="s2"&gt;"modified"&lt;/span&gt;      &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="ss"&gt;:reject&lt;/span&gt;
      &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="s2"&gt;"inconclusive"&lt;/span&gt;  &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="ss"&gt;:review&lt;/span&gt;
      &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="s2"&gt;"intact"&lt;/span&gt;        &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="ss"&gt;:accept&lt;/span&gt;
      &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="ss"&gt;:review&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;code&gt;inconclusive&lt;/code&gt; result should not be auto-accepted — it typically indicates the file came from consumer software, an online editor, an HTML renderer, or a scanner rather than an institutional generator. That is a signal to route for review, not proof of tampering. For a deeper explanation, see &lt;a href="https://htpbe.tech/blog/what-inconclusive-means-pdf-verification-results" rel="noopener noreferrer"&gt;what “inconclusive” really means&lt;/a&gt;. For documents that claim institutional origin, treat &lt;code&gt;inconclusive&lt;/code&gt; with the same caution as &lt;code&gt;modified&lt;/code&gt;: do not accept automatically, route to a human reviewer. Inverting that policy — treating &lt;code&gt;inconclusive&lt;/code&gt; as a pass — is the single most common integration mistake, because it hands an automatic accept to exactly the consumer-software-built documents a bank statement should never be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: An ActiveJob, Not a Blocking Controller
&lt;/h2&gt;

&lt;p&gt;Analysis takes 2–5 seconds for a typical document. Blocking a Rails request handler for that long is fine for an internal admin tool, but for any user-facing upload flow you want to return immediately and resolve the verdict on a worker. &lt;code&gt;ActiveJob&lt;/code&gt; is the idiomatic place. The controller stores the upload on a private bucket, records a pending &lt;code&gt;Document&lt;/code&gt;, and enqueues; the job mints a short-lived URL, calls the API, and routes on the verdict.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/jobs/verify_document_job.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;VerifyDocumentJob&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationJob&lt;/span&gt;
  &lt;span class="n"&gt;queue_as&lt;/span&gt; &lt;span class="ss"&gt;:verification&lt;/span&gt;

  &lt;span class="c1"&gt;# Retry on transient failures with growing delay; ActiveJob handles the backoff.&lt;/span&gt;
  &lt;span class="n"&gt;retry_on&lt;/span&gt; &lt;span class="no"&gt;Htpbe&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;HtpbeError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;wait: :polynomially_longer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;attempts: &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="c1"&gt;# Out of attempts. If it was a billing failure, retrying never helps —&lt;/span&gt;
    &lt;span class="c1"&gt;# flag the document for an operator rather than silently dropping it.&lt;/span&gt;
    &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
      &lt;span class="no"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;verdict: &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;review_reason: &lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;document&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Idempotency: a re-queued job from a worker crash must not double-charge credits.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt;

    &lt;span class="c1"&gt;# A 5-minute presigned URL keeps the bucket private; the file is reachable&lt;/span&gt;
    &lt;span class="c1"&gt;# only for the few seconds the analysis takes.&lt;/span&gt;
    &lt;span class="n"&gt;pdf_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;expires_in: &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;begin&lt;/span&gt;
      &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Htpbe&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="ss"&gt;pdf_url: &lt;/span&gt;&lt;span class="n"&gt;pdf_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;original_filename: &lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;original_filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;Htpbe&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;HtpbeError&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
      &lt;span class="c1"&gt;# 402 (no credits) is permanent — stop retrying immediately and alert billing.&lt;/span&gt;
      &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retryable?&lt;/span&gt;

      &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;verdict: &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;review_reason: &lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"HTPBE verification failed for #&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="n"&gt;apply_verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&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="k"&gt;end&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;apply_verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&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="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Htpbe&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DocumentGate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;for_institutional&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="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;htpbe_check_id: &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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;verdict: &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;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;status_reason: &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;status_reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;producer: &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;producer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;modification_markers: &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;modification_markers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;requires_review: &lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="ss"&gt;:review&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;
    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="ss"&gt;:reject&lt;/span&gt;
      &lt;span class="no"&gt;DocumentMailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;deliver_later&lt;/span&gt;
    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="ss"&gt;:review&lt;/span&gt;
      &lt;span class="no"&gt;ReviewQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="ss"&gt;:accept&lt;/span&gt;
      &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;approve!&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enqueue it from the controller after storing the upload, and return without waiting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/controllers/documents_controller.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DocumentsController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
    &lt;span class="n"&gt;document&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;file: &lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:file&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="ss"&gt;original_filename: &lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:file&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;original_filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;verdict: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="no"&gt;VerifyDocumentJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"verification_pending"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="ss"&gt;status: :accepted&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few production details worth getting right (the broader patterns are covered in the &lt;a href="https://htpbe.tech/blog/batch-pdf-verification-queue-async" rel="noopener noreferrer"&gt;async queue guide&lt;/a&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency.&lt;/strong&gt; The job returns early when &lt;code&gt;document.verdict&lt;/code&gt; is already set, so a re-queue after a worker crash never double-charges credits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dedicated queue.&lt;/strong&gt; Run verification on its own queue (&lt;code&gt;queue_as :verification&lt;/code&gt;) so a backlog of PDFs cannot starve other jobs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permanent vs. transient.&lt;/strong&gt; &lt;code&gt;retry_on&lt;/code&gt; handles 5xx and 429 with backoff; the &lt;code&gt;raise e if e.retryable?&lt;/code&gt; line lets those bubble up, while permanent codes (401, 402, 422) are caught and recorded so the document does not loop forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 8: Giving the API a Reachable URL
&lt;/h2&gt;

&lt;p&gt;The API does not accept file uploads — it downloads the PDF from a URL you supply, so the file must be publicly reachable for the few seconds the analysis takes. The cleanest pattern is a short-lived presigned URL from your object store. With Active Storage on S3, GCS, or Cloudflare R2, &lt;code&gt;document.file.url(expires_in: 5.minutes)&lt;/code&gt; mints exactly that: the bucket stays private, the link expires in minutes, and passing &lt;code&gt;original_filename&lt;/code&gt; keeps the audit trail readable instead of showing an opaque storage key.&lt;/p&gt;

&lt;p&gt;One security note: the API fetches whatever URL you give it, so if a URL ever comes from untrusted input (a user-pasted link, a webhook payload), validate that it resolves to a public host first — reject &lt;code&gt;localhost&lt;/code&gt;, &lt;code&gt;169.254.169.254&lt;/code&gt; (cloud metadata), and the RFC 1918 private ranges (&lt;code&gt;10.0.0.0/8&lt;/code&gt;, &lt;code&gt;172.16.0.0/12&lt;/code&gt;, &lt;code&gt;192.168.0.0/16&lt;/code&gt;) to close the SSRF surface. When you mint the URL yourself from a private bucket the risk is minimal, but the validation belongs in the request flow either way. A presigned URL is also a bearer token — anyone who sees it inside the five-minute window can fetch the PDF, so do not log full URLs and scope the signing policy to read the one object and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 9: Testing Without Burning Quota
&lt;/h2&gt;

&lt;p&gt;Every plan includes a test API key (prefix &lt;code&gt;htpbe_test_&lt;/code&gt;) that accepts only mock URLs of the form &lt;code&gt;https://api.htpbe.tech/v1/test/{filename}.pdf&lt;/code&gt; and returns deterministic responses — like Stripe test cards, with no quota cost. For unit specs you do not even need the network: stub the two endpoints with WebMock and assert that your gate branches correctly. Here is an RSpec job spec covering all three verdicts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# spec/jobs/verify_document_job_spec.rb&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"rails_helper"&lt;/span&gt;

&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt; &lt;span class="no"&gt;VerifyDocumentJob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :job&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;let&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:document&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;verdict: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;original_filename: &lt;/span&gt;&lt;span class="s2"&gt;"statement.pdf"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;allow_any_instance_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;receive_message_chain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;and_return&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"https://files.example.test/doc.pdf"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stub_flow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="ss"&gt;markers: &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="ss"&gt;status_reason: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;signature_removed: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;stub_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"https://api.htpbe.tech/v1/analyze"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_return&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;headers: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"application/json"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="ss"&gt;body: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="s2"&gt;"00000000-0000-4000-8000-000000000001"&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;to_json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;stub_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:get&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"https://api.htpbe.tech/v1/result/00000000-0000-4000-8000-000000000001"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_return&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;headers: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"application/json"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="ss"&gt;body: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="s2"&gt;"00000000-0000-4000-8000-000000000001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="ss"&gt;filename: &lt;/span&gt;&lt;span class="s2"&gt;"statement.pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="ss"&gt;status_reason: &lt;/span&gt;&lt;span class="n"&gt;status_reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="ss"&gt;origin: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;type: &lt;/span&gt;&lt;span class="s2"&gt;"institutional"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;software: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="ss"&gt;signature_removed: &lt;/span&gt;&lt;span class="n"&gt;signature_removed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="ss"&gt;modification_markers: &lt;/span&gt;&lt;span class="n"&gt;markers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;to_json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"accepts an intact document"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;stub_flow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"intact"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"intact"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requires_review&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;be&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"rejects a modified document and surfaces the markers"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;stub_flow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"modified"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;markers: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"HTPBE_SIGNATURE_REMOVED"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="ss"&gt;signature_removed: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"modified"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;modification_markers&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="kp"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"HTPBE_SIGNATURE_REMOVED"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"routes an inconclusive document to review, not accept"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;stub_flow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"inconclusive"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;status_reason: &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="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"inconclusive"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requires_review&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;be&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an end-to-end smoke test against the live mock fixtures, point a request spec at the real test key and the real test URLs. Useful fixtures: &lt;code&gt;clean.pdf&lt;/code&gt; → &lt;code&gt;intact&lt;/code&gt;, &lt;code&gt;signature-removed.pdf&lt;/code&gt; → &lt;code&gt;modified&lt;/code&gt;, &lt;code&gt;dates-mismatch.pdf&lt;/code&gt; → &lt;code&gt;modified&lt;/code&gt;, &lt;code&gt;modified-critical.pdf&lt;/code&gt; → &lt;code&gt;modified&lt;/code&gt;, and &lt;code&gt;inconclusive.pdf&lt;/code&gt; → &lt;code&gt;inconclusive&lt;/code&gt;. A direct client spec is the smallest version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# spec/services/htpbe/client_spec.rb&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"rails_helper"&lt;/span&gt;

&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt; &lt;span class="no"&gt;Htpbe&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Client&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="c1"&gt;# Requires HTPBE_TEST_API_KEY in the environment — test keys consume no quota.&lt;/span&gt;
  &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;described_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"returns inconclusive for a consumer-software fixture"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:external&lt;/span&gt; &lt;span class="k"&gt;do&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;pdf_url: &lt;/span&gt;&lt;span class="s2"&gt;"https://api.htpbe.tech/v1/test/inconclusive.pdf"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;expect&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;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"inconclusive"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expect&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;status_reason&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;not_to&lt;/span&gt; &lt;span class="n"&gt;be_nil&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Htpbe&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DocumentGate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;for_institutional&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;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:review&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"flags a stripped signature as modified"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:external&lt;/span&gt; &lt;span class="k"&gt;do&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;pdf_url: &lt;/span&gt;&lt;span class="s2"&gt;"https://api.htpbe.tech/v1/test/signature-removed.pdf"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;expect&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;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"modified"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expect&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;signature_removed&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;be&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Htpbe&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DocumentGate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;for_institutional&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;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep test and live keys in separate credentials environments and never commit either. For audit dashboards, &lt;code&gt;GET /v1/checks&lt;/code&gt; returns a paginated list of every result for your key — filter by &lt;code&gt;status&lt;/code&gt; and &lt;code&gt;limit&lt;/code&gt; (&lt;code&gt;/checks?status=modified&amp;amp;limit=50&lt;/code&gt;, same &lt;code&gt;Authorization&lt;/code&gt; header). When you reach your monthly quota, further requests return &lt;code&gt;402&lt;/code&gt; until it resets — add a one-time credit pack or move to a higher tier, and handle the 402 so a quota boundary never silently drops a check.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Verdicts Mean
&lt;/h2&gt;

&lt;p&gt;The whole signal is three verdicts and a list of named markers. Encoding them correctly in your &lt;code&gt;DocumentGate&lt;/code&gt; matters more than any other choice in the integration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;intact&lt;/code&gt;&lt;/strong&gt; — no post-creation modification was detected and the origin looks institutional. Safe to accept on the automated path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;modified&lt;/code&gt;&lt;/strong&gt; — forensic evidence of an edit after the document was created. The &lt;code&gt;modification_markers&lt;/code&gt; array names the signal: &lt;code&gt;HTPBE_DATES_DISAGREE&lt;/code&gt; for inconsistent internal timestamps, &lt;code&gt;HTPBE_SIGNATURE_REMOVED&lt;/code&gt; for a stripped digital signature, &lt;code&gt;HTPBE_POST_SIGNATURE_EDIT&lt;/code&gt; for changes made after signing, &lt;code&gt;HTPBE_MULTIPLE_REVISION_LAYERS&lt;/code&gt; for a document saved repeatedly after creation. Reject, or route to fraud review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;inconclusive&lt;/code&gt;&lt;/strong&gt; — the document was built with consumer software, an online editor, an HTML renderer, or a scanner, so there is no institutional “original” to verify integrity against. This is &lt;strong&gt;not&lt;/strong&gt; a failure and &lt;strong&gt;not&lt;/strong&gt; a clean pass — it is a routing signal. For a document that should have come from an institution (a bank statement, a payslip), &lt;code&gt;inconclusive&lt;/code&gt; means it did not, which is exactly why &lt;code&gt;DocumentGate.for_institutional&lt;/code&gt; routes it to a human reviewer.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Structural analysis has honest limits, and a Rails service making automated decisions should encode them rather than overstate the verdict:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Content fabricated in one pass.&lt;/strong&gt; If someone opens Word, types a false salary, and exports once, the file was never modified after creation — it is structurally consistent. The fraud happened at authorship, not at the byte level. This is exactly why a payslip from a consumer tool tends to return &lt;code&gt;inconclusive&lt;/code&gt; rather than &lt;code&gt;intact&lt;/code&gt;: the analysis cannot vouch for a document anyone could have produced from scratch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Born-synthetic forgeries.&lt;/strong&gt; A fake document generated programmatically with a valid-looking account number and a real logo — never derived from a genuine original — has no post-creation edit to detect. Catching that is a content-verification problem (does this account number exist, does this employer match payroll records), a different product category from structural tamper detection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documents rebuilt from scratch in the original’s software.&lt;/strong&gt; A determined attacker who recreates a document in the same institutional tool and matches the metadata leaves few structural signals. This is rare and high-effort, but possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encrypted or password-protected PDFs.&lt;/strong&gt; The service cannot parse a file it cannot open; remove the password before submitting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These limits are why structural tamper detection works as one layer in a fraud-detection stack, not the whole stack. Pair the structural verdict with domain checks — amount validation, account-number lookups, sender authentication, and your KYC or OCR provider — for a layered defense. The structural layer answers a question identity verification cannot: was this file edited after it was issued? See &lt;a href="https://htpbe.tech/blog/pdf-fraud-prevention-best-practices" rel="noopener noreferrer"&gt;PDF Fraud Prevention Best Practices&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decisions Before You Ship
&lt;/h2&gt;

&lt;p&gt;The integration surface is intentionally small: one POST, one GET, three verdicts, the typed error above. The complexity lives on the Rails side, and two choices matter most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Where verification runs.&lt;/strong&gt; Synchronous inside the controller gives the caller an immediate decision but blocks for a few seconds; an &lt;code&gt;ActiveJob&lt;/code&gt; on a dedicated queue returns instantly and defers the verdict. Sync suits low-volume internal tools; async suits any user-facing upload flow, which is why the job is the default above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;inconclusive&lt;/code&gt; routing.&lt;/strong&gt; For documents that claim institutional origin (bank statements, diplomas, payslips), treat &lt;code&gt;inconclusive&lt;/code&gt; with the same caution as &lt;code&gt;modified&lt;/code&gt; and route to human review — that is what &lt;code&gt;DocumentGate.for_institutional&lt;/code&gt; encodes. For genuinely user-generated content it may be acceptable as-is, so you may want a second gate with a looser policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To start, &lt;a href="https://htpbe.tech/auth/signup" rel="noopener noreferrer"&gt;sign up for HTPBE&lt;/a&gt; — new accounts get five checks to try, then pay-per-check credits or a subscription (see &lt;a href="https://htpbe.tech/pricing" rel="noopener noreferrer"&gt;current pricing&lt;/a&gt;) — copy your test key, and run the curl call from Step 1. The &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;full API reference&lt;/a&gt; documents every response field, error code, and the marker dictionary the Rails client branches on.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>tutorial</category>
      <category>api</category>
      <category>fraud</category>
    </item>
    <item>
      <title>What Is a VAT ID? A Plain Guide</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Wed, 05 Aug 2026 09:00:49 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/what-is-a-vat-id-a-plain-guide-1e7l</link>
      <guid>https://dev.to/iurii_rogulia/what-is-a-vat-id-a-plain-guide-1e7l</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/what-is-a-vat-id" 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 a VAT ID (VAT Identification Number)?
&lt;/h1&gt;

&lt;p&gt;A VAT ID is the number a tax authority gives a business when it registers for value-added tax, and it identifies that business for VAT everywhere it trades. In the EU it is written as a two-letter country prefix followed by a country-specific string — &lt;code&gt;DE123456789&lt;/code&gt;, for example — and it shows up on invoices and in cross-border B2B transactions.&lt;/p&gt;

&lt;p&gt;This guide covers what a VAT ID actually is, what it is used for, how it is structured, and how to confirm one is real. The scope here is the EU-27 plus XI (Northern Ireland), which is the area VIES covers.&lt;/p&gt;

&lt;p&gt;If you just want to confirm a number right now, you can jump to checking whether a VAT ID is valid. Otherwise, start from the top.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a VAT ID actually is
&lt;/h2&gt;

&lt;p&gt;A VAT ID (VAT identification number) is issued by a national tax authority when a business registers for VAT. From that point on, the number is how the business is recognised in the VAT system: it goes on the invoices it issues, it is quoted when trading with other VAT-registered businesses, and it is the value other parties look up to confirm the business is registered.&lt;/p&gt;

&lt;p&gt;A VAT ID is tied to VAT specifically. It is not a general-purpose company identifier, and it is not the number your bank or a supplier's accounts-payable team would use for anything else.&lt;/p&gt;

&lt;h3&gt;
  
  
  VAT ID vs VAT registration number vs local tax number
&lt;/h3&gt;

&lt;p&gt;These three phrases cause most of the confusion, so to be direct: &lt;strong&gt;"VAT ID", "VAT number", and "VAT registration number" are the same thing.&lt;/strong&gt; Different documents and different countries use different wording, but they all refer to the one identifier a business uses for VAT.&lt;/p&gt;

&lt;p&gt;What a VAT ID is &lt;em&gt;not&lt;/em&gt; is a domestic tax number or a company registration number:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;local tax number&lt;/strong&gt; is what the domestic tax office uses to administer a business internally. In several countries it looks different from the VAT ID and is used for filings that have nothing to do with cross-border trade. It usually has no country prefix, so it will not validate as an EU VAT ID.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;company registration number&lt;/strong&gt; is issued by a company register (the equivalent of Companies House, the Handelsregister, and so on) when the entity is incorporated. It identifies the legal entity, not its VAT status.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A business can have all three, and they are typically different values. On an intra-EU invoice, the one that belongs there is the VAT ID with its country prefix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a VAT ID is used for
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Intra-EU B2B trade
&lt;/h3&gt;

&lt;p&gt;The VAT ID's main job is cross-border business-to-business trade inside the EU. When two VAT-registered businesses in different member states trade, they exchange VAT IDs so each side can establish that the other is a registered taxable person. That single fact changes how VAT is handled on the transaction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Invoicing
&lt;/h3&gt;

&lt;p&gt;A VAT ID belongs on VAT invoices. For intra-EU B2B supplies, both the supplier's and the customer's VAT IDs generally appear on the invoice — and the customer's number being present and valid is part of what supports the correct VAT treatment of the supply.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reverse charge
&lt;/h3&gt;

&lt;p&gt;When a valid buyer VAT ID is present on a cross-border B2B supply, the seller can often avoid charging local VAT — but the exact mechanism depends on what is sold. For goods, the sale can be an exempt (zero-rated) intra-Community supply, with the buyer accounting for VAT on the acquisition in their own country. For services, the place of supply generally shifts to the buyer, who accounts for the VAT under the reverse-charge mechanism. Either way, a valid buyer VAT ID is one of the conditions that has to hold — the full set of conditions is its own topic; see &lt;a href="https://vatnode.dev/blog/reverse-charge-vat-saas-implementation-checklist" rel="noopener noreferrer"&gt;how reverse charge works for cross-border B2B&lt;/a&gt;. The point for this guide is that the buyer's VAT ID is central to that treatment, which is exactly why sellers care whether it is valid.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a VAT ID is structured
&lt;/h2&gt;

&lt;p&gt;At a glance, an EU VAT ID is:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;[2-letter country prefix] + [up to ~12 alphanumeric characters]&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For example, a German VAT ID looks like &lt;code&gt;DE123456789&lt;/code&gt;: the &lt;code&gt;DE&lt;/code&gt; prefix, then the national portion. The national portion varies by country — some are all digits, some include letters, and lengths differ — so the "up to ~12 characters" is a rough envelope, not a single fixed pattern.&lt;/p&gt;

&lt;p&gt;Two prefixes catch people out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Greece uses &lt;code&gt;EL&lt;/code&gt;, not &lt;code&gt;GR&lt;/code&gt;.&lt;/strong&gt; The VAT system uses &lt;code&gt;EL&lt;/code&gt; even though the ISO country code is &lt;code&gt;GR&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Northern Ireland uses &lt;code&gt;XI&lt;/code&gt;.&lt;/strong&gt; Under the Northern Ireland arrangements (Windsor Framework), NI VAT IDs for goods carry the &lt;code&gt;XI&lt;/code&gt; prefix and are validatable through VIES.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Per-country patterns are worth a reference when you need them, but there is no reason to memorise 27 of them. The full breakdown is here: &lt;a href="https://vatnode.dev/guides/eu-vat-number-formats" rel="noopener noreferrer"&gt;VAT number formats for all 27 EU countries&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  VAT ID vs other identifiers people confuse it with
&lt;/h2&gt;

&lt;p&gt;A VAT ID gets mixed up with several other business numbers. Quick disambiguation, staying strictly on VAT:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It is &lt;strong&gt;not an EORI number&lt;/strong&gt;. An EORI number identifies businesses for customs, not VAT.&lt;/li&gt;
&lt;li&gt;It is &lt;strong&gt;not an IOSS or OSS registration&lt;/strong&gt;. Those are separate VAT special-scheme registrations (IOSS even issues its own &lt;code&gt;IM&lt;/code&gt;-prefixed number), distinct from the VAT ID itself.&lt;/li&gt;
&lt;li&gt;It is &lt;strong&gt;not a company registration number&lt;/strong&gt;. That comes from the company register and identifies the legal entity, not its VAT status.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a number has a two-letter country prefix and validates in VIES, it is a VAT ID. The others do not behave that way.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check whether a VAT ID is valid
&lt;/h2&gt;

&lt;p&gt;For EU VAT IDs, validity is confirmed against &lt;strong&gt;VIES&lt;/strong&gt;, the European Commission's system that routes a query to the relevant national VAT database and reports back whether the number is registered; some numbers can also be cross-checked against national registries.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A valid &lt;strong&gt;format&lt;/strong&gt; is not the same as a valid &lt;strong&gt;number&lt;/strong&gt;. A string can match a country's pattern&lt;br&gt;
  perfectly and still not be a registered, active VAT ID — the difference between the two, and the&lt;br&gt;
  third state most people miss, is spelled out in &lt;a href="https://vatnode.dev/blog/vat-id-format-vs-valid" rel="noopener noreferrer"&gt;VAT ID format vs&lt;br&gt;
  valid&lt;/a&gt;. Only a live check against VIES or a national source tells&lt;br&gt;
  you whether the number actually exists and is active — never infer registration from the shape of&lt;br&gt;
  the string alone.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For a single number, the fastest route is a &lt;a href="https://vatnode.dev/check" rel="noopener noreferrer"&gt;free VAT number checker&lt;/a&gt; — paste the VAT ID in and you get the live result. If you want to understand what sits behind that result, including why VIES sometimes can't answer, read &lt;a href="https://vatnode.dev/guides/what-is-vies" rel="noopener noreferrer"&gt;what VIES is and how it works&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a business needs to validate other companies' VAT IDs
&lt;/h2&gt;

&lt;p&gt;Checking one number by hand is fine. Checking VAT IDs on every signup, every invoice, or across an existing customer base is not — that is where you validate programmatically.&lt;/p&gt;

&lt;p&gt;At that point you call a validation service from your own application: a &lt;a href="https://vatnode.dev/vies-api" rel="noopener noreferrer"&gt;VIES API&lt;/a&gt; for EU-wide lookups, or a &lt;a href="https://vatnode.dev/vat-api" rel="noopener noreferrer"&gt;per-country VAT validation API&lt;/a&gt; when you need a specific national source. The other half of the job is keeping evidence: storing the result of each check — when it ran, what came back, and which source answered — so you can show your work later. Auditors on intra-EU supplies may ask for it; a lightweight record such as &lt;a href="https://vatnode.dev/blog/vies-consultation-number-explained" rel="noopener noreferrer"&gt;the VIES consultation number&lt;/a&gt; alongside your own logs is the kind of trail that holds up.&lt;/p&gt;

&lt;p&gt;If you're building this in, the &lt;a href="https://vatnode.dev/docs" rel="noopener noreferrer"&gt;API documentation&lt;/a&gt; covers the request and response shape, and &lt;a href="https://vatnode.dev/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; has the plan limits. You can &lt;a href="https://vatnode.dev/register" rel="noopener noreferrer"&gt;get a free API key&lt;/a&gt; and validate against VIES from your own code.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  What is a VAT ID?
&lt;/h3&gt;

&lt;p&gt;A VAT ID is the number a tax authority assigns to a business when it registers for value-added tax. It identifies that business for VAT purposes and is used on invoices and in intra-EU trade. Across the EU it takes the form of a two-letter country prefix followed by a country-specific string of characters.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a VAT ID the same as a VAT number?
&lt;/h3&gt;

&lt;p&gt;Yes. "VAT ID", "VAT number", and "VAT registration number" all refer to the same identifier — the number that identifies a business for VAT. The different names are just wording; they point at the same value on your invoices and in VIES.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between a VAT ID and a tax number?
&lt;/h3&gt;

&lt;p&gt;A VAT ID identifies a business specifically for VAT and, in the EU, carries a country prefix so it works across borders. A local tax number (or company registration number) identifies the business to the domestic tax office or company register for other purposes. They are issued for different reasons and are usually different values, so they are not interchangeable on an intra-EU invoice.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I check if a VAT ID is valid?
&lt;/h3&gt;

&lt;p&gt;For EU VAT IDs, validity is confirmed against VIES, the European Commission's system that routes the query to the relevant national database; some numbers are cross-checked against national registries. Note that a correct format is not the same thing as a registered, active number — only a live check confirms that. You can run a one-off check with a free VAT number checker.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a VAT ID to sell in the EU?
&lt;/h3&gt;

&lt;p&gt;It depends on your situation — where you're established, what and to whom you sell, and whether you cross a registration threshold. Some businesses are required to register for VAT and some are not, and the rules differ by member state. This is not tax advice; check your national tax authority's guidance or a qualified advisor for your specific case.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the format of an EU VAT ID?
&lt;/h3&gt;

&lt;p&gt;An EU VAT ID is a two-letter country prefix followed by up to roughly 12 alphanumeric characters, with the exact pattern set by each country. The prefix is the country code — for example Greece uses EL rather than GR, and Northern Ireland uses XI. A German VAT ID looks like DE123456789.&lt;/p&gt;

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

&lt;p&gt;Confirm a single VAT ID against VIES in seconds with the &lt;a href="https://vatnode.dev/check" rel="noopener noreferrer"&gt;free VAT number checker&lt;/a&gt;. Validating VAT IDs in your app instead? &lt;a href="https://vatnode.dev/register" rel="noopener noreferrer"&gt;Get a free API key&lt;/a&gt; and call the &lt;a href="https://vatnode.dev/vies-api" rel="noopener noreferrer"&gt;VIES API&lt;/a&gt; from your own code — free plan, 100 requests/month.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>tax</category>
      <category>tutorial</category>
      <category>europe</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your Software Works, But Nobody Can Safely Change It</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 03 Aug 2026 10:00:46 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/your-software-works-but-nobody-can-safely-change-it-54ll</link>
      <guid>https://dev.to/iurii_rogulia/your-software-works-but-nobody-can-safely-change-it-54ll</guid>
      <description>&lt;p&gt;Most business owners describe the problem the same way. "The system works. Customers use it every day. But every time we ask for a change, it takes forever, costs more than we expected, and something else breaks that we didn't touch."&lt;/p&gt;

&lt;p&gt;That's the real problem. Not that the software is old. That nobody can safely change it anymore.&lt;/p&gt;

&lt;p&gt;Those are two completely different situations, and confusing them costs companies a lot of money. Old software that works and can be changed safely is an asset — leave it alone. Software you're afraid to touch is a liability, and it stays a liability whether you rebuild it or not. The question that matters isn't "is this old?" It's "can we still move?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Age Is Not the Reason
&lt;/h2&gt;

&lt;p&gt;I want to be blunt about this, because the software industry has a financial interest in the opposite message.&lt;/p&gt;

&lt;p&gt;Software being old is not a reason to replace it. There are systems quietly running businesses today on technology that was current a decade or two ago, and they run fine. They process orders, send invoices, handle payments, and nobody thinks about them — which is exactly what good infrastructure is supposed to do. Replacing one of those because it's "outdated" is like demolishing a warehouse because the architecture is unfashionable. It's still keeping the rain off your inventory.&lt;/p&gt;

&lt;p&gt;The pressure to modernize usually comes from one of a few places, and most of them are bad reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A developer doesn't enjoy working in it.&lt;/strong&gt; Legitimate feeling, not a legitimate business case. "I'd build it differently" is an aesthetic preference, not an economic argument.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's not the newest technology.&lt;/strong&gt; Newer is not the same as better for your situation. The newest stack still has to be built, tested, and learned by whoever maintains it next.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A vendor told you it's "legacy."&lt;/strong&gt; Vendors sell replacements. That's the whole business. When the person diagnosing the problem also sells the cure, get a second opinion.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these mean your software is holding you back. So what does?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Symptom: Change Has Become Dangerous
&lt;/h2&gt;

&lt;p&gt;Here's how you actually know something is wrong. It isn't the age of the code — it's what happens when you try to change it.&lt;/p&gt;

&lt;p&gt;Watch for these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every change takes far longer than it should, and the estimates keep growing.&lt;/strong&gt; A small request — a new field on a form, a change to how a discount is calculated — turns into weeks. And the number goes up each time, not down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Changing one thing breaks something unrelated.&lt;/strong&gt; You update the checkout page and the invoicing breaks. Nobody can explain why those two things are connected, which is precisely the problem: the connections aren't understood anymore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only one person can touch it — and you're afraid of losing them.&lt;/strong&gt; If a single developer is the only human who understands how the system works, you don't have software. You have a hostage situation, and the ransom is their salary and their continued good mood.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nobody can safely say what will happen if you deploy a change.&lt;/strong&gt; In a healthy system, you can predict the effect of a change and verify it before it reaches customers. When every deployment is a held breath, the system has stopped being controllable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice what all four have in common. None of them is about the technology being old. They're about the system having become &lt;em&gt;unpredictable and unsafe to modify&lt;/em&gt;. That's the condition worth spending money to fix. Age is a coincidence; danger is the diagnosis.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Rewriting Is the Wrong Instinct
&lt;/h2&gt;

&lt;p&gt;When change becomes dangerous, the instinct is to start over. Throw it all out, build it clean, no more inherited mess. It feels decisive. It's usually a mistake.&lt;/p&gt;

&lt;p&gt;The reason is uncomfortable but consistent: your working software knows things nobody wrote down. Over the years, it accumulated fixes for edge cases you've forgotten — the customer whose address broke the shipping label, the tax rule that only applies in one country, the payment that occasionally arrives twice and must not be charged twice. None of that is in a document. It's baked into the code that currently works.&lt;/p&gt;

&lt;p&gt;A rewrite starts without any of that knowledge and has to rediscover it — the hard way, through real failures, with real customers. Meanwhile, your business keeps running on the old system, which still needs maintenance and still breaks, while the new one consumes budget and ships nothing customers can use. The common ending is a replacement that arrives late, missing features the old system had, and that the next team will describe as "the legacy system" within two years.&lt;/p&gt;

&lt;p&gt;Rewrites can be justified. But "it's old" and "it's hard to work in" are not the justifications — those describe most software that has ever earned money.&lt;/p&gt;

&lt;p&gt;
  slug="rescue-projects"&lt;br&gt;
  text="If change has become dangerous, you usually don't need a rewrite — you need someone to read the system carefully and make it safe to change again. That's the work I take on, and I'll tell you honestly if a rewrite really is the answer."&lt;br&gt;
/&amp;gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Modernization Actually Means (When It's Worth It)
&lt;/h2&gt;

&lt;p&gt;Modernization done well is rarely a demolition. It's making a working system safe to change again — and most of that work is invisible to your customers, which is exactly why it gets skipped until it's a crisis.&lt;/p&gt;

&lt;p&gt;In practice it looks like this. Someone reads the system carefully and writes down what it actually does, so the knowledge lives in more than one head. They add a safety net that catches breakage &lt;em&gt;before&lt;/em&gt; it reaches customers, so a change to checkout can't silently break invoicing without someone being warned. They add visibility, so when something goes wrong at 2am, you find out before your customers do. And where one specific part is genuinely beyond saving, they replace &lt;em&gt;that part&lt;/em&gt; gradually — building the new alongside the old and switching over piece by piece — instead of betting the whole business on a big-bang cutover.&lt;/p&gt;

&lt;p&gt;The result you're paying for isn't "modern technology." It's the ability to change your own software again — quickly, cheaply, and without fear. That's the asset that got lost, and that's the asset worth buying back.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Modernizing Is Genuinely Worth It
&lt;/h2&gt;

&lt;p&gt;There are real cases where the investment pays for itself. They're rarer than the sales pitch suggests, but they exist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The technology is genuinely dead, not just old.&lt;/strong&gt; Not "an older version" — abandoned. No security updates, no way to hire anyone who knows it, constant workarounds just to keep it alive. At that point the cost of keeping it running exceeds the cost of replacing it, and the security exposure alone can force your hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What your business does has fundamentally changed.&lt;/strong&gt; If you've pivoted, acquired another company, or moved from serving consumers to serving other businesses, the old system may have been built for a company that no longer exists. When every new feature is a fight against how the software was originally shaped, that's a real reason — but notice it's a &lt;em&gt;business&lt;/em&gt; reason, not a code-quality one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The system is actively costing you money you can count.&lt;/strong&gt; Not vague frustration — actual, countable losses. Support hours spent on the same recurring failure. Orders lost when it goes down. A developer's time consumed by workarounds instead of new work. When you can put a monthly number on what the current situation costs, and that number is larger than the cost of fixing it, the decision makes itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the honest test. If you can't name what the current situation is costing you in euros, you don't yet have a business case for spending euros to change it. You have a feeling. Feelings are worth listening to — they're often the first signal that something's wrong — but they're not yet a decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Leave It Alone
&lt;/h2&gt;

&lt;p&gt;This is the part most articles on the subject skip, because most are written by people who profit from you saying yes. So I'll say the uncomfortable thing plainly: often the right answer is to do nothing.&lt;/p&gt;

&lt;p&gt;Leave your software alone when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It works, changes are still safe, and it isn't blocking anything.&lt;/strong&gt; Boring, reliable, and old is not a problem. It's the goal. Don't manufacture a crisis to justify a project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The pain is real but small.&lt;/strong&gt; If the annoyance costs you a few hours a month, spending a large sum and months of disruption to remove it is a bad trade. The math has to work, and for small pain it usually doesn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can't yet say what "fixed" looks like.&lt;/strong&gt; If nobody can describe what the system should do differently, no amount of rebuilding will produce it. That's a clarity problem to solve first — on paper, cheaply — before anyone writes code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The real problem isn't the software at all.&lt;/strong&gt; Sometimes "we need to rebuild the system" is standing in for an unresolved business decision — unclear direction, a team that isn't aligned, a strategy that hasn't been made. A rewrite provides the &lt;em&gt;feeling&lt;/em&gt; of action while the actual problem waits. That's the most expensive way to avoid a hard conversation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A rewrite is one of the largest and riskiest bets a company makes with its technology. The default answer should be no, and the burden of proof should sit with whoever wants to say yes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Decide
&lt;/h2&gt;

&lt;p&gt;You don't need to be technical to make the right call. You need to answer three questions honestly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can we still change it safely and affordably?&lt;/strong&gt; If yes, you almost certainly have an asset, not a liability. Age is irrelevant. Leave it alone and spend the money on something that grows the business.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If not, what is that costing us — in euros, per month?&lt;/strong&gt; Support time, downtime, developer hours spent fighting the system instead of improving it. If you can't count it, get someone to help you count it before you commit to anything. The number is the decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the software actually the problem?&lt;/strong&gt; If a clean, safe version of the system existed tomorrow, would you know exactly what to build next? If yes, the technical work is worth investing in. If no, fix the clarity first — that's cheaper, faster, and it might turn out the software was never the real obstacle.&lt;/p&gt;

&lt;p&gt;The goal was never modern technology. The goal is a business that can change its own software as fast as it needs to, without fear and without a fortune. Sometimes that means modernizing. Often it means leaving good software alone. The skill — and the honesty — is telling those two apart before you spend a euro.&lt;/p&gt;




&lt;p&gt;If your software works but every change has become slow, expensive, and frightening, that's the situation worth a careful look — not a reflexive rebuild. &lt;a href="https://iurii.rogulia.fi/contact" rel="noopener noreferrer"&gt;Let's figure out which one you're actually in&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>business</category>
      <category>legacy</category>
      <category>modernization</category>
      <category>decision</category>
    </item>
    <item>
      <title>PDF Tamper Detection API for C#: ASP.NET Core Integration Guide</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 03 Aug 2026 10:00:38 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/pdf-tamper-detection-api-for-c-aspnet-core-integration-guide-44g1</link>
      <guid>https://dev.to/iurii_rogulia/pdf-tamper-detection-api-for-c-aspnet-core-integration-guide-44g1</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/pdf-verification-csharp-dotnet-integration-guide" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt;. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;PDF fraud is a backend problem, and in enterprise fintech a great deal of that backend runs on .NET. An ASP.NET Core service ingests an uploaded bank statement, a payslip, or a claim packet, writes a row, and hands the document to underwriting — and by the time your controller has returned &lt;code&gt;201 Created&lt;/code&gt;, the document’s claims have already propagated into your business logic. Your KYC provider confirmed the applicant is a real person with a valid identity. It said nothing about whether the PDF they uploaded was edited after the bank generated it. That structural-tampering layer is invisible to identity verification, and the right place to catch it is at ingress: before your service trusts the file, not after.&lt;/p&gt;

&lt;p&gt;This guide walks through integrating the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;PDF tamper detection API&lt;/a&gt; into an ASP.NET Core application — from the first curl command to an idiomatic typed &lt;code&gt;HtpbeClient&lt;/code&gt; built on &lt;code&gt;IHttpClientFactory&lt;/code&gt;, with &lt;code&gt;System.Text.Json&lt;/code&gt; record DTOs, configuration-bound options for the API key, polling with backoff, error handling that distinguishes a configuration failure from a transient one, and a small bank-statement gate that decides accept / reject / review. The patterns target .NET 8 (the current LTS) and use minimal APIs, but everything maps cleanly to a controller-based project. Treat the code as a reference architecture — it runs the real request flow against the documented error codes, but you should adapt and harden it for your own traffic profile and threat model. (If you want the conceptual overview first, start with &lt;a href="https://htpbe.tech/blog/detect-pdf-tampering-programmatically" rel="noopener noreferrer"&gt;How to Detect PDF Tampering Programmatically&lt;/a&gt;. Integrating from another stack? See the &lt;a href="https://htpbe.tech/blog/pdf-verification-java-spring-boot-integration-guide" rel="noopener noreferrer"&gt;Java / Spring Boot&lt;/a&gt;, &lt;a href="https://htpbe.tech/blog/pdf-verification-go-integration-guide" rel="noopener noreferrer"&gt;Go&lt;/a&gt;, &lt;a href="https://htpbe.tech/blog/pdf-verification-nodejs-integration-guide" rel="noopener noreferrer"&gt;Node.js&lt;/a&gt;, &lt;a href="https://htpbe.tech/blog/pdf-verification-api-python-integration-guide" rel="noopener noreferrer"&gt;Python&lt;/a&gt;, and &lt;a href="https://htpbe.tech/blog/pdf-verification-laravel-php-integration-guide" rel="noopener noreferrer"&gt;Laravel / PHP&lt;/a&gt; guides.)&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Two API calls, three verdicts: &lt;code&gt;POST /analyze&lt;/code&gt; returns a top-level &lt;code&gt;id&lt;/code&gt;, then &lt;code&gt;GET /result/{id}&lt;/code&gt; returns the flat verdict object whose &lt;code&gt;status&lt;/code&gt; is one of &lt;code&gt;intact&lt;/code&gt;, &lt;code&gt;modified&lt;/code&gt;, or &lt;code&gt;inconclusive&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The minimum integration is a typed &lt;code&gt;HttpClient&lt;/code&gt; and two awaited calls. No dependency beyond the framework and &lt;code&gt;System.Text.Json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Production shape: an &lt;code&gt;HtpbeClient&lt;/code&gt; typed client registered with &lt;code&gt;IHttpClientFactory&lt;/code&gt;, record DTOs with &lt;code&gt;snake_case&lt;/code&gt; naming, a custom exception carrying the status code, and a Polly retry policy that backs off on 5xx and 429 only.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;DocumentGate&lt;/code&gt; that maps the three verdicts to an &lt;code&gt;Accept&lt;/code&gt; / &lt;code&gt;Reject&lt;/code&gt; / &lt;code&gt;Review&lt;/code&gt; decision for documents that claim institutional origin.&lt;/li&gt;
&lt;li&gt;This is structural PDF tamper and forgery detection — not KYC, not OCR, not AI-text detection. It complements an identity stack; it does not replace one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;.NET 8 SDK (records, &lt;code&gt;required&lt;/code&gt; members, &lt;code&gt;IHttpClientFactory&lt;/code&gt;, minimal APIs)&lt;/li&gt;
&lt;li&gt;An HTPBE API key (Dashboard → copy key)&lt;/li&gt;
&lt;li&gt;Optionally &lt;code&gt;Microsoft.Extensions.Http.Polly&lt;/code&gt; for the resilience policy in Step 6 (the framework &lt;code&gt;HttpClient&lt;/code&gt; works without it)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Test the API with curl
&lt;/h2&gt;

&lt;p&gt;Before writing any C#, confirm your key works. The API uses a two-step flow: &lt;code&gt;POST /analyze&lt;/code&gt; submits a PDF URL and returns a check id, then &lt;code&gt;GET /result/{id}&lt;/code&gt; retrieves the full verdict. (For a language-agnostic overview of what the API detects, see &lt;a href="https://htpbe.tech/how" rel="noopener noreferrer"&gt;how PDF tamper detection works&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1a — submit for analysis:&lt;/strong&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 &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://api.htpbe.tech/v1/test/clean.pdf"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will receive a flat object with a single field: &lt;code&gt;{"id": "00000000-0000-4000-8000-000000000001"}&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1b — retrieve the result:&lt;/strong&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/YOUR_CHECK_ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_API_KEY"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will receive a flat JSON object with &lt;code&gt;"status": "intact"&lt;/code&gt; and the full set of analysis fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"00000000-0000-4000-8000-000000000001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"filename"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"clean.pdf"&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;"intact"&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;"institutional"&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="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;span class="nl"&gt;"creator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Adobe Acrobat Pro DC"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"producer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Adobe PDF Library 15.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"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;"none"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"has_incremental_updates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;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;"update_chain_length"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;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;"signature_removed"&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;"modifications_after_signature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"modification_markers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="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 same shape comes back for &lt;code&gt;modified&lt;/code&gt; and &lt;code&gt;inconclusive&lt;/code&gt; verdicts — only the values change. Two fields are conditional: &lt;code&gt;status_reason&lt;/code&gt; appears only when &lt;code&gt;status&lt;/code&gt; is &lt;code&gt;inconclusive&lt;/code&gt;, and &lt;code&gt;outdated_warning&lt;/code&gt; only when the check ran against an older algorithm version. Note that there is no &lt;code&gt;data&lt;/code&gt; wrapper and no numeric risk score — the response is the flat object above, and the whole signal is the verdict plus the named markers.&lt;/p&gt;

&lt;p&gt;The URL &lt;code&gt;https://api.htpbe.tech/v1/test/clean.pdf&lt;/code&gt; is a test mock — it returns a predictable response without consuming quota. Test keys (prefix &lt;code&gt;htpbe_test_&lt;/code&gt;) accept only these mock URLs; live keys (prefix &lt;code&gt;htpbe_live_&lt;/code&gt;) accept any public PDF URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Bind Configuration, Not Constants
&lt;/h2&gt;

&lt;p&gt;Keep the key and base URL out of code. Bind them to a typed options record so they are validated at startup and overridable per environment. The key resolves from configuration — &lt;code&gt;appsettings.json&lt;/code&gt;, an environment variable, or a secrets store — never a hard-coded literal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;Htpbe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HtpbeOptions&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;SectionName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Htpbe"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ApiKey&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;BaseUrl&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&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="s"&gt;"https://api.htpbe.tech/v1/"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;TimeoutSeconds&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&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="m"&gt;35&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;MaxResultPollAttempts&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&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="m"&gt;5&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;Provide the values in &lt;code&gt;appsettings.json&lt;/code&gt;, leaving the key itself empty so it is supplied by an environment variable or user-secret at runtime:&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;"Htpbe"&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;"ApiKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"BaseUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.htpbe.tech/v1/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"TimeoutSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"MaxResultPollAttempts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&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;In development, store the key with &lt;code&gt;dotnet user-secrets set "Htpbe:ApiKey" "htpbe_live_..."&lt;/code&gt;. In production, set the environment variable &lt;code&gt;Htpbe__ApiKey&lt;/code&gt; (the double underscore maps to the nested section). Either way the key never lands in source control. Bind and validate the options in &lt;code&gt;Program.cs&lt;/code&gt; so a missing key fails the application at boot, not on the first document that arrives at 2 a.m.:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HtpbeOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HtpbeOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SectionName&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApiKey&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s"&gt;"Htpbe:ApiKey is not configured. Set the Htpbe__ApiKey environment variable."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ValidateOnStart&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: The Result DTO
&lt;/h2&gt;

&lt;p&gt;Model the &lt;code&gt;GET /result/{id}&lt;/code&gt; response as C# records. &lt;code&gt;System.Text.Json&lt;/code&gt; maps &lt;code&gt;snake_case&lt;/code&gt; JSON to &lt;code&gt;PascalCase&lt;/code&gt; members when you set &lt;code&gt;JsonNamingPolicy.SnakeCaseLower&lt;/code&gt; on the serializer options (available since .NET 8). Reference types stay nullable so “absent” remains distinguishable from a genuine zero, and unknown fields are ignored by default so a newly added API field never breaks deserialization.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;System.Text.Json.Serialization&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;Htpbe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;AnalysisResult&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Filename&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;FileSize&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;PageCount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;AlgorithmVersion&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;CurrentAlgorithmVersion&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;OutdatedWarning&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// present only on an outdated check&lt;/span&gt;

    &lt;span class="c1"&gt;// Primary verdict: "intact" | "modified" | "inconclusive"&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Present only when Status == "inconclusive":&lt;/span&gt;
    &lt;span class="c1"&gt;// "consumer_software_origin" | "online_editor_origin" |&lt;/span&gt;
    &lt;span class="c1"&gt;// "scanned_document" | "unverifiable_metadata"&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;StatusReason&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Origin&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Origin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// "certain" | "high" | "none" | null&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;ModificationConfidence&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Creator&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Producer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;CreationDate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;     &lt;span class="c1"&gt;// Unix seconds&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;ModificationDate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// Unix seconds&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;PdfVersion&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;DateSequenceValid&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;MetadataCompletenessScore&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;XrefCount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;HasIncrementalUpdates&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;UpdateChainLength&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;HasDigitalSignature&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;SignatureCount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;SignatureRemoved&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;ModificationsAfterSignature&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;ObjectCount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;HasJavascript&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;HasEmbeddedFiles&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Stable HTPBE_* marker ids, e.g. ["HTPBE_SIGNATURE_REMOVED"].&lt;/span&gt;
    &lt;span class="c1"&gt;// Empty when Status is "intact" or "inconclusive".&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;IReadOnlyList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ModificationMarkers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&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;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;JsonIgnore&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;IsIntact&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"intact"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;JsonIgnore&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;IsModified&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"modified"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;JsonIgnore&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;IsInconclusive&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"inconclusive"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;Origin&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// "consumer_software" | "institutional" | "unknown" |&lt;/span&gt;
    &lt;span class="c1"&gt;// "online_editor" | "scanned"&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Software&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&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="c1"&gt;// The /analyze response carries only the check id.&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;AnalyzeResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&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;Two fields deserve a closer look. &lt;code&gt;StatusReason&lt;/code&gt; is populated only when &lt;code&gt;Status&lt;/code&gt; is &lt;code&gt;inconclusive&lt;/code&gt;, and it carries one of several values — &lt;code&gt;consumer_software_origin&lt;/code&gt;, &lt;code&gt;online_editor_origin&lt;/code&gt;, &lt;code&gt;scanned_document&lt;/code&gt;, and a few more. The difference matters: a scanned document is benign for a user-submitted handwritten form, but a &lt;code&gt;consumer_software_origin&lt;/code&gt; on something that claims to be a payslip is the kind of origin you would not expect from a real payroll system — that class covers both consumer apps and freely available HTML-to-PDF renderers, so it is a strong signal to route for review. Branch on the specific reason, not just on the top-level &lt;code&gt;inconclusive&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ModificationMarkers&lt;/code&gt; returns stable, machine-readable ids prefixed &lt;code&gt;HTPBE_&lt;/code&gt; — for example &lt;code&gt;HTPBE_SIGNATURE_REMOVED&lt;/code&gt;, &lt;code&gt;HTPBE_DATES_DISAGREE&lt;/code&gt;, &lt;code&gt;HTPBE_POST_SIGNATURE_EDIT&lt;/code&gt;, &lt;code&gt;HTPBE_MULTIPLE_REVISION_LAYERS&lt;/code&gt;. Branch your integration logic on the id; render the human-readable label from the dictionary published on &lt;a href="https://htpbe.tech/how" rel="noopener noreferrer"&gt;htpbe.tech/how&lt;/a&gt;. These ids are part of the public contract and never change once shipped. The API does not return a numeric risk score — the verdict plus the named markers are the whole signal, by design, so there is no threshold to tune on your side.&lt;/p&gt;

&lt;p&gt;A small enum keeps the rest of your codebase from comparing against bare string literals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;Htpbe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Intact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Modified&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Inconclusive&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;VerdictParser&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt; &lt;span class="nf"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"intact"&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Intact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"modified"&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Modified&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"inconclusive"&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Inconclusive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ArgumentOutOfRangeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"unknown status from HTPBE"&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;h2&gt;
  
  
  Step 4: A Typed Exception
&lt;/h2&gt;

&lt;p&gt;A 401 means your key is wrong; a 402 means the credit pool is dry; a 500 is transient. Both the retry layer and your business logic need to branch on the status code, so wrap every non-success response in a typed exception that carries it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;Htpbe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HtpbeApiException&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Exception&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Code&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;                 &lt;span class="c1"&gt;// machine-readable code from the JSON body&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;RetryAfterSeconds&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;      &lt;span class="c1"&gt;// parsed from Retry-After on 429; null if absent&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;HtpbeApiException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;retryAfterSeconds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;base&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"htpbe: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;Code&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;RetryAfterSeconds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;retryAfterSeconds&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Only 5xx and 429 are transient. Every other 4xx is permanent —&lt;/span&gt;
    &lt;span class="c1"&gt;// retrying it burns latency and, for 402, can never succeed until&lt;/span&gt;
    &lt;span class="c1"&gt;// the account is topped up.&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;Retryable&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;500&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;429&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;h2&gt;
  
  
  Step 5: The Typed HttpClient
&lt;/h2&gt;

&lt;p&gt;Here is the complete client. It is a typed &lt;code&gt;HttpClient&lt;/code&gt; — registered with &lt;code&gt;IHttpClientFactory&lt;/code&gt; in Step 6 — so the factory owns the connection pool and the &lt;code&gt;Authorization&lt;/code&gt; header is set once at registration. The client exposes one public method, &lt;code&gt;VerifyAsync&lt;/code&gt;, that runs both steps of the flow; a private &lt;code&gt;ParseErrorAsync&lt;/code&gt; converts every non-success response into an &lt;code&gt;HtpbeApiException&lt;/code&gt;, reading the JSON error body and the &lt;code&gt;Retry-After&lt;/code&gt; header in one place.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;System.Net&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;System.Net.Http.Json&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;System.Text.Json&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Extensions.Options&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;Htpbe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HtpbeClient&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;HttpClient&lt;/span&gt; &lt;span class="n"&gt;_http&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;_maxPollAttempts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;JsonSerializerOptions&lt;/span&gt; &lt;span class="n"&gt;JsonOptions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;PropertyNamingPolicy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JsonNamingPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SnakeCaseLower&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;PropertyNameCaseInsensitive&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;HtpbeClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpClient&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HtpbeOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_http&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;_maxPollAttempts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxResultPollAttempts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;/// &amp;lt;summary&amp;gt;&lt;/span&gt;
    &lt;span class="c1"&gt;/// Submits a PDF URL and returns the full verdict. The two steps are kept&lt;/span&gt;
    &lt;span class="c1"&gt;/// separate on purpose: POST /analyze is the billable, job-creating call,&lt;/span&gt;
    &lt;span class="c1"&gt;/// GET /result/{id} is a free read. The resilience policy (Step 6) wraps&lt;/span&gt;
    &lt;span class="c1"&gt;/// the whole client, but only transient failures are retried — a permanent&lt;/span&gt;
    &lt;span class="c1"&gt;/// 402 short-circuits immediately.&lt;/span&gt;
    &lt;span class="c1"&gt;/// &amp;lt;/summary&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AnalysisResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;VerifyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;id&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;SubmitAnalysisAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&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;await&lt;/span&gt; &lt;span class="nf"&gt;GetResultAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;SubmitAnalysisAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// The API accepts a JSON body with the PDF URL; original_filename is optional.&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
            &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&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="s"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pdfUrl&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&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="s"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"original_filename"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

        &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PostAsJsonAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"analyze"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JsonOptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&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="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsSuccessStatusCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ParseErrorAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AnalyzeResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonOptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&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="n"&gt;payload&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;HtpbeApiException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"BAD_RESPONSE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"analyze response missing id"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AnalysisResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetResultAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// POST /analyze runs the analysis synchronously, so the result is normally&lt;/span&gt;
        &lt;span class="c1"&gt;// ready on the first GET. The bounded poll below is defensive: it tolerates&lt;/span&gt;
        &lt;span class="c1"&gt;// a brief replication lag and re-reads on a transient 404 before giving up.&lt;/span&gt;
        &lt;span class="n"&gt;HtpbeApiException&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;lastError&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;_maxPollAttempts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"result/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&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="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsSuccessStatusCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AnalysisResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonOptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;HtpbeApiException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"BAD_RESPONSE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"empty result body"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="n"&gt;lastError&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;ParseErrorAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="c1"&gt;// Only a 404 is worth re-reading (the row may not be visible yet).&lt;/span&gt;
            &lt;span class="c1"&gt;// Every other error is terminal — surface it without burning attempts.&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;HttpStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;lastError&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromMilliseconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;300&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;lastError&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;HtpbeApiException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;504&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"RESULT_TIMEOUT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"result not ready after polling"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HtpbeApiException&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ParseErrorAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;HttpResponseMessage&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"UNKNOWN"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReasonPhrase&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ErrorBody&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonOptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&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="n"&gt;error&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Code&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// body was not JSON — keep the status-derived defaults&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="m"&gt;401&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"invalid API key — check Htpbe:ApiKey"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="m"&gt;402&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"no credits available for this key — top up or subscribe"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="m"&gt;403&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"test key sent to a live URL, or vice versa"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="m"&gt;413&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"PDF exceeds the 10 MB size limit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="m"&gt;422&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"the URL did not return a valid PDF file"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;

        &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;retryAfter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&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="n"&gt;statusCode&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;429&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&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;RetryAfter&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;retryAfter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ParseRetryAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ra&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;new&lt;/span&gt; &lt;span class="nf"&gt;HtpbeApiException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retryAfter&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Handles both the delta-seconds form and the HTTP-date form,&lt;/span&gt;
    &lt;span class="c1"&gt;// clamped to [1, 600]. Returns null when neither is present.&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;ParseRetryAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Http&lt;/span&gt;&lt;span class="p"&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;RetryConditionHeaderValue&lt;/span&gt; &lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delta&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Clamp&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TotalSeconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;600&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="n"&gt;ra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Date&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Clamp&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;TotalSeconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;600&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;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ErrorBody&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Code&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&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;Two status codes deserve explicit handling in your own code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;402&lt;/code&gt; (Payment Required)&lt;/strong&gt; — the key has no credit source left. Credits are universal: a subscription’s monthly quota, a one-time top-up batch, and the welcome credits all draw from one pool. A 402 means all three are exhausted (or there is no active plan on a live key). &lt;code&gt;HtpbeApiException.Retryable&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt; for it — surface it to your billing path rather than retrying, because retrying fails identically until the account is topped up at &lt;a href="https://htpbe.tech/pricing" rel="noopener noreferrer"&gt;the pricing page&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;429&lt;/code&gt; (Too Many Requests)&lt;/strong&gt; — this is server-wide concurrency, not per-key rate limiting. The response carries a &lt;code&gt;Retry-After&lt;/code&gt; header, which &lt;code&gt;ParseRetryAfter&lt;/code&gt; reads (both delta-seconds and HTTP-date forms, clamped to &lt;code&gt;[1, 600]&lt;/code&gt;) and stashes on the exception. Your retry policy reads that value before falling back to exponential backoff.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 6: Register the Client and Retry on Transient Failures Only
&lt;/h2&gt;

&lt;p&gt;Register &lt;code&gt;HtpbeClient&lt;/code&gt; as a typed client. &lt;code&gt;IHttpClientFactory&lt;/code&gt; manages the underlying handler pool, sets the base address and default headers once, and lets you attach a resilience policy. The policy retries &lt;strong&gt;only&lt;/strong&gt; on the transient codes — 5xx and 429 — and never on the permanent 4xx codes like 401, 402, or 422, where retrying would burn latency and credits without ever succeeding.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;System.Net&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Extensions.Options&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Polly&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Polly.Extensions.Http&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// In Program.cs&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddHttpClient&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HtpbeClient&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;((&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;opts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HtpbeOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;().&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseAddress&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultRequestHeaders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Authorization&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Http&lt;/span&gt;&lt;span class="p"&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;AuthenticationHeaderValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApiKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultRequestHeaders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Http&lt;/span&gt;&lt;span class="p"&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;MediaTypeWithQualityHeaderValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TimeoutSeconds&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;AddPolicyHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;TransientRetryPolicy&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;IAsyncPolicy&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HttpResponseMessage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;TransientRetryPolicy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;HttpPolicyExtensions&lt;/span&gt;
        &lt;span class="c1"&gt;// Network failures and 5xx are handled by HandleTransientHttpError;&lt;/span&gt;
        &lt;span class="c1"&gt;// add 429 explicitly so a capacity signal also backs off and retries.&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HandleTransientHttpError&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;HttpStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TooManyRequests&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitAndRetryAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;retryCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;sleepDurationProvider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="c1"&gt;// Honour a server-supplied Retry-After when present (429 capacity),&lt;/span&gt;
                &lt;span class="c1"&gt;// otherwise fall back to exponential backoff: 1s, 2s, 4s.&lt;/span&gt;
                &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;retryAfter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&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="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RetryAfter&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Delta&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;retryAfter&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;onRetryAsync&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CompletedTask&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you would rather not take a Polly dependency, the bounded poll inside &lt;code&gt;GetResultAsync&lt;/code&gt; already handles the most common transient case (a result that is not yet visible), and you can wrap &lt;code&gt;VerifyAsync&lt;/code&gt; in a small &lt;code&gt;for&lt;/code&gt; loop that re-throws when &lt;code&gt;HtpbeApiException.Retryable&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;. For most integrations the Polly handler is the cleanest fit, because it sits at the &lt;code&gt;HttpClient&lt;/code&gt; layer and applies to both the submit and the read uniformly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: The Bank-Statement Gate
&lt;/h2&gt;

&lt;p&gt;The client returns facts. Turning those facts into an &lt;code&gt;Accept&lt;/code&gt; / &lt;code&gt;Reject&lt;/code&gt; / &lt;code&gt;Review&lt;/code&gt; decision is a policy choice that depends on what the document claims to be. A bank statement, a payslip, or a diploma claims institutional origin, so anything other than &lt;code&gt;intact&lt;/code&gt; should stop the automated path. A user-generated form is held to a looser standard.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;Htpbe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Reject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Review&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DocumentGate&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;/// &amp;lt;summary&amp;gt;&lt;/span&gt;
    &lt;span class="c1"&gt;/// Maps a verdict to a decision for documents that claim institutional&lt;/span&gt;
    &lt;span class="c1"&gt;/// origin (bank statements, payslips, diplomas). For these, "inconclusive"&lt;/span&gt;
    &lt;span class="c1"&gt;/// is treated as strictly as "modified": a document that should have come&lt;/span&gt;
    &lt;span class="c1"&gt;/// from a bank's own system but looks like it was built in Word does not&lt;/span&gt;
    &lt;span class="c1"&gt;/// get the benefit of the doubt.&lt;/span&gt;
    &lt;span class="c1"&gt;/// &amp;lt;/summary&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt; &lt;span class="nf"&gt;ForInstitutional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AnalysisResult&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;VerdictParser&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="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Modified&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="c1"&gt;// A bank statement that comes back inconclusive should not be&lt;/span&gt;
            &lt;span class="c1"&gt;// auto-accepted: it typically came from consumer software rather&lt;/span&gt;
            &lt;span class="c1"&gt;// than a bank's own system — a signal to route for review, not&lt;/span&gt;
            &lt;span class="c1"&gt;// proof of tampering. Send it to a human.&lt;/span&gt;
            &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Inconclusive&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Review&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Intact&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Review&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;Wire the client and the gate into a minimal-API endpoint that accepts a JSON body with a reachable URL. The handler runs the check before any business logic touches the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In Program.cs, after building the app&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/documents/verify"&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="n"&gt;VerifyRequest&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HtpbeClient&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DocumentUrl&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BadRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"documentUrl is required"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="n"&gt;AnalysisResult&lt;/span&gt; &lt;span class="n"&gt;result&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="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;VerifyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DocumentUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OriginalFilename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&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="n"&gt;HtpbeApiException&lt;/span&gt; &lt;span class="n"&gt;e&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="nf"&gt;MapApiError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&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="n"&gt;DocumentGate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ForInstitutional&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="k"&gt;switch&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reject&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UnprocessableEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"reject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"document modified after creation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;modification_markers&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="n"&gt;ModificationMarkers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Review&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Accepted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"review"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;status_reason&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="n"&gt;StatusReason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"accept"&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="n"&gt;Id&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;static&lt;/span&gt; &lt;span class="n"&gt;IResult&lt;/span&gt; &lt;span class="nf"&gt;MapApiError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HtpbeApiException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Configuration / billing errors — never leak the cause to the caller.&lt;/span&gt;
    &lt;span class="m"&gt;401&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="m"&gt;402&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"verification temporarily unavailable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;503&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="m"&gt;422&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UnprocessableEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"the URL did not return a valid PDF"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="m"&gt;413&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"PDF must be under 10 MB"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;413&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"verification failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;502&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;VerifyRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;DocumentUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;OriginalFilename&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;code&gt;inconclusive&lt;/code&gt; result should not be auto-accepted — it typically indicates the file came from consumer software, an online editor, an HTML renderer, or a scanner rather than an institutional generator. That is a signal to route for review, not proof of tampering. For a deeper explanation, see &lt;a href="https://htpbe.tech/blog/what-inconclusive-means-pdf-verification-results" rel="noopener noreferrer"&gt;what “inconclusive” really means&lt;/a&gt;. For documents that claim institutional origin, treat &lt;code&gt;inconclusive&lt;/code&gt; with the same caution as &lt;code&gt;modified&lt;/code&gt;: do not accept automatically, route to a human reviewer. Inverting that policy — treating &lt;code&gt;inconclusive&lt;/code&gt; as a pass — is the single most common integration mistake, because it hands an automatic accept to exactly the consumer-software-built documents a bank statement should never be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: Giving the API a Reachable URL
&lt;/h2&gt;

&lt;p&gt;The API does not accept file uploads — it downloads the PDF from a URL you supply, so the file must be publicly reachable for the few seconds the analysis takes. The cleanest pattern is a short-lived presigned URL from your object store: you never expose the bucket, the link expires in minutes, and passing &lt;code&gt;originalFilename&lt;/code&gt; keeps the audit trail readable instead of showing an opaque storage key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Store the upload privately, mint a 5-minute presigned GET URL, verify.&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"incoming/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s"&gt;.pdf"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PutObjectAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;PutObjectRequest&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;BucketName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;InputStream&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pdfStream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ContentType&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"application/pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;presignedUrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetPreSignedURLAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;GetPreSignedUrlRequest&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;BucketName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Expires&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;Verb&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;HttpVerb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;VerifyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;presignedUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same pattern works with Azure Blob Storage (a SAS token via &lt;code&gt;BlobClient.GenerateSasUri&lt;/code&gt;), Google Cloud Storage (&lt;code&gt;UrlSigner&lt;/code&gt;), or Cloudflare R2 (S3-compatible — reuse the AWS SDK with the R2 endpoint). One security note: the API fetches whatever URL you give it, so if a URL ever comes from untrusted input (a user-pasted link, a webhook payload), validate that it resolves to a public host first — reject &lt;code&gt;localhost&lt;/code&gt;, &lt;code&gt;169.254.169.254&lt;/code&gt; (cloud metadata), and the RFC 1918 private ranges to close the SSRF surface. When you mint the URL yourself from a private bucket the risk is minimal, but the validation belongs in the request flow either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 9: Testing Without Burning Quota
&lt;/h2&gt;

&lt;p&gt;Every plan includes a test API key (prefix &lt;code&gt;htpbe_test_&lt;/code&gt;) that accepts only mock URLs of the form &lt;code&gt;https://api.htpbe.tech/v1/test/{filename}.pdf&lt;/code&gt; and returns deterministic responses — like Stripe test cards, with no quota cost. Point an integration test at these fixtures to cover every branch of the gate. With &lt;code&gt;WebApplicationFactory&amp;lt;Program&amp;gt;&lt;/code&gt; you exercise the real &lt;code&gt;HtpbeClient&lt;/code&gt;, configured with the test key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.AspNetCore.Mvc.Testing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Extensions.DependencyInjection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Xunit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HtpbeClientTests&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IClassFixture&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;WebApplicationFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Program&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;HtpbeClient&lt;/span&gt; &lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;HtpbeClientTests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WebApplicationFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Program&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;configured&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithWebHostBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseSetting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Htpbe:ApiKey"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetEnvironmentVariable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"HTPBE_TEST_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;)!));&lt;/span&gt;
        &lt;span class="n"&gt;_client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;configured&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HtpbeClient&lt;/span&gt;&lt;span class="p"&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="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;CleanDocumentReturnsIntact&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;VerifyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://api.htpbe.tech/v1/test/clean.pdf"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"intact"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ModificationMarkers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DocumentGate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ForInstitutional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&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="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;SignatureRemovedIsRejected&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;VerifyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://api.htpbe.tech/v1/test/signature-removed.pdf"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"modified"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;True&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SignatureRemoved&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DocumentGate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ForInstitutional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&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="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;InconclusiveIsRoutedToReview&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;VerifyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://api.htpbe.tech/v1/test/inconclusive.pdf"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"inconclusive"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NotNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusReason&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Review&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DocumentGate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ForInstitutional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&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;Useful fixtures: &lt;code&gt;clean.pdf&lt;/code&gt; → &lt;code&gt;intact&lt;/code&gt;, &lt;code&gt;signature-removed.pdf&lt;/code&gt; → &lt;code&gt;modified&lt;/code&gt;, &lt;code&gt;dates-mismatch.pdf&lt;/code&gt; → &lt;code&gt;modified&lt;/code&gt;, and &lt;code&gt;inconclusive.pdf&lt;/code&gt; → &lt;code&gt;inconclusive&lt;/code&gt;. For pure unit tests of the endpoint and gate without any network, inject a fake &lt;code&gt;HttpMessageHandler&lt;/code&gt; into the &lt;code&gt;HtpbeClient&lt;/code&gt; and return canned JSON, or test &lt;code&gt;DocumentGate.ForInstitutional&lt;/code&gt; directly against a hand-built &lt;code&gt;AnalysisResult&lt;/code&gt;. Keep test and live keys in separate configuration sources and never commit either.&lt;/p&gt;

&lt;p&gt;For audit dashboards, &lt;code&gt;GET /api/v1/checks&lt;/code&gt; returns a paginated list of every result for your key — filter by &lt;code&gt;status&lt;/code&gt; and &lt;code&gt;limit&lt;/code&gt; (&lt;code&gt;/checks?status=modified&amp;amp;limit=50&lt;/code&gt;, same &lt;code&gt;Authorization&lt;/code&gt; header). When you reach your monthly quota, further requests return &lt;code&gt;402 PAYMENT_REQUIRED&lt;/code&gt; until it resets — add a one-time credit pack or move to a higher tier to keep going, and handle the 402 so a quota boundary never silently drops a check.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Verdicts Mean
&lt;/h2&gt;

&lt;p&gt;The whole signal is three verdicts and a list of named markers. Encoding them correctly in your &lt;code&gt;DocumentGate&lt;/code&gt; matters more than any other choice in the integration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;intact&lt;/code&gt;&lt;/strong&gt; — no post-creation modification was detected and the origin looks institutional. Safe to accept on the automated path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;modified&lt;/code&gt;&lt;/strong&gt; — forensic evidence of an edit after the document was created. The &lt;code&gt;modification_markers&lt;/code&gt; array names the signal: &lt;code&gt;HTPBE_DATES_DISAGREE&lt;/code&gt; for inconsistent internal timestamps, &lt;code&gt;HTPBE_SIGNATURE_REMOVED&lt;/code&gt; for a stripped digital signature, &lt;code&gt;HTPBE_POST_SIGNATURE_EDIT&lt;/code&gt; for changes made after signing, &lt;code&gt;HTPBE_MULTIPLE_REVISION_LAYERS&lt;/code&gt; for a document saved repeatedly after creation. Reject, or route to fraud review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;inconclusive&lt;/code&gt;&lt;/strong&gt; — the document was built with consumer software, an online editor, an HTML renderer, or a scanner, so there is no institutional “original” to verify integrity against. This is &lt;strong&gt;not&lt;/strong&gt; a failure and &lt;strong&gt;not&lt;/strong&gt; a clean pass — it is a routing signal. For a document that should have come from an institution (a bank statement, a payslip), &lt;code&gt;inconclusive&lt;/code&gt; means it did not, which is exactly why &lt;code&gt;DocumentGate.ForInstitutional&lt;/code&gt; routes it to a human reviewer.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Structural analysis has honest limits, and an ASP.NET Core service making automated decisions should encode them rather than overstate the verdict:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Content fabricated in one pass.&lt;/strong&gt; If someone opens Word, types a false salary, and exports once, the file was never modified after creation — it is structurally consistent. The fraud happened at authorship, not at the byte level. This is exactly why a payslip from a consumer tool tends to return &lt;code&gt;inconclusive&lt;/code&gt; rather than &lt;code&gt;intact&lt;/code&gt;: the analysis cannot vouch for a document anyone could have produced from scratch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Born-synthetic forgeries.&lt;/strong&gt; A fake document generated programmatically with a valid-looking account number and a real logo — never derived from a genuine original — has no post-creation edit to detect. Catching that is a content-verification problem (does this account number exist, does this employer match payroll records), a different product category from structural tamper detection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documents rebuilt from scratch in the original’s software.&lt;/strong&gt; A determined attacker who recreates a document in the same institutional tool and matches the metadata leaves few structural signals. This is rare and high-effort, but possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encrypted or password-protected PDFs.&lt;/strong&gt; The service cannot parse a file it cannot open; remove the password before submitting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These limits are why structural tamper detection works as one layer in a fraud-detection stack, not the whole stack. Pair the structural verdict with domain checks — amount validation, account-number lookups, sender authentication, and your KYC or OCR provider — for a layered defense. The structural layer answers a question identity verification cannot: was this file edited after it was issued? See &lt;a href="https://htpbe.tech/blog/pdf-fraud-prevention-best-practices" rel="noopener noreferrer"&gt;PDF Fraud Prevention Best Practices&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decisions Before You Ship
&lt;/h2&gt;

&lt;p&gt;The integration surface is intentionally small: one POST, one GET, three verdicts, the typed exception above. The complexity lives on the .NET side, and two choices matter most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Where verification runs.&lt;/strong&gt; Synchronous inside the request handler gives the caller an immediate decision but blocks for a few seconds; a background &lt;code&gt;IHostedService&lt;/code&gt; or a message-driven consumer (Azure Service Bus, RabbitMQ) returns instantly and defers the verdict. Sync suits low-volume B2B onboarding; async suits high-volume portals. Because the call is fully &lt;code&gt;async&lt;/code&gt;/&lt;code&gt;await&lt;/code&gt; and the analysis takes 2–5 seconds, the synchronous path is cheap enough for most onboarding flows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;inconclusive&lt;/code&gt; routing.&lt;/strong&gt; For documents that claim institutional origin (bank statements, diplomas, payslips), treat &lt;code&gt;inconclusive&lt;/code&gt; with the same caution as &lt;code&gt;modified&lt;/code&gt; and route to human review — that is what &lt;code&gt;DocumentGate.ForInstitutional&lt;/code&gt; encodes. For genuinely user-generated content it may be acceptable as-is, so you may want a second gate with a looser policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To start, &lt;a href="https://htpbe.tech/auth/signup" rel="noopener noreferrer"&gt;sign up for HTPBE&lt;/a&gt; — new accounts get five checks to try, then pay-per-check credits or a subscription (see &lt;a href="https://htpbe.tech/pricing" rel="noopener noreferrer"&gt;current pricing&lt;/a&gt;) — copy your test key, and run the curl call from Step 1. The &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;full API reference&lt;/a&gt; documents every response field, error code, and the marker dictionary the .NET client branches on.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>tutorial</category>
      <category>api</category>
      <category>fraud</category>
    </item>
  </channel>
</rss>
