<?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>Why 'Two Weeks' Always Means Six — and How to Estimate Honestly</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Wed, 22 Jul 2026 10:00:45 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/why-two-weeks-always-means-six-and-how-to-estimate-honestly-1ddc</link>
      <guid>https://dev.to/iurii_rogulia/why-two-weeks-always-means-six-and-how-to-estimate-honestly-1ddc</guid>
      <description>&lt;p&gt;A developer says "two weeks." Six weeks later, it ships.&lt;/p&gt;

&lt;p&gt;This happens so reliably that whole project-management methodologies exist mostly to manage around it. And the standard explanations are wrong, or at least lazy. "Developers are bad at estimating" is the comfortable one — it puts the fault in a personality trait, which means nobody has to change how they ask for numbers. It also doesn't survive contact with the evidence: the same developers estimate their commute, their grocery run, and their weekend renovation with the same optimism, and so does everyone else. The bias isn't a coding skill defect. It's structural.&lt;/p&gt;

&lt;p&gt;So here is the question worth answering, because the answer is actionable: &lt;strong&gt;how to estimate software projects honestly&lt;/strong&gt; — knowing in advance why the number bends, and giving a figure your business can actually plan against. I've been giving estimates, and being held to them, for a long time. I've been wrong in every direction. What follows is why "two weeks" becomes six, and the specific things I now do to give numbers that don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The iceberg under "the happy path"
&lt;/h2&gt;

&lt;p&gt;When a developer pictures a feature and says "two weeks," they are almost always estimating the happy path. The part they can see. User clicks the button, the data is valid, the third-party API responds, the record saves, the page renders. That mental movie runs in a few seconds and feels like the whole job.&lt;/p&gt;

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

&lt;p&gt;Below the waterline sits everything the demo never shows. Input validation. The empty state, the loading state, the error state — three screens nobody mentioned but all of which have to exist. Authentication and authorization on the new route. The database migration, and the rollback for when it goes wrong. Error handling for the API call that times out. Idempotency for the request the user double-clicks. The deploy. Code review, and the round of changes it produces. QA finding the thing you didn't, and the back-and-forth to fix it. Then the second bug QA finds because the first fix broke something adjacent.&lt;/p&gt;

&lt;p&gt;None of that is in the two-week movie, and none of it is optional. A feature that handles only the happy path isn't 90% done; it's a prototype that happens to demo well. The gap between "works when I click through it once" and "works in production for strangers doing things I didn't predict" is where the other four weeks live.&lt;/p&gt;

&lt;p&gt;This is the single biggest source of estimate error, and it's not arithmetic. It's a perception problem: the part of the work that's easy to imagine is a small and unrepresentative fraction of the part that has to be built.&lt;/p&gt;

&lt;h2&gt;
  
  
  Effort is not duration
&lt;/h2&gt;

&lt;p&gt;The second structural error is quieter and just as expensive. A developer estimates &lt;strong&gt;effort&lt;/strong&gt; — "this is about three days of work" — and the number gets recorded, and reported upward, as &lt;strong&gt;duration&lt;/strong&gt;: three calendar days, done Thursday.&lt;/p&gt;

&lt;p&gt;Those are not the same quantity, and the conversion factor is brutal. Three "ideal days" of focused work do not fit into three working days. They fit into a working week and a half, because the working day is not eight hours of the estimated activity. It's standups and the sync that ran long. It's the context-switch tax every time someone asks "quick question." It's reviewing someone else's PR because the team blocks if you don't. It's the interview you're on the panel for. It's lunch and the commute that lives in the calendar even when remote.&lt;/p&gt;

&lt;p&gt;Industry-wide, the gap between ideal engineering hours and elapsed calendar time tends to land somewhere around half — you get roughly four to five productive hours of the estimated work into a nominal eight-hour day, and less on a meeting-heavy one. So an honest effort estimate of "five days of work" is, before anything goes wrong, closer to two calendar weeks of wall-clock time. The estimate wasn't wrong. The translation was missing.&lt;/p&gt;

&lt;p&gt;When I give a number now, I'm explicit about which currency it's in. "Five days of effort" and "ready in two weeks" are different sentences, and conflating them is how a correct estimate produces a broken commitment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The things you can't estimate because you haven't met them yet
&lt;/h2&gt;

&lt;p&gt;The third reason is the honest one, and it's the reason no technique fully closes the gap: you cannot estimate what you haven't discovered yet.&lt;/p&gt;

&lt;p&gt;The payment provider's documentation says the webhook fires once. In production it fires three times, out of order, and one of them arrives before the API call that triggered it has returned. The legacy table has a column called &lt;code&gt;status&lt;/code&gt; that's authoritative in three places and ignored in a fourth, and nobody alive knows why. The "simple" integration turns out to rate-limit at a number the docs don't mention, discovered only under real load. The third-party API lies — returns &lt;code&gt;200 OK&lt;/code&gt; with an error nested in the body.&lt;/p&gt;

&lt;p&gt;These are unknown-unknowns, and they are not a sign that the estimator was careless. They are genuinely invisible at estimation time. You find them by building, because building is the act of discovery. Every non-trivial feature contains some number of these, and the number is itself unknown. This is exactly why I won't quote a rescue engagement before I've spent time in the codebase — most of the cost lives in things the repository hasn't shown me yet, and I wrote up that whole audit method in &lt;a href="https://iurii.rogulia.fi/blog/due-diligence-before-rewrite" rel="noopener noreferrer"&gt;technical due diligence before a rewrite&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;You cannot estimate these away. But you can estimate their &lt;em&gt;existence&lt;/em&gt; — you know they're coming, even if you don't know their shape — and that's what a range is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope creep and the "while you're in there" tax
&lt;/h2&gt;

&lt;p&gt;Then there's the part that isn't a misperception at all — it's the work genuinely growing under your feet.&lt;/p&gt;

&lt;p&gt;"While you're in there, can you also…" is the most expensive sentence in software, because each instance is individually reasonable and collectively unbounded. The feature that was scoped as one form acquires a second field, then validation on that field, then a special case for one customer, then an admin view to manage the special case. Nobody decided to triple the work. It accreted, one defensible request at a time.&lt;/p&gt;

&lt;p&gt;The estimate was honest for the feature that was described. It was never updated for the feature that was actually built, because the growth happened in conversation, not in a ticket. An estimate is a statement about a fixed scope; the moment the scope moves, the estimate is stale and almost nobody re-states it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optimism, anchoring, and the number that bends
&lt;/h2&gt;

&lt;p&gt;The last reason is psychological, and the cheapest to fix once you see it.&lt;/p&gt;

&lt;p&gt;A stakeholder asks, "this is pretty simple, right?" — and the number bends toward the answer they're hoping for. Not through dishonesty. Through anchoring: the word "simple" is now in the room, and "six weeks" feels like a confrontation, so "a couple of weeks" comes out instead. The developer wants to be helpful and competent, and the social path of least resistance is the smaller number.&lt;/p&gt;

&lt;p&gt;Optimism bias does the rest. We imagine the version of the project where nothing goes wrong, because that's the version that's easy to imagine — the failures are, by definition, the things we haven't pictured. So we estimate the best case and report it as the expected case.&lt;/p&gt;

&lt;p&gt;The fix is not "be more pessimistic." Pessimism is just optimism's equally uncalibrated twin. The fix is structural, and it's the rest of this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to estimate honestly: ranges, not single numbers
&lt;/h2&gt;

&lt;p&gt;The single most important change: stop giving single numbers. A single number is a lie of precision. It claims a confidence the situation doesn't contain.&lt;/p&gt;

&lt;p&gt;Give a range, and attach meaning to its ends. Not "two to six weeks" mumbled as a hedge — that's a single number with anxiety. A real range says what each end &lt;em&gt;means&lt;/em&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Two weeks if the payment integration behaves the way the docs claim. Five if it fights us the way these integrations usually do. My honest expectation is three.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sentence is more useful to a business than any single number, because it tells the recipient what they're betting on and where the risk sits. If they need it in two weeks, the conversation is now about the payment integration specifically — the actual source of uncertainty — instead of about whether the developer is sandbagging.&lt;/p&gt;

&lt;p&gt;If your organization speaks the language, frame it as confidence intervals: a P50 (half the time it's done by here) and a P90 (nine times in ten it's done by here). The distance between P50 and P90 is the most honest thing in the estimate — it's a direct measurement of how much you don't yet know. A wide gap isn't incompetence. It's an accurate report of genuine uncertainty, and a narrow gap on a vague feature is the actual red flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decompose until each piece is a day — and time-box what you can't
&lt;/h2&gt;

&lt;p&gt;You cannot estimate a big thing. You can only estimate small things and add them up. So decompose the work until every piece is roughly a day or less. Pieces that small are things you've done shapes of before; your gut is calibrated on them. "Build the reporting feature" is a guess. "Add the date-range picker, write the aggregation query, build the empty state, add the CSV export, handle the no-data case" is five things you can each actually picture.&lt;/p&gt;

&lt;p&gt;The decomposition does double duty: the pieces you &lt;em&gt;can't&lt;/em&gt; break down are exactly the risky ones. If you can't decompose "integrate the partner's API" into day-sized chunks, that's not an estimate waiting to happen — it's a research spike. So don't estimate it. &lt;strong&gt;Time-box&lt;/strong&gt; it: "I'll spend one day finding out how this API actually behaves, then I'll estimate the integration." A timebox is a commitment to spend a fixed amount of &lt;em&gt;learning&lt;/em&gt;, not a guess at the cost of &lt;em&gt;building&lt;/em&gt;. Conflating the two — estimating the build of a thing you don't yet understand — is where the worst overruns come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate the estimate from the commitment
&lt;/h2&gt;

&lt;p&gt;This is the distinction most teams collapse, and collapsing it is why estimates feel like traps to everyone involved.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;estimate&lt;/strong&gt; is your honest, technical best guess at the effort. It belongs to engineering. It's a probabilistic statement about uncertain work.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;commitment&lt;/strong&gt; is the date the business promises to a customer, a board, a launch. It belongs to the business, and it should include a buffer for the risk the estimate just quantified.&lt;/p&gt;

&lt;p&gt;These are different objects owned by different people. When a developer's raw estimate gets promised verbatim to a customer as a hard date, the buffer that should have absorbed the unknown-unknowns was never added — and the first surprise blows the commitment. The honest move is to make the seam visible: "My estimate is three weeks. Given what we don't know about the integration, I wouldn't promise the customer anything tighter than five." The buffer is stated, owned, and defensible — not smuggled in by secretly tripling the number, which is the dishonest version everyone resorts to when the seam is hidden.&lt;/p&gt;

&lt;p&gt;
  slug="fractional-cto"&lt;br&gt;
  text="If your estimates keep arriving as single comforting numbers and landing as overruns, that's a process gap I fix from the inside — separating engineering's estimate from the business's commitment, and putting calibration in place."&lt;br&gt;
/&amp;gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Track estimate vs actual, and trust history over your gut
&lt;/h2&gt;

&lt;p&gt;Your gut is uncalibrated until you measure it. The single highest-leverage practice in estimation is also the most neglected: write down what you estimated, then write down what it actually took, and look at the two columns.&lt;/p&gt;

&lt;p&gt;After a dozen rows, patterns appear that no amount of careful thinking would have surfaced. You discover you're consistently 2.2x light on anything touching authentication. You discover CRUD features land almost exactly on estimate, but anything involving a third-party API runs triple. That's &lt;strong&gt;reference-class forecasting&lt;/strong&gt;, and it beats fresh judgment every time: "the last three features that looked like this took four weeks each" is worth more than any amount of reasoning about why this one will be different. It usually won't be.&lt;/p&gt;

&lt;p&gt;This is also the cheapest competitive edge a team can build. A developer who knows their own historical multiplier gives numbers that come true. One who estimates from scratch every time keeps relearning the same lesson and keeps surprising everyone, including themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cone of uncertainty: a stale estimate is a lie
&lt;/h2&gt;

&lt;p&gt;There's a well-documented shape to estimation error called the cone of uncertainty. At the very beginning of a project — the idea stage, before anything is built — estimates are reliably off by a factor of around four &lt;em&gt;in either direction&lt;/em&gt;. Not because everyone is bad at it; because there's genuinely four-x worth of unknowns still undiscovered. The cone narrows only as you build and learn. By the time you're halfway through, the same estimate might be off by 25%. By the end, it's off by nothing, because it's done.&lt;/p&gt;

&lt;p&gt;The operational consequence is blunt: an estimate has a shelf life, and re-estimating at milestones isn't admitting failure — it's the job. The number you gave at the idea stage was your honest best guess given near-total ignorance. Three weeks in, you know things you didn't, and the responsible move is to update the number out loud. A team that holds you to an idea-stage estimate after a month of discovery is asking you to honour a guess made by someone who knew less than you do now.&lt;/p&gt;

&lt;p&gt;The opposite failure is just as common: giving the idea-stage estimate, then never revisiting it, and letting it quietly become a broken promise. A stale estimate that nobody updated is, functionally, a lie — not because anyone intended to deceive, but because it stopped describing reality and nobody said so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Name your assumptions, or the number means nothing
&lt;/h2&gt;

&lt;p&gt;Every estimate rests on assumptions, and an estimate detached from its assumptions is worthless — worse than worthless, because it looks authoritative while being unconditioned.&lt;/p&gt;

&lt;p&gt;"Three weeks" means nothing on its own. "Three weeks, &lt;em&gt;assuming&lt;/em&gt; the staging environment matches production, the partner's API does what its docs claim, and nobody changes the requirements mid-flight" is a real estimate — because now both sides can see exactly which load-bearing beliefs the number stands on, and watch for the moment one of them fails. When the partner's API turns out to behave differently, you don't have an unexplained overrun; you have a named assumption that broke, and a conversation that starts from "remember assumption two" instead of "why are you late."&lt;/p&gt;

&lt;p&gt;I write the assumptions down, next to the number, every time. It's the difference between an estimate someone can interrogate and a number they can only resent when it's wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest-CTO framing: who you can plan around
&lt;/h2&gt;

&lt;p&gt;Here is the part that's really about hiring, not arithmetic.&lt;/p&gt;

&lt;p&gt;A good engineer — or a good fractional CTO, or a good contractor — gives you a number you can plan around &lt;em&gt;together with its uncertainty&lt;/em&gt;. They will tell you the range, name the risk, separate the estimate from the commitment, and update you when the cone narrows. That is not them being evasive or covering themselves. That is them being useful. The uncertainty is real whether or not they tell you about it; the only choice is whether you find out now or in week five. I've written more about what that role actually involves in &lt;a href="https://iurii.rogulia.fi/blog/what-fractional-cto-does" rel="noopener noreferrer"&gt;what a fractional CTO does&lt;/a&gt;, and the same posture shows up in &lt;a href="https://iurii.rogulia.fi/blog/rewrite-vs-stabilise-decision" rel="noopener noreferrer"&gt;the rewrite-or-stabilise decision&lt;/a&gt; — the honest answer is the conditional one.&lt;/p&gt;

&lt;p&gt;And here is the filter, stated plainly: the client who wants a single small number &lt;em&gt;no matter what the work is&lt;/em&gt; — who hears the range and pushes for "just give me one number, just tell me two weeks" — is asking to be lied to. They will get their single small number from someone, because someone will always say what a client wants to hear. That someone will then be late, and the client will be surprised, and the cycle repeats with the next contractor. If you, as a buyer, punish honest ranges and reward confident single numbers, you are training your suppliers to deceive you, and you will get exactly the estimates you've selected for.&lt;/p&gt;

&lt;p&gt;The engineer who refuses to compress an honest range into a comforting lie is the one worth keeping. The discomfort of hearing "three to five weeks, here's why" is the price of a number that comes true. I'd rather lose the engagement at the estimate than lose your trust in week six — and a contractor who feels the same way is the one you want holding your timeline.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Two weeks" is the happy path.&lt;/strong&gt; The iceberg below — validation, error states, auth, migrations, review, QA — is most of the real work and none of the mental movie. Estimate the iceberg, not the demo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Effort is not duration.&lt;/strong&gt; Ideal engineering hours convert to calendar time at roughly half; say which currency your number is in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can't estimate unknown-unknowns away&lt;/strong&gt; — but you can estimate that they exist, which is what a range is for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give ranges with meaning attached&lt;/strong&gt;, not single numbers. The distance between P50 and P90 is an honest measure of what you don't yet know.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decompose to day-sized pieces.&lt;/strong&gt; What won't decompose is a research spike — time-box the learning, then estimate the build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate the estimate from the commitment.&lt;/strong&gt; Engineering owns the honest guess; the business owns the buffered promise. Make the buffer visible, don't hide it in a secret multiplier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Track estimate vs actual and trust history over gut.&lt;/strong&gt; Your own multiplier on auth, or on third-party APIs, beats fresh reasoning every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-estimate at milestones.&lt;/strong&gt; The cone of uncertainty narrows as you learn; a stale estimate nobody updated is functionally a lie.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name your assumptions next to the number.&lt;/strong&gt; An estimate without its assumptions can't be interrogated, only resented.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The honest filter:&lt;/strong&gt; a contractor who gives you a range with its risk is the one to keep. The client who demands a single small number regardless of the work is training their suppliers to lie.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>fractionalcto</category>
      <category>projectmanagement</category>
      <category>softwareestimation</category>
      <category>engineeringpractice</category>
    </item>
    <item>
      <title>Resistant AI Alternative: PDF Tamper Detection API</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Wed, 22 Jul 2026 10:00:35 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/resistant-ai-alternative-pdf-tamper-detection-api-4lbn</link>
      <guid>https://dev.to/iurii_rogulia/resistant-ai-alternative-pdf-tamper-detection-api-4lbn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/resistant-ai-alternative-pdf-fraud-detection" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt;. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you are searching for a Resistant AI alternative, you are usually in one of two situations. Either you priced out Resistant AI and found that the enterprise contract, the managed onboarding, or the procurement timeline does not fit your stage, or you already use it and want a lighter, self-serve building block for one specific part of your pipeline. This article is honest about both, and about where Resistant AI is the better choice.&lt;/p&gt;

&lt;p&gt;HTPBE is not a drop-in replacement for everything Resistant AI does. Resistant AI is a broad financial-crime platform. HTPBE solves one narrower piece — the structural integrity of a PDF’s bytes — and it solves it as a self-serve API you can wire into any workflow, today.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Resistant AI Does — and Who It Is Built For
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://resistant.ai/" rel="noopener noreferrer"&gt;Resistant AI&lt;/a&gt; is a document-forensics and financial-crime company. Its document product uses machine learning trained on a very large document corpus to detect manipulation across many document types — bank statements, utility bills, tax forms, certificates of incorporation, invoices, and more. It analyses both how a document is built and signals that point to content-level manipulation, and it markets detection of AI-generated and synthetic documents alongside classic edits.&lt;/p&gt;

&lt;p&gt;Around that document layer, Resistant AI has built a broader platform: a transaction-monitoring product aimed at money-laundering and payment fraud, and an enterprise delivery model. New customers are typically assigned a customer success manager who handles discovery, technical setup, training, and interpretation of early results. The buyer is a bank, payment processor, insurer, or large fintech with a dedicated fraud-operations team and the budget for a managed, sales-led engagement.&lt;/p&gt;

&lt;p&gt;That is a coherent, well-built platform for that buyer. If you process tens of thousands of documents a month, need content-level and AI-generated forgery detection plus transaction monitoring in one managed system, and have a fraud team to run it, Resistant AI is squarely in its lane — and HTPBE is not trying to take that lane.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why People Look for a Resistant AI Alternative
&lt;/h2&gt;

&lt;p&gt;The search term “Resistant AI alternative” is almost always driven by one of these reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You only need the structural-PDF piece.&lt;/strong&gt; You already have identity, income, and transaction tooling. You want tampered-PDF detection without buying an entire financial-crime platform on top.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You cannot justify enterprise procurement yet.&lt;/strong&gt; You are a 30-to-150-person lender, insurer, or platform, and a multi-month sales cycle with a managed-onboarding contract is the wrong shape for your stage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You are a developer who wants an API, not a managed deployment.&lt;/strong&gt; You are building the product, and you need a call your own code branches on — not a platform a success manager onboards you into.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want to prove the signal before you commit budget.&lt;/strong&gt; You want to run a few hundred documents and see the result before you sign anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of those describe you, a focused, self-serve PDF tamper detection API is a better-shaped tool than an enterprise platform. That is the gap HTPBE fills.&lt;/p&gt;

&lt;h2&gt;
  
  
  What HTPBE Is
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;HTPBE&lt;/a&gt; is a PDF tamper detection API. You send it the URL of a PDF, and it runs a structural forensic analysis of the file’s bytes — the document’s internal revision history, the software fingerprints left by whatever generated and last touched it, the consistency of internal timestamps, and the integrity of any digital signature. It returns a verdict and the named markers behind it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;intact&lt;/code&gt; — no post-creation modification was found in the file structure.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;modified&lt;/code&gt; — the file carries structural evidence of being changed after it was first created.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;inconclusive&lt;/code&gt; — the file was produced by consumer software (a word processor, an export-to-PDF tool, a phone scan), so its structural integrity cannot be established the way it can for a document generated by an institution’s own systems.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no numeric “risk score.” You get a verdict plus the specific &lt;a href="https://htpbe.tech/how" rel="noopener noreferrer"&gt;modification markers&lt;/a&gt; that produced it, so your own logic decides what to do next.&lt;/p&gt;

&lt;p&gt;To be clear about category: HTPBE is &lt;strong&gt;tamper detection, not identity verification&lt;/strong&gt;. It does not run KYC, biometric ID matching, credit checks, or income verification against a bank or payroll provider, and it does not do transaction monitoring. It does not read the numbers inside the document and tell you whether they are true. It tells you whether the file itself was structurally altered after it left its source. This is a separate question from KYC and identity verification, and it complements them: it covers a layer that identity tools do not check. For the full picture of how these layers fit together, see &lt;a href="https://htpbe.tech/blog/kyc-vs-document-forensics-pillar" rel="noopener noreferrer"&gt;KYC versus document forensics&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Comparison That Matters: Scope, Shape, and How You Buy
&lt;/h2&gt;

&lt;p&gt;For a developer or a risk lead evaluating options, the difference is less about a feature checklist and more about scope, the shape of the tool, and how you buy it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;Resistant AI&lt;/th&gt;
&lt;th&gt;HTPBE&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Primary form factor&lt;/td&gt;
&lt;td&gt;Managed enterprise platform&lt;/td&gt;
&lt;td&gt;Developer-first REST API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Detection scope&lt;/td&gt;
&lt;td&gt;Structural + content-level + AI-generated + transaction monitoring&lt;/td&gt;
&lt;td&gt;Structural PDF tamper detection only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivery model&lt;/td&gt;
&lt;td&gt;Sales-led, customer-success onboarding&lt;/td&gt;
&lt;td&gt;Self-serve — instant, 5 welcome credits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public pricing&lt;/td&gt;
&lt;td&gt;Sales-quoted&lt;/td&gt;
&lt;td&gt;Yes — published, self-serve + pay-per-check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical buyer&lt;/td&gt;
&lt;td&gt;Banks, processors, large fintechs&lt;/td&gt;
&lt;td&gt;Lenders, insurers, HR &amp;amp; legal tech, AP teams of any size&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time to first result&lt;/td&gt;
&lt;td&gt;Onboarding into the platform&lt;/td&gt;
&lt;td&gt;Minutes — first real call after signup&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest read of this table: if you need a broad, managed financial-crime platform with content-level and AI-generated detection plus transaction monitoring, those rows favour Resistant AI. If you want the structural-PDF layer as a self-serve building block you integrate yourself, with transparent pricing and no onboarding gate, they favour HTPBE.&lt;/p&gt;

&lt;h2&gt;
  
  
  Different Scope, Stated Plainly
&lt;/h2&gt;

&lt;p&gt;This is the most important section to read before choosing, because the two tools do not overlap as much as a keyword match suggests.&lt;/p&gt;

&lt;p&gt;Resistant AI’s document forensics is &lt;strong&gt;broad and content-aware&lt;/strong&gt;. It is designed to reason about manipulation at the content level — what the document shows — and to flag AI-generated and synthetic documents, on top of structural signals. Combined with its transaction product, it spans a large slice of the financial-crime problem.&lt;/p&gt;

&lt;p&gt;HTPBE is &lt;strong&gt;narrow and structural by design&lt;/strong&gt;. It answers exactly one question: was this file modified after it was created? It deliberately does not attempt content-truth analysis, AI-generated-document detection, transaction monitoring, KYC/identity, or born-synthetic forgery detection. Doing one layer well, and being honest about its edges, is the point — not a limitation we are working around.&lt;/p&gt;

&lt;p&gt;So this is not “cheaper version of the same thing.” It is a different, smaller tool for a different job. The question is not which is better in the abstract; it is which layer you need right now, and how fast and cheaply you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-Vertical: The Same Attack, Outside Banking
&lt;/h2&gt;

&lt;p&gt;The reason HTPBE is not tuned to one industry is that the underlying attack is not industry-specific. A bank statement edited in a PDF editor to change a balance is the same structural event whether it lands on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;loan application&lt;/strong&gt; — see &lt;a href="https://htpbe.tech/blog/bank-statement-fraud-in-lending" rel="noopener noreferrer"&gt;bank statement fraud in personal lending&lt;/a&gt; and the &lt;a href="https://htpbe.tech/blog/kyc-pdf-blind-spot-bank-statement-fraud" rel="noopener noreferrer"&gt;KYC blind spot it slips through&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;insurance claim&lt;/strong&gt; — an altered claim or invoice that passes manual review.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;HR onboarding flow&lt;/strong&gt; — a falsified payslip submitted to a recruiter.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;accounts-payable queue&lt;/strong&gt; — a tampered invoice before payment.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;legal matter&lt;/strong&gt; — an exhibit or contract edited after signing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same HTPBE API call covers all of them, because the structural analysis does not care what the document claims to be — it reads the file format.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;inconclusive&lt;/code&gt; Verdict — A Routing Signal, Not a Dead End
&lt;/h2&gt;

&lt;p&gt;When HTPBE returns &lt;code&gt;inconclusive&lt;/code&gt;, it is not saying “the tool couldn’t decide.” It is making a specific, useful statement: this file was produced by consumer software, so it was not generated by the kind of institutional system that issues an authoritative bank statement, payslip, or tax form.&lt;/p&gt;

&lt;p&gt;For a lending or insurance intake, that is high-value. If an applicant uploads something that claims to be a bank statement but the file was built in a word processor or a generic export-to-PDF tool, &lt;code&gt;inconclusive&lt;/code&gt; is the cue to route it to manual review or to ask for the statement through a direct bank connection. You are not rejecting anyone — you are &lt;a href="https://htpbe.tech/blog/what-inconclusive-means-pdf-verification-results" rel="noopener noreferrer"&gt;routing on a clear signal&lt;/a&gt; instead of taking a consumer-software document at face value.&lt;/p&gt;

&lt;p&gt;The mistake teams make on day one is treating &lt;code&gt;inconclusive&lt;/code&gt; as a pass. For a document that claims institutional origin, it deserves the same caution as &lt;code&gt;modified&lt;/code&gt;: do not auto-accept it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integration: One Call, Your Workflow
&lt;/h2&gt;

&lt;p&gt;HTPBE is an API, so integration is a single request. Submit a PDF for analysis:&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.com/applicant-statement.pdf"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That returns a top-level &lt;code&gt;id&lt;/code&gt;. Retrieve the full result with &lt;code&gt;GET /result/{id}&lt;/code&gt; and branch on the verdict in your own intake logic — the pattern is identical whether the document is a loan file, a claim, or a new-hire payroll form:&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;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;screen_document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Structural tamper check on an applicant-submitted PDF.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;analyze&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.htpbe.tech/v1/analyze&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;document_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;uid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.htpbe.tech/v1/result/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;uid&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="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;api_key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="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;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;verdict&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;# Structural evidence of post-creation editing — route to fraud review
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&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;review&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;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;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;modification_markers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;verdict&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-software origin — ask for a bank-connected statement
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&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;re_request&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;reason&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;consumer_software_origin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&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;proceed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You submit with &lt;code&gt;POST /analyze&lt;/code&gt;, retrieve with &lt;code&gt;GET /result/{id}&lt;/code&gt;, and three branches cover the workflow. The result carries the verdict in &lt;code&gt;status&lt;/code&gt; and the named markers in &lt;code&gt;modification_markers&lt;/code&gt; — there is no managed platform to adopt and no migration. It is a layer inside the product you already run.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Resistant AI Is the Better Choice
&lt;/h2&gt;

&lt;p&gt;Building trust means saying where the other tool wins. Choose Resistant AI over HTPBE when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You need content-level and AI-generated forgery detection.&lt;/strong&gt; If your threat is documents fabricated from scratch or generated by AI — where there is no post-creation edit to find — you need a content-aware platform. That is Resistant AI’s lane, not HTPBE’s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need transaction monitoring in the same system.&lt;/strong&gt; HTPBE does not touch payments or transaction behaviour. Resistant AI bundles a transaction product; HTPBE does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want a managed, enterprise relationship.&lt;/strong&gt; At very high volumes, with SLAs, dedicated success management, and broad document-type coverage, an enterprise platform offers capabilities a self-serve API does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You are a bank or large processor with a fraud-operations team&lt;/strong&gt; built to run exactly this kind of managed system.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When HTPBE Is the Better Choice
&lt;/h2&gt;

&lt;p&gt;Choose HTPBE when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You want the structural-fraud layer as an API you control&lt;/strong&gt;, wired into your own intake instead of a managed platform.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You operate across several verticals&lt;/strong&gt; — lending, insurance, HR, AP, legal — and need one consistent structural check for all of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want self-serve, transparent pricing&lt;/strong&gt; with no onboarding gate — sign up, get &lt;a href="https://htpbe.tech/auth/signup" rel="noopener noreferrer"&gt;5 welcome credits&lt;/a&gt;, and make a real call within minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want to prove the signal before you commit budget.&lt;/strong&gt; Run a few hundred documents on a &lt;a href="https://htpbe.tech/pricing" rel="noopener noreferrer"&gt;low monthly plan&lt;/a&gt; or pay-per-check, measure how many come back &lt;code&gt;modified&lt;/code&gt; or &lt;code&gt;inconclusive&lt;/code&gt;, and decide from data rather than a sales deck.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are not mutually exclusive. A common, practical path is to deploy the structural layer first — cheaply, this week — measure how much modification it surfaces in your real pipeline, and use that data to decide whether you also need a broader content-aware platform later. The structural layer sits alongside your KYC and transaction stack, not in place of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What HTPBE Cannot Catch
&lt;/h2&gt;

&lt;p&gt;No structural tool is complete, and a comparison that hides the gaps is not honest.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Documents fabricated from scratch.&lt;/strong&gt; If someone builds a fake bank statement in design software with plausible internal details and never edits it afterwards, there may be no post-creation modification to find — the file can read as &lt;code&gt;intact&lt;/code&gt;. Detecting whether a from-scratch document’s &lt;em&gt;contents&lt;/em&gt; are truthful is a different problem, and one HTPBE does not solve. See &lt;a href="https://htpbe.tech/blog/pdf-forensics-without-original-file" rel="noopener noreferrer"&gt;forensics without the original file&lt;/a&gt; for why this gap exists. This is exactly the content-level and AI-generated territory where a platform like Resistant AI is built to operate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content-level lies in an unedited file.&lt;/strong&gt; If an applicant submits a real, unmodified statement from an account they control that simply does not reflect their true finances, structural analysis correctly returns &lt;code&gt;intact&lt;/code&gt; — because the file was not modified. Catching that needs income source-of-truth checks, which structural analysis does not provide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image-only PDFs with no structural signal.&lt;/strong&gt; A photo or scan wrapped into a PDF may lack the internal structure the analysis relies on; those typically land as &lt;code&gt;inconclusive&lt;/code&gt; rather than a confident verdict.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These limits are exactly why HTPBE positions itself as one layer — the structural-PDF layer — rather than an end-to-end fraud platform. It catches the most common and fastest-growing attack: post-creation modification of a legitimate document. If you also need content-level forgery detection, AI-generated-document detection, and transaction monitoring in one managed box, Resistant AI is built for that. If you need the structural layer as a self-serve, cross-vertical &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;API you integrate yourself&lt;/a&gt;, that is what HTPBE is for. The full product-side breakdown lives on the &lt;a href="https://htpbe.tech/compare/resistant-ai-alternative" rel="noopener noreferrer"&gt;Resistant AI alternative&lt;/a&gt; page.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>fintech</category>
      <category>api</category>
      <category>fraud</category>
    </item>
    <item>
      <title>Snappt Alternative: A Self-Serve PDF Fraud Detection API for Rental &amp; Beyond</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Tue, 21 Jul 2026 10:00:36 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/snappt-alternative-a-self-serve-pdf-fraud-detection-api-for-rental-beyond-4m98</link>
      <guid>https://dev.to/iurii_rogulia/snappt-alternative-a-self-serve-pdf-fraud-detection-api-for-rental-beyond-4m98</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/snappt-alternative-pdf-fraud-detection" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt;. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you are searching for a Snappt alternative, you are usually in one of two situations. Either you run a property-management or leasing operation that wants document fraud detection without committing to a full screening platform, or you are building something — a tenant-screening product, a lending pipeline, an HR onboarding flow — and you want the structural PDF fraud layer that Snappt does well, but as an API you control. This article is written for the second case, and it is honest about the first.&lt;/p&gt;

&lt;p&gt;HTPBE is not a drop-in replacement for everything Snappt does. Snappt is a multifamily-rental platform with a leasing-team dashboard, income verification, identity checks, and human review. HTPBE solves one narrower piece of that picture — structural PDF tamper detection — and it solves it as a self-serve API you can wire into any workflow in any vertical, today.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Snappt Does — and Who It Is Built For
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://snappt.com/" rel="noopener noreferrer"&gt;Snappt&lt;/a&gt; is a fraud-detection and income-verification product built for the &lt;strong&gt;property-management and multifamily-rental&lt;/strong&gt; industry. Its core job is to screen rental applications for falsified financial documents — typically pay stubs and bank statements that applicants edit to look like they earn more than they do.&lt;/p&gt;

&lt;p&gt;Around that core, Snappt has assembled a leasing-focused platform: document fraud detection (analysing metadata and running authenticity checks), income verification through connected payroll and bank sources, identity and rental-history checks, and a workflow that pairs automated detection with human review and a dashboard for leasing teams. It sells to property managers and leasing operators, and it is designed to plug into the leasing process rather than sit behind a developer’s API.&lt;/p&gt;

&lt;p&gt;That is a coherent, well-built product for its buyer. If you are a regional property-management company that wants a turnkey applicant-screening system with a UI your leasing agents log into, Snappt is squarely in its lane and HTPBE is not trying to take that lane.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why People Look for a Snappt Alternative
&lt;/h2&gt;

&lt;p&gt;The search term “Snappt alternative” is almost always driven by one of these reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You only need the document-fraud piece.&lt;/strong&gt; You already have your own screening flow, your own identity provider, or your own income data — and you want the tampered-PDF detection without buying an entire leasing platform on top.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You are not in rental at all.&lt;/strong&gt; The same falsified bank statement that shows up on a rental application also shows up on a loan application, an insurance claim, an expense report, and a new-hire payroll form. A rental-only platform is the wrong shape for a lending or HR workflow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You are a developer who wants an API, not a dashboard.&lt;/strong&gt; You are building the product, and you need a programmatic call that returns a result your own code can branch on — not a portal a human logs into.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want to start small and prove value before committing.&lt;/strong&gt; You want to run a few hundred documents and see the signal before you sign anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of those describe you, a structural PDF tamper detection API is a better-shaped tool than a rental screening platform. That is the gap HTPBE fills.&lt;/p&gt;

&lt;h2&gt;
  
  
  What HTPBE Is
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;HTPBE&lt;/a&gt; is a PDF tamper detection API. You send it the URL of a PDF, and it runs a structural forensic analysis of the file’s bytes — the document’s internal revision history, the software fingerprints left by whatever generated and last touched it, the consistency of internal timestamps, and the integrity of any digital signature. It returns a verdict and the named markers behind it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;intact&lt;/code&gt; — no post-creation modification was found in the file structure.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;modified&lt;/code&gt; — the file carries structural evidence of being changed after it was first created.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;inconclusive&lt;/code&gt; — the file was produced by consumer software (a word processor, an export-to-PDF tool, a phone scan), so its structural integrity cannot be established the way it can for a document generated by an institution’s own systems.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no numeric “risk score.” You get a verdict plus the specific &lt;a href="https://htpbe.tech/how" rel="noopener noreferrer"&gt;modification markers&lt;/a&gt; that produced it, so your own logic decides what to do next.&lt;/p&gt;

&lt;p&gt;To be clear about category: HTPBE is &lt;strong&gt;tamper detection, not identity verification&lt;/strong&gt;. It does not do tenant identity checks, credit checks, biometric ID matching, or income verification against a bank or payroll provider. It does not read the numbers inside the document and tell you whether they are true. It tells you whether the file itself was structurally altered after it left its source. That is a different and complementary question from the KYC/identity category — and an important layer that identity tools do not cover.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Comparison That Matters: Shape and Buying Experience
&lt;/h2&gt;

&lt;p&gt;For a developer or a risk lead evaluating options, the difference is less about a feature checklist and more about the shape of the tool and how you buy it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;Snappt&lt;/th&gt;
&lt;th&gt;HTPBE&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Primary form factor&lt;/td&gt;
&lt;td&gt;Leasing platform + dashboard&lt;/td&gt;
&lt;td&gt;Developer-first REST API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Industry focus&lt;/td&gt;
&lt;td&gt;Multifamily / rental&lt;/td&gt;
&lt;td&gt;Cross-vertical (rental, lending, insurance, HR, AP, legal)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scope&lt;/td&gt;
&lt;td&gt;Doc fraud + income + identity + human review&lt;/td&gt;
&lt;td&gt;Structural PDF tamper detection only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-serve signup&lt;/td&gt;
&lt;td&gt;Built for property-management onboarding&lt;/td&gt;
&lt;td&gt;Yes — instant, 5 welcome credits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public pricing&lt;/td&gt;
&lt;td&gt;Per-unit, rental-oriented&lt;/td&gt;
&lt;td&gt;Yes — published, self-serve + pay-per-check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Human review service&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No — automated API only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time to first result&lt;/td&gt;
&lt;td&gt;Onboarding into the platform&lt;/td&gt;
&lt;td&gt;Minutes — first real call after signup&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest read of this table: if you want a staffed, turnkey leasing product, those rows favour Snappt. If you want a structural-fraud building block you integrate yourself, across more than one vertical, with transparent pricing and no onboarding gate, they favour HTPBE.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-Vertical: The Same Fraud, Outside Rental
&lt;/h2&gt;

&lt;p&gt;The reason HTPBE is not rental-only is that the underlying attack is not rental-only. A bank statement edited in a PDF editor to change a balance is the same structural event whether it lands on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;rental application&lt;/strong&gt; — see &lt;a href="https://htpbe.tech/blog/rental-fraud-bank-statement-pdf" rel="noopener noreferrer"&gt;how tenants falsify bank statements&lt;/a&gt; and how &lt;a href="https://htpbe.tech/blog/tenant-screening-bank-statement-fraud-saas" rel="noopener noreferrer"&gt;tenant-screening platforms add a structural layer&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;loan application&lt;/strong&gt; — &lt;a href="https://htpbe.tech/blog/bank-statement-fraud-in-lending" rel="noopener noreferrer"&gt;bank statement fraud in personal lending&lt;/a&gt; and the &lt;a href="https://htpbe.tech/blog/kyc-pdf-blind-spot-bank-statement-fraud" rel="noopener noreferrer"&gt;KYC blind spot it slips through&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;HR onboarding flow&lt;/strong&gt; — falsified &lt;a href="https://htpbe.tech/blog/payslip-fraud-hr-detection" rel="noopener noreferrer"&gt;payslips submitted to recruiters&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;insurance claim&lt;/strong&gt; — &lt;a href="https://htpbe.tech/blog/insurance-claims-pdf-fraud" rel="noopener noreferrer"&gt;altered claim PDFs that pass manual review&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;accounts-payable queue&lt;/strong&gt; — &lt;a href="https://htpbe.tech/blog/invoice-fraud-accounts-payable-pdf" rel="noopener noreferrer"&gt;tampered invoices before payment&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A rental-only platform gives you one of these. The same HTPBE API call gives you all of them, because the structural analysis does not care what the document claims to be — it reads the file format.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;inconclusive&lt;/code&gt; Verdict — A Routing Signal, Not a Dead End
&lt;/h2&gt;

&lt;p&gt;When HTPBE returns &lt;code&gt;inconclusive&lt;/code&gt;, it is not saying “the tool couldn’t decide.” It is making a specific, useful statement: this file was produced by consumer software, so it was not generated by the kind of institutional system that issues an authoritative bank statement or pay stub.&lt;/p&gt;

&lt;p&gt;For a rental or lending intake, that is high-value. If your applicant uploads something that claims to be a bank statement but the file was built in a word processor or a generic export-to-PDF tool, &lt;code&gt;inconclusive&lt;/code&gt; is the cue to route it to manual review or to ask the applicant to provide the statement through a direct bank connection. You are not rejecting anyone — you are &lt;a href="https://htpbe.tech/blog/what-inconclusive-means-pdf-verification-results" rel="noopener noreferrer"&gt;routing on a clear signal&lt;/a&gt; instead of taking a consumer-software document at face value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integration: One Call, Your Workflow
&lt;/h2&gt;

&lt;p&gt;HTPBE is an API, so integration is a single request. Submit a PDF for analysis:&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.com/applicant-statement.pdf"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then branch on the verdict in your own intake logic — this pattern is identical whether the document is a rental application, a loan file, or a new-hire payroll form:&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;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;screen_document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Structural fraud check on an applicant-submitted PDF.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;analyze&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.htpbe.tech/v1/analyze&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;document_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;uid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.htpbe.tech/v1/result/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;uid&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="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;api_key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="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;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;modified&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&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;review&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;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;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;modification_markers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;verdict&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-software origin — ask for a bank-connected statement
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&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;re_request&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;reason&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;consumer_software_origin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&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;proceed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You submit with &lt;code&gt;POST /analyze&lt;/code&gt;, retrieve with &lt;code&gt;GET /result/{id}&lt;/code&gt;, and three branches cover the workflow. There is no leasing UI to adopt and no platform to migrate onto — it is a layer inside the product you already run.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Snappt Is the Better Choice
&lt;/h2&gt;

&lt;p&gt;Building trust means saying where the other tool wins. Choose Snappt over HTPBE when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You want a turnkey leasing product.&lt;/strong&gt; If your buyers are leasing agents who need a dashboard to log into, not developers who write code, a platform is the right form factor and an API is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need income and identity verification in the same product.&lt;/strong&gt; HTPBE does not connect to payroll or banks to verify income, and it does not run identity or rental-history checks. Snappt bundles those; HTPBE does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want a human-review service.&lt;/strong&gt; Snappt pairs automated detection with human experts. HTPBE is automated only — it returns a verdict, and your team (or your own reviewers) decides what to do with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You are exclusively in multifamily rental and want a product purpose-built for that workflow.&lt;/strong&gt; Snappt is tuned for exactly that buyer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When HTPBE Is the Better Choice
&lt;/h2&gt;

&lt;p&gt;Choose HTPBE when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You want the structural-fraud layer as an API you control&lt;/strong&gt;, wired into your own intake instead of a separate portal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You operate outside rental&lt;/strong&gt;, or across several verticals, and need one consistent fraud check for lending, insurance, HR, and AP documents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want self-serve, transparent pricing&lt;/strong&gt; with no onboarding gate — sign up, get &lt;a href="https://htpbe.tech/auth/signup" rel="noopener noreferrer"&gt;5 welcome credits&lt;/a&gt;, and make a real call within minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want to prove the signal before you commit budget.&lt;/strong&gt; Run a few hundred documents on a &lt;a href="https://htpbe.tech/pricing" rel="noopener noreferrer"&gt;low monthly plan&lt;/a&gt; or pay-per-check, measure how many come back &lt;code&gt;modified&lt;/code&gt; or &lt;code&gt;inconclusive&lt;/code&gt;, and decide from data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What HTPBE Cannot Catch
&lt;/h2&gt;

&lt;p&gt;No structural tool is complete, and a comparison that hides the gaps is not honest.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Documents fabricated from scratch.&lt;/strong&gt; If someone builds a fake bank statement in design software with plausible internal details and never edits it afterwards, there may be no post-creation modification to find — the file can read as &lt;code&gt;intact&lt;/code&gt;. Detecting whether a from-scratch document’s &lt;em&gt;contents&lt;/em&gt; are truthful is a different problem (content and income verification), and one HTPBE does not solve. See &lt;a href="https://htpbe.tech/blog/pdf-forensics-without-original-file" rel="noopener noreferrer"&gt;forensics without the original file&lt;/a&gt; for why this gap exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content-level lies in an unedited file.&lt;/strong&gt; If an applicant submits a real, unmodified statement from an account they control that simply does not reflect their true finances, structural analysis correctly returns &lt;code&gt;intact&lt;/code&gt; — because the file was not modified. Catching that needs income source-of-truth checks, which is Snappt’s lane, not HTPBE’s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image-only PDFs with no structural signal.&lt;/strong&gt; A photo or scan wrapped into a PDF may lack the internal structure the analysis relies on; those typically land as &lt;code&gt;inconclusive&lt;/code&gt; rather than a confident verdict.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These limits are exactly why HTPBE positions itself as one layer — the structural-PDF layer — rather than an end-to-end fraud platform. It catches the most common and fastest-growing attack: post-creation modification of a legitimate document. If you also need income verification, identity proofing, and a leasing workflow in one box, Snappt is built for that. If you need the structural layer as a self-serve, cross-vertical &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;API you integrate yourself&lt;/a&gt;, that is what HTPBE is for. The full product-side breakdown lives on the &lt;a href="https://htpbe.tech/compare/snappt-alternative" rel="noopener noreferrer"&gt;Snappt alternative&lt;/a&gt; page.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>fintech</category>
      <category>api</category>
      <category>fraud</category>
    </item>
    <item>
      <title>PDF Tamper Detection API for Java: Spring Boot Integration Guide</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 20 Jul 2026 11:00:37 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/pdf-tamper-detection-api-for-java-spring-boot-integration-guide-28bf</link>
      <guid>https://dev.to/iurii_rogulia/pdf-tamper-detection-api-for-java-spring-boot-integration-guide-28bf</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/pdf-verification-java-spring-boot-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, lending, banking, and insurance the backend runs on the JVM. A Spring Boot 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 &lt;code&gt;@RestController&lt;/code&gt; has returned &lt;code&gt;201&lt;/code&gt;, the document’s claims have already propagated into your business logic. Your KYC provider verified that 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 a Spring Boot application — from the first curl command to an idiomatic &lt;code&gt;@Service&lt;/code&gt; built on Spring’s &lt;code&gt;RestClient&lt;/code&gt;, with a typed &lt;code&gt;record&lt;/code&gt; DTO, &lt;code&gt;@ConfigurationProperties&lt;/code&gt; for the API key, 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 Spring Boot 3.2+ / Spring Framework 6.1+ (where &lt;code&gt;RestClient&lt;/code&gt; is stable); a &lt;code&gt;WebClient&lt;/code&gt; variant is included for reactive stacks. 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-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 check id, &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 &lt;code&gt;RestClient&lt;/code&gt; and two method calls. No extra dependency beyond &lt;code&gt;spring-boot-starter-web&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Production shape: a typed &lt;code&gt;record&lt;/code&gt; DTO, &lt;code&gt;@ConfigurationProperties&lt;/code&gt; for the key, a custom &lt;code&gt;ResponseErrorHandler&lt;/code&gt; that maps status codes to a typed exception, and Spring Retry 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;Java 17+ (records, sealed types, switch expressions) — Java 21 if you want virtual threads for batch fan-out&lt;/li&gt;
&lt;li&gt;Spring Boot 3.2+ (&lt;code&gt;RestClient&lt;/code&gt; is stable from 3.2; the &lt;code&gt;WebClient&lt;/code&gt; path works on any 3.x)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;spring-boot-starter-web&lt;/code&gt; on the classpath&lt;/li&gt;
&lt;li&gt;An HTPBE API key (Dashboard → copy key)&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 Java, 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: &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;"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.&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: Configuration Properties
&lt;/h2&gt;

&lt;p&gt;Keep the key and base URL out of code. Bind them with &lt;code&gt;@ConfigurationProperties&lt;/code&gt; so they are typed, validated at startup, and overridable per environment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;com.example.htpbe&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.boot.context.properties.ConfigurationProperties&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.validation.annotation.Validated&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;jakarta.validation.constraints.NotBlank&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.time.Duration&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@Validated&lt;/span&gt;
&lt;span class="nd"&gt;@ConfigurationProperties&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"htpbe"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;HtpbeProperties&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nd"&gt;@NotBlank&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;baseUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Duration&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;maxRetries&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;HtpbeProperties&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Defaults applied to the compact constructor so a partial&lt;/span&gt;
        &lt;span class="c1"&gt;// configuration block still produces a usable instance.&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;baseUrl&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;baseUrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;baseUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://api.htpbe.tech/v1"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&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;timeout&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofSeconds&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&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;maxRetries&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;maxRetries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="c1"&gt;// Tolerate a trailing slash so either form of the base URL works.&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;baseUrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;endsWith&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;baseUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;baseUrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;substring&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baseUrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable binding and validation on your configuration class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Configuration&lt;/span&gt;
&lt;span class="nd"&gt;@EnableConfigurationProperties&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HtpbeProperties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HtpbeConfig&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And supply the values in &lt;code&gt;application.yml&lt;/code&gt; — the key resolves from an environment variable so it never lands in source control:&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_API_KEY}&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&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;35s&lt;/span&gt;
  &lt;span class="na"&gt;max-retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@NotBlank&lt;/code&gt; on &lt;code&gt;apiKey&lt;/code&gt; means the application context fails to start if &lt;code&gt;HTPBE_API_KEY&lt;/code&gt; is unset — a misconfigured deployment is caught at boot, not on the first document that arrives at 2 a.m.&lt;/p&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 a Java &lt;code&gt;record&lt;/code&gt;. Jackson maps &lt;code&gt;snake_case&lt;/code&gt; JSON to the record components when you enable &lt;code&gt;@JsonNaming(SnakeCaseStrategy.class)&lt;/code&gt; (or set &lt;code&gt;spring.jackson.property-naming-strategy: SNAKE_CASE&lt;/code&gt; globally). Configure your &lt;code&gt;ObjectMapper&lt;/code&gt; to ignore unknown properties so a newly added API field never breaks deserialization. Nullable fields are reference types so “absent” stays distinguishable from a genuine zero.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;com.example.htpbe&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.fasterxml.jackson.databind.annotation.JsonNaming&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.util.List&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@JsonNaming&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SnakeCaseStrategy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;AnalysisResult&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;fileSize&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Integer&lt;/span&gt; &lt;span class="n"&gt;pageCount&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;

        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;algorithmVersion&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;currentAlgorithmVersion&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;outdatedWarning&lt;/span&gt;&lt;span class="o"&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="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&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="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;statusReason&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;

        &lt;span class="nc"&gt;Origin&lt;/span&gt; &lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;

        &lt;span class="c1"&gt;// "certain" | "high" | "none" | null&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;modificationConfidence&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;

        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;creator&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;producer&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;creationDate&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;// Unix seconds&lt;/span&gt;
        &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;modificationDate&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;// Unix seconds&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;pdfVersion&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;

        &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="n"&gt;dateSequenceValid&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Integer&lt;/span&gt; &lt;span class="n"&gt;metadataCompletenessScore&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;

        &lt;span class="nc"&gt;Integer&lt;/span&gt; &lt;span class="n"&gt;xrefCount&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="n"&gt;hasIncrementalUpdates&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Integer&lt;/span&gt; &lt;span class="n"&gt;updateChainLength&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;

        &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="n"&gt;hasDigitalSignature&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Integer&lt;/span&gt; &lt;span class="n"&gt;signatureCount&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="n"&gt;signatureRemoved&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="n"&gt;modificationsAfterSignature&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;

        &lt;span class="nc"&gt;Integer&lt;/span&gt; &lt;span class="n"&gt;objectCount&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="n"&gt;hasJavascript&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="n"&gt;hasEmbeddedFiles&lt;/span&gt;&lt;span class="o"&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="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;modificationMarkers&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@JsonNaming&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SnakeCaseStrategy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;Origin&lt;/span&gt;&lt;span class="o"&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="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;software&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;isIntact&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"intact"&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&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="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;isModified&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"modified"&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&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="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;isInconclusive&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"inconclusive"&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&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="o"&gt;}&lt;/span&gt;
&lt;span class="o"&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;. 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 short 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 java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;INTACT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;MODIFIED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;INCONCLUSIVE&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt; &lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;switch&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="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"intact"&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;INTACT&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"modified"&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;MODIFIED&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"inconclusive"&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;INCONCLUSIVE&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="o"&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="nc"&gt;IllegalArgumentException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"unknown status: "&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="o"&gt;};&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&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-2xx 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 java"&gt;&lt;code&gt;&lt;span class="kn"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;com.example.htpbe&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HtpbeApiException&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;RuntimeException&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// machine-readable code from the JSON body&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Integer&lt;/span&gt; &lt;span class="n"&gt;retryAfterSeconds&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// parsed from Retry-After on 429; null if absent&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;HtpbeApiException&lt;/span&gt;&lt;span class="o"&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="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                             &lt;span class="nc"&gt;Integer&lt;/span&gt; &lt;span class="n"&gt;retryAfterSeconds&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;super&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"htpbe: %d %s: %s"&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;formatted&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="o"&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;message&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&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="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;retryAfterSeconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;retryAfterSeconds&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;statusCode&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;code&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Integer&lt;/span&gt; &lt;span class="nf"&gt;retryAfterSeconds&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;retryAfterSeconds&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="cm"&gt;/**
     * Only 5xx and 429 are transient. Every other 4xx is permanent —
     * retrying it burns latency and, for 402, can never succeed until
     * the account is topped up.
     */&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;retryable&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;statusCode&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;statusCode&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="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Here is the complete &lt;code&gt;@Service&lt;/code&gt; on &lt;code&gt;RestClient&lt;/code&gt;. A custom &lt;code&gt;ResponseErrorHandler&lt;/code&gt; converts every non-2xx response into an &lt;code&gt;HtpbeApiException&lt;/code&gt;, parsing 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 java"&gt;&lt;code&gt;&lt;span class="kn"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;com.example.htpbe&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.fasterxml.jackson.databind.JsonNode&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.fasterxml.jackson.databind.ObjectMapper&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.http.HttpStatusCode&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.http.client.ClientHttpResponse&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.stereotype.Service&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.web.client.ResponseErrorHandler&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.web.client.RestClient&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.io.IOException&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.nio.charset.StandardCharsets&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.time.ZonedDateTime&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.time.format.DateTimeFormatter&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.time.format.DateTimeParseException&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.time.Instant&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.util.List&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.util.Map&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HtpbeClient&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;RestClient&lt;/span&gt; &lt;span class="n"&gt;restClient&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ObjectMapper&lt;/span&gt; &lt;span class="n"&gt;objectMapper&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;HtpbeClient&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HtpbeProperties&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                       &lt;span class="nc"&gt;RestClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Builder&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                       &lt;span class="nc"&gt;ObjectMapper&lt;/span&gt; &lt;span class="n"&gt;objectMapper&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;objectMapper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;objectMapper&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;restClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defaultHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Authorization"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Bearer "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defaultHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Accept"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"application/json"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defaultStatusHandler&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;HtpbeErrorHandler&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;objectMapper&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="cm"&gt;/**
     * Submits a PDF URL and returns the full verdict. The two steps are kept
     * separate on purpose: POST /analyze is the billable, job-creating call,
     * GET /result/{id} is a free read. Retry policy (Step 6) wraps each step
     * independently so a transient read failure never re-submits — and never
     * re-bills — a fresh analysis.
     *
     * @param originalFilename optional; pass it so the result's `filename`
     *                         shows a human-readable name, not a storage key.
     */&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;AnalysisResult&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;submitAnalysis&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getResult&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;submitAnalysis&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;originalFilename&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"url"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"url"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"original_filename"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;JsonNode&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;restClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;post&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/analyze"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;retrieve&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;JsonNode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;);&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;node&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&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="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"BAD_RESPONSE"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="s"&gt;"analyze response missing id"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;asText&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;AnalysisResult&lt;/span&gt; &lt;span class="nf"&gt;getResult&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;restClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/result/{id}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;retrieve&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AnalysisResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="cm"&gt;/** Maps every non-2xx response to a typed HtpbeApiException. */&lt;/span&gt;
    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HtpbeErrorHandler&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ResponseErrorHandler&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

        &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ObjectMapper&lt;/span&gt; &lt;span class="n"&gt;mapper&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

        &lt;span class="nc"&gt;HtpbeErrorHandler&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ObjectMapper&lt;/span&gt; &lt;span class="n"&gt;mapper&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;mapper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mapper&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="nd"&gt;@Override&lt;/span&gt;
        &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;hasError&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ClientHttpResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;IOException&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStatusCode&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isError&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="nd"&gt;@Override&lt;/span&gt;
        &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;handleError&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ClientHttpResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;IOException&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;HttpStatusCode&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;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStatusCode&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;raw&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;String&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="na"&gt;getBody&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;readAllBytes&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;StandardCharsets&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UTF_8&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

            &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"UNKNOWN"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
            &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;message&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="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="nc"&gt;JsonNode&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;mapper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;readTree&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="o"&gt;);&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;body&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasNonNull&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"code"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;asText&lt;/span&gt;&lt;span class="o"&gt;();&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;body&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hasNonNull&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;asText&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;ignore&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="c1"&gt;// body was not JSON — keep the status-derived defaults&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;

            &lt;span class="nc"&gt;Integer&lt;/span&gt; &lt;span class="n"&gt;retryAfter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
            &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;sc&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="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"invalid API key — check HTPBE_API_KEY"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
                &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;402&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&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="o"&gt;;&lt;/span&gt;
                &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&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="o"&gt;;&lt;/span&gt;
                &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;413&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"PDF exceeds the 10 MB size limit"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
                &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;422&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"the URL did not return a valid PDF file"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
                &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;retryAfter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parseRetryAfter&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="na"&gt;getHeaders&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getFirst&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Retry-After"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
                &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* keep parsed message */&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="o"&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="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="o"&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;message&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retryAfter&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="cm"&gt;/**
         * Handles both the delay-seconds form ("30") and the HTTP-date form,
         * clamped to [1, 600]. Returns null when absent or unparseable so the
         * caller can fall back to its own backoff.
         */&lt;/span&gt;
        &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;Integer&lt;/span&gt; &lt;span class="nf"&gt;parseRetryAfter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&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;header&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Integer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parseInt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;trim&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NumberFormatException&lt;/span&gt; &lt;span class="n"&gt;ignore&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="c1"&gt;// not a plain number — try HTTP-date&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="nc"&gt;ZonedDateTime&lt;/span&gt; &lt;span class="n"&gt;when&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ZonedDateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                        &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;DateTimeFormatter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;RFC_1123_DATE_TIME&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;secs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toEpochSecond&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getEpochSecond&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;secs&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DateTimeParseException&lt;/span&gt; &lt;span class="n"&gt;ignore&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&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 the handler parses (both &lt;code&gt;delay-seconds&lt;/code&gt; and &lt;code&gt;HTTP-date&lt;/code&gt; 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: Retry on Transient Failures Only
&lt;/h2&gt;

&lt;p&gt;The error handler classifies failures; the retry policy acts on that classification. Spring Retry (&lt;code&gt;spring-retry&lt;/code&gt; plus &lt;code&gt;@EnableRetry&lt;/code&gt;) is the idiomatic fit. Retry &lt;strong&gt;only&lt;/strong&gt; the billable &lt;code&gt;submitAnalysis&lt;/code&gt; step and &lt;strong&gt;only&lt;/strong&gt; when the exception is &lt;code&gt;retryable()&lt;/code&gt; — never wrap the whole &lt;code&gt;verify&lt;/code&gt; in one retry, or a transient &lt;code&gt;getResult&lt;/code&gt; failure would replay the POST and bill a second analysis.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;com.example.htpbe&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.retry.annotation.Backoff&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.retry.annotation.Retryable&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.stereotype.Component&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HtpbeVerificationService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;HtpbeClient&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;HtpbeVerificationService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HtpbeClient&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;AnalysisResult&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// submit (billable, retried) → read (free, retried separately)&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;submitWithRetry&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;readWithRetry&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Retryable&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;retryFor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HtpbeApiException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="c1"&gt;// Only retry when the exception says it is transient.&lt;/span&gt;
            &lt;span class="c1"&gt;// exceptionExpression evaluates retryable() on the thrown instance.&lt;/span&gt;
            &lt;span class="n"&gt;exceptionExpression&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"#{#root instanceof T(com.example.htpbe.HtpbeApiException) "&lt;/span&gt;
                    &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"&amp;amp;&amp;amp; #root.retryable()}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;maxAttempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;@Backoff&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;multiplier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;submitWithRetry&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;submitAnalysis&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Retryable&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;retryFor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HtpbeApiException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;exceptionExpression&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"#{#root instanceof T(com.example.htpbe.HtpbeApiException) "&lt;/span&gt;
                    &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"&amp;amp;&amp;amp; #root.retryable()}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;maxAttempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;@Backoff&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;multiplier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
    &lt;span class="nc"&gt;AnalysisResult&lt;/span&gt; &lt;span class="nf"&gt;readWithRetry&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getResult&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spring Retry’s annotation backoff is fixed at configuration time, so the &lt;code&gt;@Backoff&lt;/code&gt; above applies a generic exponential curve. If you want to honour the server’s exact &lt;code&gt;Retry-After&lt;/code&gt; on a 429, drop the annotation in favour of a programmatic &lt;code&gt;RetryTemplate&lt;/code&gt; whose &lt;code&gt;BackOffPolicy&lt;/code&gt; reads &lt;code&gt;HtpbeApiException.retryAfterSeconds()&lt;/code&gt; from the last failure — the typed exception already carries the parsed value, clamped to a safe range. For most integrations the annotation form is enough; the server-supplied delay matters most under sustained capacity pressure.&lt;/p&gt;

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

&lt;p&gt;The service 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 java"&gt;&lt;code&gt;&lt;span class="kn"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;com.example.htpbe&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DocumentGate&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="no"&gt;ACCEPT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;REJECT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;REVIEW&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;DocumentGate&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="cm"&gt;/**
     * Maps a verdict to a decision for documents that claim institutional
     * origin (bank statements, payslips, diplomas). For these, "inconclusive"
     * is treated as strictly as "modified": a document that should have come
     * from a bank's own system but looks like it was built in Word does not
     * get the benefit of the doubt.
     */&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt; &lt;span class="nf"&gt;forInstitutional&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AnalysisResult&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;switch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;MODIFIED&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;REJECT&lt;/span&gt;&lt;span class="o"&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="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;INCONCLUSIVE&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;REVIEW&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;INTACT&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ACCEPT&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;};&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire the service and the gate into a controller 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 java"&gt;&lt;code&gt;&lt;span class="kn"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;com.example.htpbe&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.http.HttpStatus&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.http.ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.web.bind.annotation.*&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.util.List&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.util.Map&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@RestController&lt;/span&gt;
&lt;span class="nd"&gt;@RequestMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/documents"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DocumentController&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;HtpbeVerificationService&lt;/span&gt; &lt;span class="n"&gt;verification&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;DocumentController&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HtpbeVerificationService&lt;/span&gt; &lt;span class="n"&gt;verification&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;verification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;verification&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;VerifyRequest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;documentUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@PostMapping&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@RequestBody&lt;/span&gt; &lt;span class="nc"&gt;VerifyRequest&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&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;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;documentUrl&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;documentUrl&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;badRequest&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"document_url is required"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="nc"&gt;AnalysisResult&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;verification&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;documentUrl&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;originalFilename&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HtpbeApiException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&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="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;switch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DocumentGate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forInstitutional&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;REJECT&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unprocessableEntity&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="s"&gt;"decision"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"reject"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="s"&gt;"reason"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"document modified after creation"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="s"&gt;"modification_markers"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;modificationMarkers&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;REVIEW&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;accepted&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="s"&gt;"decision"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"review"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="s"&gt;"status_reason"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;statusReason&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;ACCEPT&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="s"&gt;"decision"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"accept"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="s"&gt;"check_id"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
        &lt;span class="o"&gt;};&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;mapApiError&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HtpbeApiException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;switch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&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="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;402&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SERVICE_UNAVAILABLE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"verification temporarily unavailable"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;422&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unprocessableEntity&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"the URL did not return a valid PDF"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;413&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PAYLOAD_TOO_LARGE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"PDF must be under 10 MB"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
            &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;BAD_GATEWAY&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"verification failed"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="o"&gt;};&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&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: The Reactive Variant (WebClient)
&lt;/h2&gt;

&lt;p&gt;If your service is built on Spring WebFlux, swap &lt;code&gt;RestClient&lt;/code&gt; for &lt;code&gt;WebClient&lt;/code&gt; and return a &lt;code&gt;Mono&amp;lt;AnalysisResult&amp;gt;&lt;/code&gt;. The two-step flow becomes a &lt;code&gt;flatMap&lt;/code&gt;, and &lt;code&gt;onStatus&lt;/code&gt; plays the role the &lt;code&gt;ResponseErrorHandler&lt;/code&gt; played above. The retry semantics are the same: retry the submit and the read independently, and only on a &lt;code&gt;retryable()&lt;/code&gt; exception.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;com.example.htpbe&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.stereotype.Service&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.web.reactive.function.client.WebClient&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;reactor.core.publisher.Mono&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;reactor.util.retry.Retry&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.time.Duration&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.util.Map&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReactiveHtpbeClient&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;WebClient&lt;/span&gt; &lt;span class="n"&gt;webClient&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;ReactiveHtpbeClient&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HtpbeProperties&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;WebClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Builder&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;webClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defaultHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Authorization"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Bearer "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Mono&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AnalysisResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;retryWhen&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transientBackoff&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;     &lt;span class="c1"&gt;// billable step&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;flatMap&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;retryWhen&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transientBackoff&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt; &lt;span class="c1"&gt;// free read&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Mono&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;originalFilename&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"url"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"url"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"original_filename"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;webClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;post&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/analyze"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bodyValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;retrieve&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isError&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createException&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HtpbeApiException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                                &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="s"&gt;"ERROR"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bodyToMono&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Mono&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AnalysisResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;webClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/result/{id}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;retrieve&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isError&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createException&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HtpbeApiException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                                &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="s"&gt;"ERROR"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bodyToMono&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AnalysisResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Retry&lt;/span&gt; &lt;span class="nf"&gt;transientBackoff&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Retry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;backoff&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofSeconds&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;HtpbeApiException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;retryable&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reactive path is worth it only if the rest of your stack is reactive. For a conventional Spring MVC service, the blocking &lt;code&gt;RestClient&lt;/code&gt; in Step 5 is simpler and easier to reason about — the 2–5 seconds the analysis takes is no worse on a platform thread than any other outbound call, and on Java 21 virtual threads remove even that cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 9: 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 java"&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="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"incoming/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;randomUUID&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;".pdf"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;s3Client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;putObject&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PutObjectRequest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bucket&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;contentType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"application/pdf"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
        &lt;span class="nc"&gt;RequestBody&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromBytes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdfBytes&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="nc"&gt;GetObjectPresignRequest&lt;/span&gt; &lt;span class="n"&gt;presignRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GetObjectPresignRequest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;signatureDuration&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofMinutes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getObjectRequest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bucket&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;presignedUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s3Presigner&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;presignGetObject&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;presignRequest&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="nc"&gt;AnalysisResult&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;verification&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;presignedUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same pattern works with Google Cloud Storage (&lt;code&gt;Storage.signUrl&lt;/code&gt;), Azure Blob (a SAS token), 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 10: 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@SpringBootTest&lt;/span&gt;
&lt;span class="nd"&gt;@TestPropertySource&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;properties&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"htpbe.api-key=${HTPBE_TEST_API_KEY}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HtpbeClientIntegrationTest&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Autowired&lt;/span&gt;
    &lt;span class="nc"&gt;HtpbeVerificationService&lt;/span&gt; &lt;span class="n"&gt;verification&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;cleanDocumentReturnsIntact&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;AnalysisResult&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;verification&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"https://api.htpbe.tech/v1/test/clean.pdf"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;isEqualTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"intact"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;modificationMarkers&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DocumentGate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forInstitutional&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEqualTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DocumentGate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Decision&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ACCEPT&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;signatureRemovedIsRejected&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;AnalysisResult&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;verification&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"https://api.htpbe.tech/v1/test/signature-removed.pdf"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;isEqualTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"modified"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;signatureRemoved&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;isTrue&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DocumentGate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forInstitutional&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEqualTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DocumentGate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Decision&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;REJECT&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;inconclusiveIsRoutedToReview&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;AnalysisResult&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;verification&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"https://api.htpbe.tech/v1/test/inconclusive.pdf"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;isEqualTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"inconclusive"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;statusReason&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;isNotNull&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DocumentGate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forInstitutional&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEqualTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DocumentGate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Decision&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;REVIEW&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&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 controller and gate without any network, stub the &lt;code&gt;HtpbeVerificationService&lt;/code&gt; with Mockito and return a canned &lt;code&gt;AnalysisResult&lt;/code&gt;. Keep test and live keys in separate property 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 This Does Not Catch
&lt;/h2&gt;

&lt;p&gt;Structural analysis has honest limits, and a Spring 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. The verdict is honest about what it can and cannot establish, which is why &lt;code&gt;inconclusive&lt;/code&gt; is a routing signal, not a pass.&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 defence. 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 Spring 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 &lt;code&gt;@Async&lt;/code&gt; method or a message-driven consumer returns instantly and defers the verdict. Sync suits low-volume B2B onboarding; async suits high-volume portals. On Java 21, virtual threads make the synchronous path cheap enough that most teams never need the async one.&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/api" 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 from $15/mo — 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 Spring client branches on.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>tutorial</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Feature Flags Without LaunchDarkly: A 100-Line Solution</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 20 Jul 2026 10:00:44 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/feature-flags-without-launchdarkly-a-100-line-solution-1mpb</link>
      <guid>https://dev.to/iurii_rogulia/feature-flags-without-launchdarkly-a-100-line-solution-1mpb</guid>
      <description>&lt;p&gt;You want to merge a half-finished checkout redesign into &lt;code&gt;main&lt;/code&gt; without breaking checkout for everyone. You want to ship a risky billing change but keep a kill switch in case it misbehaves at 2am. You want to turn a new dashboard on for one beta customer and nobody else. The first instinct, reading the docs, is to reach for LaunchDarkly or Flagsmith or Split. But for a small team or an early-stage SaaS, feature flags without LaunchDarkly is not a compromise — it's about a hundred lines of code you fully own.&lt;/p&gt;

&lt;p&gt;This is the same kind of decision I keep helping founders make: which piece of infrastructure to actually buy, and which to build because the build is small and the buy is a recurring tax. Feature flags, for a team that has eight of them, land firmly on the build side. Let me show you why, and then show you the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Feature Flags Actually Buy You
&lt;/h2&gt;

&lt;p&gt;Strip away the marketing and a feature flag is one thing: a runtime switch that decides whether a piece of code runs, without redeploying. That single capability unlocks several distinct workflows, and it's worth being precise about them because they're often blurred together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decoupling deploy from release.&lt;/strong&gt; Today, for most teams, deploying code and releasing a feature are the same event — the moment the new bundle goes live, users get the new behaviour. Flags split those apart. You deploy the code dark on Tuesday, verify it's healthy in production, and flip it on for users on Thursday. The deploy is a low-stress engineering event; the release is a separate, deliberate product decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kill switches for risky code.&lt;/strong&gt; A new payment path, a rewritten search index, a third-party integration you don't fully trust yet — wrap it in a flag and you have an off switch that doesn't require a rollback deploy. When something misbehaves, you flip the flag instead of reverting commits and waiting for CI. Mean-time-to-recovery drops from "however long a deploy takes" to "a database write."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gradual percentage rollout.&lt;/strong&gt; Instead of shipping a change to 100% of users at once, you turn it on for 5%, watch your error rates and latency, then 20%, then 50%, then everyone. If something breaks, it broke for 5% of traffic, not all of it. This is the feature that's genuinely fiddly to build correctly, and most of the technical interest in this article lives here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-user and per-tenant targeting.&lt;/strong&gt; Turn a feature on for one specific beta tenant, your own internal accounts, or everyone on the enterprise plan — regardless of the rollout percentage. This is how you dogfood, how you run private betas, and how you honour "customer X explicitly asked to opt out."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trunk-based development.&lt;/strong&gt; You can merge unfinished work into &lt;code&gt;main&lt;/code&gt; behind a flag that defaults to off. The code is in the codebase, getting integrated and built continuously, but it's inert until you decide otherwise. This kills long-lived feature branches and the merge hell that comes with them.&lt;/p&gt;

&lt;p&gt;That's the value. Notice that none of it inherently requires a vendor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a SaaS Is Often the Wrong First Choice
&lt;/h2&gt;

&lt;p&gt;I want to be fair here, because LaunchDarkly is a genuinely good product and there's a point where it earns its price. But for an early-stage team, buying it first is usually backwards, for four concrete reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost that scales with the wrong thing.&lt;/strong&gt; Flag platforms price on seats and monthly active users — the very numbers a growing product wants to grow. You start paying more precisely as you succeed, for a capability whose complexity didn't change. A team with eight flags is paying a per-MAU rate for infrastructure they could express in one database table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A network dependency in your hot path.&lt;/strong&gt; This is the one engineers underestimate. Every flag evaluation is conceptually a question — "is feature X on for this user?" — and a SaaS answers it either via a remote call or via an SDK that has to initialize, stream updates, and stay in sync. Their SDKs work hard to make this fast and local, but you've still introduced a third-party system into the path of rendering your pages. When their edge has a bad day, or the SDK fails to init, you need a sane fallback — and now you're writing flag-evaluation fallback logic anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data-residency and privacy surface.&lt;/strong&gt; To do per-user targeting, the platform needs to know about your users — identifiers, attributes, sometimes more. For an EU product that's another processor in your data-flow diagram, another DPA to sign, another thing your privacy policy has to account for. Keeping flag evaluation inside your own Postgres sidesteps all of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Massively over-featured for where you are.&lt;/strong&gt; Audit logs, approval workflows, multivariate experiments, twelve-attribute segmentation, a polished UI for non-engineers — that's a lot of product. It's the right product for a 60-engineer org with PMs flipping flags. It's dead weight for a three-person team whose "flag UI" can be a &lt;code&gt;SQL UPDATE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;None of these are dealbreakers forever. They're reasons not to start there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 100-Line Solution
&lt;/h2&gt;

&lt;p&gt;Here's the whole design. A Postgres table holds the flags. A small evaluation function answers &lt;code&gt;isEnabled(key, context)&lt;/code&gt;. An in-memory cache keeps you off the database on the hot path. That's it.&lt;/p&gt;

&lt;p&gt;I'm using Postgres-backed flags as the primary version on purpose, because the entire point of a flag is to flip it &lt;strong&gt;without a deploy&lt;/strong&gt;. If your flags live in a TypeScript config file, changing one means a commit, a build, and a deploy — which defeats the kill-switch and gradual-rollout use cases. A static config is fine for the simplest case (a permanent on/off toggle that rarely changes), and I'll note that variant at the end, but the database version is the one that earns its keep.&lt;/p&gt;

&lt;h3&gt;
  
  
  The flags table
&lt;/h3&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;feature_flags&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;key&lt;/span&gt;                 &lt;span class="nb"&gt;TEXT&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;enabled&lt;/span&gt;             &lt;span class="nb"&gt;BOOLEAN&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="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;rollout_percentage&lt;/span&gt;  &lt;span class="nb"&gt;SMALLINT&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;rollout_percentage&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;enabled_tenants&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;[]&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="s1"&gt;'{}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;disabled_tenants&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;[]&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="s1"&gt;'{}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;updated_at&lt;/span&gt;          &lt;span class="n"&gt;TIMESTAMPTZ&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="n"&gt;now&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 columns map directly to the use cases above. &lt;code&gt;enabled&lt;/code&gt; is the master switch — false means off for everyone, full stop, which is your kill switch. &lt;code&gt;rollout_percentage&lt;/code&gt; drives gradual rollout. &lt;code&gt;enabled_tenants&lt;/code&gt; and &lt;code&gt;disabled_tenants&lt;/code&gt; are the targeting allow-lists: a tenant in &lt;code&gt;enabled_tenants&lt;/code&gt; gets the feature regardless of percentage, and a tenant in &lt;code&gt;disabled_tenants&lt;/code&gt; never gets it regardless of percentage. (For per-user rather than per-tenant targeting, the same columns hold user IDs — the evaluation logic is identical; pick whichever identifier your product keys on.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Deterministic percentage rollout
&lt;/h3&gt;

&lt;p&gt;This is the part worth slowing down for. The naive way to roll a flag out to 20% of users is to roll a die per request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ANTI-PATTERN: do not do this — recomputes per request&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isInRollout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;percentage&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;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;percentage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's broken in a way that's easy to miss. The same user gets a different answer on every request — feature on, page reload, feature off, reload, feature on again. The UI flickers, sessions are inconsistent, and your error rates become impossible to attribute. A 20% rollout has to mean "a stable 20% of users always get it," not "every request has a 20% chance."&lt;/p&gt;

&lt;p&gt;The fix is to make the decision a deterministic function of the user and the flag, with no randomness at request time. Hash &lt;code&gt;flagKey + userId&lt;/code&gt; into a number in &lt;code&gt;[0, 100)&lt;/code&gt; and compare it to the rollout percentage. Same user, same flag, same answer — forever, until you change the percentage. This is the same deterministic-hashing trick I used to make &lt;a href="https://iurii.rogulia.fi/blog/wrongulator-deterministic" rel="noopener noreferrer"&gt;a viral web toy wrong the same way every time&lt;/a&gt;: unpredictable to a human, perfectly reproducible from the input alone.&lt;/p&gt;

&lt;p&gt;You need a fast, well-distributed hash — not a cryptographic one, since this isn't a security boundary, just a bucketing function. FNV-1a is a good fit: tiny, fast, and spreads inputs evenly across the output range.&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;// FNV-1a, 32-bit. Small, fast, well-distributed — not cryptographic.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fnv1a&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x811c9dc5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// FNV offset basis&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;let&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;^=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// 32-bit FNV prime multiply, kept in uint32 range&lt;/span&gt;
    &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;imul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x01000193&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Map any string to a stable bucket in [0, 100).&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flagKey&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;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Salt with the flag key so a user isn't always in the same&lt;/span&gt;
  &lt;span class="c1"&gt;// percentile across every flag — otherwise the unlucky 5% of&lt;/span&gt;
  &lt;span class="c1"&gt;// user A's first rollout are the unlucky 5% of every rollout.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fnv1a&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;flagKey&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;userId&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="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The salting detail matters more than it looks. If you hash the user ID alone, the user who lands in bucket 3 is in bucket 3 for &lt;em&gt;every&lt;/em&gt; flag — so the same unlucky cohort is always first into every rollout, and your "20% of users" are always the same 20% of users across unrelated features. Mixing the flag key in re-shuffles the buckets per flag, so each rollout samples an independent slice.&lt;/p&gt;

&lt;h3&gt;
  
  
  The evaluation function
&lt;/h3&gt;

&lt;p&gt;Now the whole decision, in the order the rules apply:&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;FlagRecord&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;key&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;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;rolloutPercentage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;enabledTenants&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;disabledTenants&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;Context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;tenantId&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;// or userId — whatever you bucket on&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;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FlagRecord&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Unknown flag → off. Fail closed, never crash on a typo'd key.&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;flag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Master kill switch wins over everything.&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;flag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Explicit overrides beat the percentage roll, both directions.&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;id&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disabledTenants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enabledTenants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// No identity to bucket on → treat as a plain on/off at 100%.&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;id&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;flag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rolloutPercentage&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Deterministic percentage rollout.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flag&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="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rolloutPercentage&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;Read the order of the rules, because the order &lt;em&gt;is&lt;/em&gt; the semantics. Kill switch first, then explicit per-tenant overrides (force-off beats force-on by convention — the safer direction wins ties), then the percentage bucket. An unknown flag key returns &lt;code&gt;false&lt;/code&gt; rather than throwing: a typo in a flag name should make the feature quietly stay off, never take down the request. That fail-closed default is deliberate, and it's the kind of small decision that separates a flag system you can trust from one that becomes its own source of incidents.&lt;/p&gt;

&lt;h3&gt;
  
  
  Caching: stay off the database
&lt;/h3&gt;

&lt;p&gt;If &lt;code&gt;evaluate&lt;/code&gt; hit Postgres on every flag check, you'd add a query to the hot path of every request — exactly the network dependency I criticized the SaaS for. So you don't. Load all flags into memory once, refresh on an interval, and serve every evaluation from the in-memory snapshot.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pg&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;REFRESH_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// flips propagate within this window&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FlagStore&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;cache&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="nx"&gt;FlagRecord&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// unref so the timer never keeps the process alive on shutdown&lt;/span&gt;
    &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flag refresh failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;REFRESH_MS&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;unref&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="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;FlagRecord&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`SELECT key,
              enabled,
              rollout_percentage AS "rolloutPercentage",
              enabled_tenants    AS "enabledTenants",
              disabled_tenants   AS "disabledTenants"
       FROM feature_flags`&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;next&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="nx"&gt;FlagRecord&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;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="nx"&gt;next&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;row&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="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// atomic swap — readers never see a half-built map&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;isEnabled&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cache&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;key&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="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;Call &lt;code&gt;flags.start()&lt;/code&gt; once at boot, then &lt;code&gt;flags.isEnabled("new_checkout", { tenantId })&lt;/code&gt; anywhere — it's a synchronous map lookup plus an integer hash, with no I/O. Note the failure handling: a failed refresh logs and keeps serving the previous snapshot, so a transient database blip degrades to slightly-stale flags rather than an outage. And building the new map fully before swapping it in means a reader mid-refresh sees either the complete old state or the complete new state, never a partial one.&lt;/p&gt;

&lt;p&gt;The honest tradeoff is right there in &lt;code&gt;REFRESH_MS&lt;/code&gt;: a flag flip takes up to one refresh interval to propagate to every running instance. At 30 seconds, flipping a kill switch in SQL takes up to half a minute to fully take effect across your fleet. For most teams that's completely fine — half a minute to disable a misbehaving feature is still dramatically faster than a rollback deploy. If you genuinely need sub-second propagation, that's a real reason to want something more, which is the next section. (You can also shorten the interval or add a &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt; push to invalidate on write — but now you're adding lines, and the whole pitch was that this stays small.)&lt;/p&gt;

&lt;p&gt;That's the system. The table, the hash, the evaluation function, and the cached store come to roughly a hundred lines, and you own every one of them.&lt;/p&gt;

&lt;p&gt;
  slug="mvp-development"&lt;br&gt;
  text="Building an MVP and weighing build-versus-buy on every piece of infrastructure? Knowing which 100-line solution beats a subscription — and which doesn't — is exactly the kind of early call I help founders get right."&lt;br&gt;
/&amp;gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  When You Should Actually Buy LaunchDarkly
&lt;/h2&gt;

&lt;p&gt;I'm not going to pretend the 100-line version scales to every org, because it doesn't, and pretending otherwise would be the kind of dishonesty that makes the rest of this article less trustworthy. There's a clear line where a SaaS earns its price. You're over it when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Non-engineers need to flip flags themselves.&lt;/strong&gt; The moment a PM or a marketing lead wants to turn a feature on without a developer running SQL, you need a real UI with roles and permissions. Building and maintaining that UI is its own product — buy it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need a rich audit trail with approvals.&lt;/strong&gt; Who changed which flag, when, why, and who approved it. Regulated industries and larger orgs need this for compliance, not vanity. An &lt;code&gt;updated_at&lt;/code&gt; column doesn't cut it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Segmentation gets genuinely complex.&lt;/strong&gt; Targeting by plan tier and country and signup date and twelve other attributes, with reusable segments — that's a rules engine, and writing your own rules engine is exactly the over-engineering this article argues against, just in the other direction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need true real-time propagation.&lt;/strong&gt; Sub-second streaming updates instead of a polling interval. If a flag flip has to reach every client in under a second, a vendor's streaming SDK is built for it and your 30-second poll isn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're running real experiments.&lt;/strong&gt; Multivariate testing with built-in statistical significance, conversion tracking, and guardrail metrics. That's an experimentation platform, not a flag system, and it's a lot to build well.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need those things, LaunchDarkly is worth every euro. The point of the 100-line version isn't that the SaaS is bad — it's that most early-stage teams have &lt;em&gt;none&lt;/em&gt; of these needs yet, and buying a platform to solve problems you don't have is how MVPs accrete cost and complexity before they've found product-market fit. Build the small thing now; buy the big thing when the big thing's problems are actually yours.&lt;/p&gt;

&lt;p&gt;This is also why feature flags fit so naturally into a &lt;a href="https://iurii.rogulia.fi/blog/multi-tenant-saas-schema" rel="noopener noreferrer"&gt;multi-tenant SaaS schema&lt;/a&gt;: the per-tenant allow-lists above are just another tenant-scoped concern, evaluated against the same &lt;code&gt;tenant_id&lt;/code&gt; you're already threading through everything. And if you're standing up a new product, the flag table slots cleanly into the broader &lt;a href="https://iurii.rogulia.fi/blog/build-saas-nextjs-checklist" rel="noopener noreferrer"&gt;build-a-SaaS-with-Next.js checklist&lt;/a&gt; — it's a small, early piece of plumbing that pays for itself the first time you need to ship something dark.&lt;/p&gt;
&lt;h3&gt;
  
  
  The static-config variant
&lt;/h3&gt;

&lt;p&gt;For completeness: if a flag is a permanent on/off you change maybe twice a year, you don't even need the table. A typed config object works:&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;// Simplest case only — changing a flag here requires a deploy.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FLAGS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;newCheckout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;legacyExportApi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isEnabled&lt;/span&gt; &lt;span class="o"&gt;=&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="kr"&gt;keyof&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;FLAGS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;FLAGS&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's type-safe, it's zero-infrastructure, and it's honest about its one limitation: flipping a flag means a deploy. That rules out kill switches and gradual rollout, which is most of the value. Use it for the genuinely static toggles and use the database version for everything that needs to move at runtime.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Feature flags decouple deploy from release&lt;/strong&gt; — kill switches, gradual rollout, per-tenant targeting, and trunk-based development all fall out of one runtime switch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For a small team, building beats buying.&lt;/strong&gt; A SaaS prices on MAU, adds a dependency to your hot path, and ships features you won't use for years.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic percentage rollout is the one non-trivial idea.&lt;/strong&gt; Hash &lt;code&gt;flagKey + userId&lt;/code&gt; to a stable bucket — never &lt;code&gt;Math.random()&lt;/code&gt;, or the same user flickers on and off every request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Salt the hash with the flag key&lt;/strong&gt; so each rollout samples an independent slice instead of always picking on the same unlucky cohort.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache in memory, refresh on an interval, and own the tradeoff:&lt;/strong&gt; flips propagate within one refresh window, which is still far faster than a rollback deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail closed.&lt;/strong&gt; An unknown flag returns off and never throws; a failed refresh serves the last good snapshot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buy the SaaS when its problems are actually yours&lt;/strong&gt; — non-engineers flipping flags, audit trails with approvals, complex segmentation, sub-second propagation, or real experimentation. Most early teams have none of these yet.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>typescript</category>
      <category>node</category>
      <category>postgres</category>
      <category>saas</category>
    </item>
    <item>
      <title>Adobe Producer Spoofing: A PDF Metadata Forgery Case Study</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Sun, 19 Jul 2026 10:00:34 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/adobe-producer-spoofing-a-pdf-metadata-forgery-case-study-5fdd</link>
      <guid>https://dev.to/iurii_rogulia/adobe-producer-spoofing-a-pdf-metadata-forgery-case-study-5fdd</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/adobe-producer-spoofing-case-study" 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 fraud reviewer opens a PDF bank statement. The first thing many manual checks look at is the document’s &lt;code&gt;Producer&lt;/code&gt; field — the line of metadata that records which software last wrote the file. This one says &lt;code&gt;Adobe PDF Library 23.1&lt;/code&gt;. To a human, and to most lightweight metadata checks, that reads as reassuring: Adobe is professional software, the kind a bank’s back office or a law firm would use. The reviewer moves on.&lt;/p&gt;

&lt;p&gt;That is exactly the reaction the forger was counting on.&lt;/p&gt;

&lt;p&gt;The document was not produced by Adobe. It was edited in a free browser-based PDF editor, then passed through a step that overwrote the &lt;code&gt;Producer&lt;/code&gt; string to say &lt;code&gt;Adobe&lt;/code&gt;. The metadata now lies about the file’s own origin — and it lies in the most credibility-laundering direction available, because “Adobe” is the producer string people trust most. This is producer identity forgery, and it is one of the most common ways a tampered PDF tries to talk its way past a metadata-only review.&lt;/p&gt;

&lt;p&gt;This is a case study in how that attack works at a conceptual level, why a metadata-only check waves it through, and how a structural approach — the one behind the public marker &lt;code&gt;HTPBE_PRODUCER_IDENTITY_FORGED&lt;/code&gt; — catches the contradiction the forger left behind.&lt;/p&gt;

&lt;p&gt;If you want to see the &lt;code&gt;Producer&lt;/code&gt; string for yourself, the free &lt;a href="https://htpbe.tech/pdf-metadata-viewer" rel="noopener noreferrer"&gt;PDF metadata viewer&lt;/a&gt; reads it — along with every other field — straight out of any PDF.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Producer field is the obvious thing to forge
&lt;/h2&gt;

&lt;p&gt;Every PDF carries internal records about how it was made. Two fields matter most to a reviewer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;producer&lt;/code&gt;&lt;/strong&gt; — the software that wrote the final bytes of the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;creator&lt;/code&gt;&lt;/strong&gt; — the application the content originated in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fraud-detection lore, repeated in countless “how to spot a fake bank statement” guides, says the same thing: a real institutional document is generated by an automated back-end system, so if the &lt;code&gt;producer&lt;/code&gt; says &lt;code&gt;Microsoft Word&lt;/code&gt;, &lt;code&gt;Canva&lt;/code&gt;, or some online PDF tool, you are probably looking at a forgery. (For the full breakdown of what these fields contain and what they reveal, see the &lt;a href="https://htpbe.tech/blog/pdf-metadata-fields-complete-reference" rel="noopener noreferrer"&gt;PDF metadata fields reference&lt;/a&gt; and &lt;a href="https://htpbe.tech/blog/understanding-pdf-metadata-what-documents-reveal" rel="noopener noreferrer"&gt;what PDF metadata reveals&lt;/a&gt;.) That advice is correct as far as it goes.&lt;/p&gt;

&lt;p&gt;The problem is that it is public advice. Forgers read the same guides. So the natural next move is not to leave an incriminating &lt;code&gt;producer&lt;/code&gt; string in place — it is to overwrite it. And if you are going to overwrite it, you do not write &lt;code&gt;LibreOffice&lt;/code&gt;. You write the most trusted name you can: Adobe.&lt;/p&gt;

&lt;p&gt;Overwriting a metadata string is trivial. A &lt;code&gt;producer&lt;/code&gt; value is just text inside the file; dozens of free tools and one-line scripts will set it to anything you like. So the field that fraud guides tell reviewers to trust is also the field that is cheapest for a forger to fake. A metadata-only check that stops at “the &lt;code&gt;producer&lt;/code&gt; says Adobe, looks fine” is checking the one thing the attacker fully controls.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a metadata-only review actually verifies (almost nothing)
&lt;/h2&gt;

&lt;p&gt;Reading the &lt;code&gt;producer&lt;/code&gt; and &lt;code&gt;creator&lt;/code&gt; strings tells you what the file &lt;em&gt;claims&lt;/em&gt; about itself. It does not tell you whether those claims are true. A self-reported field is a self-reported field, whether it says &lt;code&gt;Canva&lt;/code&gt; or &lt;code&gt;Adobe PDF Library&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the gap. The reviewer who rejects a statement because its &lt;code&gt;producer&lt;/code&gt; says &lt;code&gt;Canva&lt;/code&gt; is doing the right thing — but a forger who has done their homework will never present that file. They present the version that says &lt;code&gt;Adobe&lt;/code&gt;. Now the same reviewer, applying the same rule, accepts the worse forgery. The rule rewards the more careful attacker.&lt;/p&gt;

&lt;p&gt;To catch producer identity forgery you cannot ask “what does the file say?” You have to ask “does the rest of the file behave the way a file from that producer actually behaves?” That is a structural question, not a metadata-string question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contradiction a forged Adobe claim leaves behind
&lt;/h2&gt;

&lt;p&gt;Genuine Adobe software does not just stamp a &lt;code&gt;producer&lt;/code&gt; string and stop. When real Adobe products write a PDF, they leave a coherent set of structural fingerprints throughout the file — the byproduct of how that software actually assembles, describes, and saves a document. These fingerprints are consistent across genuine Adobe output because they fall out of the software’s real internals, not from any single field a user types.&lt;/p&gt;

&lt;p&gt;A forger who only overwrites the &lt;code&gt;producer&lt;/code&gt; text gets none of that for free. They have changed the label on the box without changing what is inside it. The result is a file whose metadata announces “Adobe produced me” while its underlying structure tells a different, internally inconsistent story — the structure of an online editor or a consumer re-save tool wearing an Adobe name tag.&lt;/p&gt;

&lt;p&gt;That contradiction — an Adobe origin claim that the file’s own structure does not support — is the signal. When HTPBE sees a document asserting Adobe origin while the structural fingerprints that genuine Adobe output reliably carries are absent or mutually inconsistent, it treats the Adobe claim as forged and returns the public marker &lt;code&gt;HTPBE_PRODUCER_IDENTITY_FORGED&lt;/code&gt;. The verdict for such a file is &lt;code&gt;modified&lt;/code&gt;: the metadata has been edited to misrepresent the file’s origin, which is precisely a post-creation modification.&lt;/p&gt;

&lt;p&gt;We deliberately do not publish the exact byte-level checklist of which fingerprints are checked or how they relate. That list would be a bypass recipe — a map of exactly which fields a forger would need to forge in lockstep to defeat the check. The principle is the part that’s safe to state plainly: &lt;strong&gt;real Adobe output leaves structural fingerprints that a string-overwrite, a consumer re-save tool, or a metadata editor does not reliably reproduce.&lt;/strong&gt; The forger faked the easy part and skipped the hard part, because the hard part is invisible to them.&lt;/p&gt;

&lt;h2&gt;
  
  
  A worked example, in business terms
&lt;/h2&gt;

&lt;p&gt;Picture two files crossing a lending team’s desk in the same week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File A&lt;/strong&gt; is a real PDF statement from a bank’s document system. Its &lt;code&gt;producer&lt;/code&gt; reflects the institutional pipeline that generated it. Its internal timestamps, structure, and origin signals all line up. HTPBE returns &lt;code&gt;intact&lt;/code&gt;. Accept.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File B&lt;/strong&gt; looks nicer. Its &lt;code&gt;producer&lt;/code&gt; proudly says &lt;code&gt;Adobe PDF Library&lt;/code&gt;, which the reviewer reads as a green flag. But File B started life as a PDF that someone opened in a browser editor, changed the closing balance on, and then ran through a step that rewrote the &lt;code&gt;producer&lt;/code&gt; to &lt;code&gt;Adobe&lt;/code&gt;. The Adobe claim is bolted onto a body that was never anywhere near Adobe software. HTPBE returns &lt;code&gt;modified&lt;/code&gt; with &lt;code&gt;HTPBE_PRODUCER_IDENTITY_FORGED&lt;/code&gt;. Reject.&lt;/p&gt;

&lt;p&gt;To the human eye, File B is the &lt;em&gt;more&lt;/em&gt; trustworthy of the two — it name-drops Adobe; File A just has some institutional toolchain string nobody recognizes. Structural forensics inverts that intuition, which is the whole point. The forger optimized for the human reviewer’s heuristic and walked straight into the structural one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where &lt;code&gt;inconclusive&lt;/code&gt; fits — and why it isn’t a failure
&lt;/h2&gt;

&lt;p&gt;Not every non-Adobe file is a forged-Adobe file. Plenty of legitimate documents are simply produced by consumer software: someone exports a perfectly honest letter from a word processor or a print-to-PDF driver. Those files don’t claim a false institutional origin; they just aren’t the kind of file whose integrity can be cryptographically vouched for after the fact.&lt;/p&gt;

&lt;p&gt;For those, HTPBE returns &lt;code&gt;inconclusive&lt;/code&gt;. That verdict is not a tool failure and it is not an accusation — it means the file was made with consumer software, so there is no institutional structural baseline to verify the file against. &lt;code&gt;inconclusive&lt;/code&gt; is itself a useful signal: if your process &lt;em&gt;expected&lt;/em&gt; a document from an institution and the result is &lt;code&gt;inconclusive&lt;/code&gt;, the file wasn’t generated by an institutional system, and that mismatch is something your workflow should act on. Producer identity forgery is the opposite case: a file actively &lt;em&gt;claiming&lt;/em&gt; the institutional/Adobe origin it doesn’t have. The first is a quiet gap; the second is a loud lie.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limit
&lt;/h2&gt;

&lt;p&gt;This detector raises the cost of the attack; it does not make it impossible. A forger who fully understands what genuine Adobe output looks like, and who reproduces the &lt;em&gt;entire&lt;/em&gt; coherent fingerprint — not just the &lt;code&gt;producer&lt;/code&gt; string, but every structural detail that has to agree with it — can in principle still present a file that asserts Adobe origin without contradicting itself. No structural check that relies on fingerprints can claim absolute immunity against an attacker who perfectly reproduces those fingerprints.&lt;/p&gt;

&lt;p&gt;What the detector does is move the bar from “edit one text field, which any free tool does in a second” to “reconstruct a complete, internally consistent Adobe production fingerprint by hand.” The overwhelming majority of producer-spoofing forgeries are the first kind, because the second kind requires deep, document-internals knowledge that the casual forger — the one buying a “fake bank statement” off a Telegram channel — simply does not have. Catching the cheap, common attack while being honest that a determined expert can still get through is the realistic standard for forensics, and it is the standard we hold this check to.&lt;/p&gt;

&lt;p&gt;One more boundary worth stating: HTPBE is structural PDF tamper detection. It reasons about the file’s bytes and structure — whether the document was modified after creation and whether its origin claims hold up. It is not a KYC or identity-verification platform, and it does not read the document’s content to decide whether the named account holder is a real person or whether the balance is plausible. It complements an identity and risk stack; it does not replace one. Producer identity forgery is firmly in its lane: a &lt;em&gt;structural&lt;/em&gt; contradiction between what the file says made it and what actually did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detecting it in your own pipeline
&lt;/h2&gt;

&lt;p&gt;Producer identity forgery is one of 61 forensic checks (as of this writing) that run on every document submitted to the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;PDF tamper detection API&lt;/a&gt;. You don’t request it specifically — you submit a PDF and read the verdict.&lt;/p&gt;

&lt;p&gt;Submit a document for analysis:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;That returns a check id. Retrieve the verdict:&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/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 &lt;/span&gt;&lt;span class="nv"&gt;$HTPBE_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A file whose Adobe claim doesn’t survive structural scrutiny comes back like this:&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;"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_PRODUCER_IDENTITY_FORGED"&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_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"consumer_software"&lt;/span&gt;&lt;span class="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 your gate, the rule is simple: a &lt;code&gt;modified&lt;/code&gt; verdict carrying &lt;code&gt;HTPBE_PRODUCER_IDENTITY_FORGED&lt;/code&gt; on a document that claims institutional origin is a strong, conclusive structural finding — hold the document, route it to a reviewer, and request a fresh copy directly from the issuer. The file has told you, in its own structure, that its origin label is fake. Don’t make it the sole basis for an automatic decision against the person who sent it: a structural verdict describes the file, not their intent, which is why the API ships a &lt;code&gt;usage_caution&lt;/code&gt; object on every result to keep a human or an issuer re-request in the loop. (For the full request/response contract, see the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;API documentation&lt;/a&gt;; for the conceptual model behind the verdicts, 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;If you want to test the behavior without wiring up live traffic first, a &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;test key&lt;/a&gt; returns deterministic synthetic results for documented scenarios, so you can build and verify your gate logic before pointing it at production documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who should care about this check
&lt;/h2&gt;

&lt;p&gt;If you run fraud or risk operations at an alternative lender, a fintech, an insurer, or any business that accepts customer-supplied PDFs claiming to come from a bank, an employer, or a government body, producer identity forgery is being used against you right now — specifically because your team has been trained to read the &lt;code&gt;producer&lt;/code&gt; field as a trust signal. The forgers know that, which is why they forge it.&lt;/p&gt;

&lt;p&gt;The fix is not to stop reading metadata; it’s to stop trusting self-reported metadata on its own. (If you want a broader catalog of what tampering leaves behind, see &lt;a href="https://htpbe.tech/blog/5-signs-pdf-tampered-with" rel="noopener noreferrer"&gt;5 signs a PDF was tampered with&lt;/a&gt;.) Put a structural check between the uploaded file and the decision, so that an Adobe claim has to survive more than a glance. You can wire that into your existing flow through the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;PDF tamper detection API&lt;/a&gt;, or start with pay-per-check on the &lt;a href="https://htpbe.tech/auth/signup" rel="noopener noreferrer"&gt;web tool&lt;/a&gt; if you want to run a batch of suspicious documents before committing to an integration. Either way, the next forged-Adobe statement that lands in your queue should come back &lt;code&gt;modified&lt;/code&gt; — not waved through on the strength of a string anyone can type.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>forensics</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Isomorphic Canvas Rendering: One draw() in Browser and Node</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Sat, 18 Jul 2026 10:00:42 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/isomorphic-canvas-rendering-one-draw-in-browser-and-node-3gkn</link>
      <guid>https://dev.to/iurii_rogulia/isomorphic-canvas-rendering-one-draw-in-browser-and-node-3gkn</guid>
      <description>&lt;p&gt;A user types &lt;code&gt;64+5&lt;/code&gt; into &lt;a href="https://wrongulator.com" rel="noopener noreferrer"&gt;Wrongulator&lt;/a&gt;, gets back &lt;code&gt;67&lt;/code&gt; — "the only correct number" — laughs, and pastes the link into a group chat. Twitter, WhatsApp, Slack, Discord: each one fetches the URL, finds an Open Graph image, and unfurls a 1080×1080 card right there in the feed. That card &lt;em&gt;is&lt;/em&gt; the joke. If it looks even slightly different from the card the user saw in their browser — different font, missing emoji, wrong line break — the joke breaks in transit.&lt;/p&gt;

&lt;p&gt;The share card is the unit of virality, so it has exactly one job: be byte-for-byte the same picture wherever it renders. The problem is that "wherever it renders" means two completely different environments — a browser &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; and a Node process — and the obvious way to support both is to write the card twice. That's the trap. This post is about isomorphic canvas rendering: writing &lt;strong&gt;one&lt;/strong&gt; drawing routine and running it unchanged in both runtimes, so the two pictures can't drift because there's only one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why You Need a Server Render at All
&lt;/h2&gt;

&lt;p&gt;The instinct for a client-side toy is to keep everything in the browser. The engine that computes Wrongulator's wrong answer does exactly that — it's a pure function, no server call, and &lt;a href="https://iurii.rogulia.fi/blog/wrongulator-deterministic" rel="noopener noreferrer"&gt;why the same input always returns the same wrong answer&lt;/a&gt; is its own story. So why involve a server in the picture at all?&lt;/p&gt;

&lt;p&gt;Because social crawlers don't run JavaScript.&lt;/p&gt;

&lt;p&gt;When you paste a link into a chat app, the crawler that fetches it — Twitterbot, facebookexternalhit, Slack's unfurler — reads the raw HTML response and stops. It does not boot a JS runtime, it does not wait for &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; to paint, it does not execute your engine. It looks for &lt;code&gt;&amp;lt;meta property="og:image"&amp;gt;&lt;/code&gt; and fetches whatever URL it finds. That means the 1080×1080 PNG has to &lt;strong&gt;already exist&lt;/strong&gt; in the server's response, fully rendered, before a single line of client JS runs.&lt;/p&gt;

&lt;p&gt;So the card lives a double life. In the browser, it's painted live on a real &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; element the moment the user lands on a result. On the server, the same card has to be produced as a static PNG by an endpoint a crawler can fetch — no browser, no DOM, no JS execution on the crawler's side. Two environments, one picture, and they have to match.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two-Renderer Trap
&lt;/h2&gt;

&lt;p&gt;The naïve way to satisfy both is to build two renderers. A browser one that draws to &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt;, and a server one that produces the OG image somehow — maybe an SVG template, maybe an HTML-to-image library, maybe a headless browser screenshotting the page.&lt;/p&gt;

&lt;p&gt;Every one of those splits your card into two sources of truth. The browser renderer says the headline sits at &lt;code&gt;y: 120&lt;/code&gt; with &lt;code&gt;48px&lt;/code&gt; Russo One; the SVG template says &lt;code&gt;y: 118&lt;/code&gt; because SVG text baselines work differently. The browser wraps the reason at 40 characters; the server library wraps at 38 because it measures glyphs differently. None of these are bugs, exactly — they're two implementations of "the same" layout that were never going to agree on every pixel. And the drift compounds: every time you tweak the card in one place, you have to remember to mirror it in the other, forever. Miss one, and the shared image quietly diverges from the one users see.&lt;/p&gt;

&lt;p&gt;For a product whose entire growth loop is "the picture I shared is the picture you see," that drift isn't cosmetic. It's the failure mode.&lt;/p&gt;

&lt;p&gt;The fix is to refuse the premise. Don't write two renderers that try to agree. Write one renderer that runs in both places.&lt;/p&gt;

&lt;h2&gt;
  
  
  One UMD Core, Two Runtimes
&lt;/h2&gt;

&lt;p&gt;Canvas 2D is the right common denominator because both worlds speak it. The browser gives you a &lt;code&gt;CanvasRenderingContext2D&lt;/code&gt; from a real &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt;. On the server, &lt;a href="https://github.com/Brooooooklyn/canvas" rel="noopener noreferrer"&gt;&lt;code&gt;@napi-rs/canvas&lt;/code&gt;&lt;/a&gt; — a Rust-backed, Skia-powered Node binding — gives you a context with the same API surface: &lt;code&gt;fillRect&lt;/code&gt;, &lt;code&gt;fillText&lt;/code&gt;, &lt;code&gt;measureText&lt;/code&gt;, &lt;code&gt;drawImage&lt;/code&gt;, the lot. If you write your drawing code against that bare API and touch nothing browser-specific (no &lt;code&gt;document&lt;/code&gt;, no &lt;code&gt;window&lt;/code&gt;, no DOM measurement), the exact same function runs in both.&lt;/p&gt;

&lt;p&gt;So the card is a single module, &lt;code&gt;card-core.js&lt;/code&gt;, written against the Canvas 2D API and exported as UMD so it loads cleanly in either environment:&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/card-core.js — environment-agnostic UMD; same draw() in both runtimes&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;factory&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WrongCardCore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;})(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ... drawCard(ctx, { expr, answer, reason, headline, emoji })&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UMD wrapper is the whole trick. In Node, &lt;code&gt;module.exports&lt;/code&gt; exists, so the factory's return value becomes the module — &lt;code&gt;require('./public/card-core.js')&lt;/code&gt; gives you &lt;code&gt;{ W, H, drawCard }&lt;/code&gt;. In the browser, there's no &lt;code&gt;module&lt;/code&gt;, so it hangs the same object off &lt;code&gt;self&lt;/code&gt; as &lt;code&gt;WrongCardCore&lt;/code&gt;, and a &lt;code&gt;&amp;lt;script src="card-core.js"&amp;gt;&lt;/code&gt; tag makes it global. One file, zero build step, no bundler required, and critically: the &lt;code&gt;drawCard&lt;/code&gt; function inside is identical in both cases because it's literally the same source.&lt;/p&gt;

&lt;p&gt;The browser side feeds it a real canvas context. The server side feeds it a Node canvas context — and that's the entire OG endpoint:&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 — the OG endpoint feeds the identical core a node canvas&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/og&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;core&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;W&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;core&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;H&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wrongulate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;core&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawCard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&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;expr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;answer&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="na"&gt;reason&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="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cache-Control&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;public, immutable, max-age=31536000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// output is deterministic&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;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBuffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&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;Read that endpoint and notice what it &lt;em&gt;doesn't&lt;/em&gt; do. It doesn't lay out text. It doesn't pick colors, position the headline, or wrap the reason. All of that lives in &lt;code&gt;core.drawCard&lt;/code&gt;, the same function the browser calls. The server's job shrinks to three lines: make a canvas of &lt;code&gt;core.W × core.H&lt;/code&gt;, hand the context to the shared core, serialize the result with &lt;code&gt;canvas.toBuffer('image/png')&lt;/code&gt;. The dimensions come from the core too (&lt;code&gt;core.W&lt;/code&gt;, &lt;code&gt;core.H&lt;/code&gt;), so even the canvas size can't drift between environments.&lt;/p&gt;

&lt;p&gt;This is the payoff of isomorphism stated plainly: the OG endpoint has no rendering logic of its own to get wrong. There's nothing to keep in sync because there's nothing duplicated. When I change how the card looks, I change one function, and both the in-app card and every future unfurl move together.&lt;/p&gt;

&lt;p&gt;
  slug="mvp-development"&lt;br&gt;
  text="If your MVP's growth depends on shareable artifacts — cards, certificates, generated images that unfurl in feeds — the server render is not an afterthought. I build the whole loop: engine, isomorphic render, OG endpoint, caching."&lt;br&gt;
/&amp;gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The Font Problem: Where Isomorphism Actually Costs You
&lt;/h2&gt;

&lt;p&gt;I want to be honest about where this approach gets hard, because "one function, two runtimes" makes it sound free. It isn't. The seam shows up in fonts.&lt;/p&gt;

&lt;p&gt;In the browser, font fallback is automatic and invisible. You ask for a font, and if a glyph isn't in it — say the user's reason contains Japanese and an emoji — the browser silently walks its own fallback chain and finds something that can render &lt;code&gt;サ&lt;/code&gt; and &lt;code&gt;🙏&lt;/code&gt;. The OS has fonts installed; the browser knows how to reach them. You never think about it.&lt;/p&gt;

&lt;p&gt;A Node process has none of that. &lt;code&gt;@napi-rs/canvas&lt;/code&gt; will only draw glyphs from fonts you have explicitly registered, and it will not invent a fallback chain for you. Ask it to render &lt;code&gt;"サンキュー 🙏"&lt;/code&gt; with only a Latin font loaded and you get tofu boxes, or nothing, where the Japanese and the emoji should be. The browser hid an entire subsystem from you, and on the server you have to rebuild it by hand.&lt;/p&gt;

&lt;p&gt;Wrongulator runs in 17 languages, including RTL Arabic, so a card's text can mix Latin, Cyrillic, Thai, Arabic, Japanese, Korean, and emoji in a single string. To make that render server-side, the OG renderer carries a &lt;strong&gt;per-glyph font fallback chain&lt;/strong&gt;: Russo One for Latin and Cyrillic, the appropriate Noto faces for Thai, Arabic, Japanese, and Korean, and Noto Color Emoji for the pictographs. Each of those font files has to be present in the runtime, so they're fetched at build time — the Dockerfile pulls the OFL-licensed fonts and bakes them into the image, registering each with the canvas library before any card is drawn. That's real work the browser did for free, and it's the part of "isomorphic" that has an asterisk.&lt;/p&gt;
&lt;h2&gt;
  
  
  Determinism Makes the Cache Free
&lt;/h2&gt;

&lt;p&gt;Here's where the rendering story meets the engine story, and the two reinforce each other.&lt;/p&gt;

&lt;p&gt;The wrong answer for &lt;code&gt;64+5&lt;/code&gt; is a pure function of &lt;code&gt;64+5&lt;/code&gt;. It's &lt;code&gt;67&lt;/code&gt;, forever, for everyone — that determinism is the &lt;a href="https://iurii.rogulia.fi/blog/wrongulator-deterministic" rel="noopener noreferrer"&gt;whole reason the toy is shareable&lt;/a&gt;. Now layer the isomorphic render on top: because the card is drawn by a pure function of the same inputs, the PNG for &lt;code&gt;/api/og?expr=64+5&lt;/code&gt; is &lt;em&gt;also&lt;/em&gt; a pure function of its query. Same query in, same bytes out, every time.&lt;/p&gt;

&lt;p&gt;Which means the image never needs to be re-rendered. So the endpoint sets:&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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cache-Control&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;public, immutable, max-age=31536000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A full year, marked &lt;code&gt;immutable&lt;/code&gt;. The CDN fetches each card once and serves it from the edge forever. There's no invalidation logic, no cache-busting, no "did the source change" check — the output &lt;em&gt;can't&lt;/em&gt; change for a given input, so caching it permanently is not a risk, it's the correct behavior. Determinism plus isomorphism turns the OG endpoint from a per-request renderer into a write-once asset factory. The first crawler to unfurl &lt;code&gt;64+5&lt;/code&gt; pays for the render; every subsequent one, across every platform, gets a CDN hit.&lt;/p&gt;

&lt;p&gt;That's the combination I want to underline: a deterministic engine alone gives you a cacheable &lt;em&gt;answer&lt;/em&gt;; an isomorphic render gives you a cacheable &lt;em&gt;picture&lt;/em&gt; that's guaranteed to match what users see. Together they make the share card free to serve at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Honest Edge Cases
&lt;/h2&gt;

&lt;p&gt;"Byte-for-byte identical" is the goal and the day-to-day reality of this design, but I'd be lying if I called it a mathematical guarantee across every platform. The browser's Canvas implementation and &lt;code&gt;@napi-rs/canvas&lt;/code&gt; are different engines — Skia under the Node binding, the browser's own compositor in front. Antialiasing can differ at the sub-pixel level. Text metrics from &lt;code&gt;measureText&lt;/code&gt; can disagree by a fraction, which on a long wrapped line can occasionally push a word to the next row in one environment and not the other.&lt;/p&gt;

&lt;p&gt;So what does isomorphism actually buy if it's not a bit-exact promise? It removes the &lt;em&gt;structural&lt;/em&gt; source of drift — the second renderer with its own layout opinions. Both environments run the same &lt;code&gt;drawCard&lt;/code&gt; against the same Canvas 2D API with the same fonts and the same dimensions, so they agree on everything the code decides: positions, sizes, colors, wrap points computed from the same &lt;code&gt;measureText&lt;/code&gt; logic. What's left to disagree on is the rendering engine's own pixel-level rasterization, and that's a far smaller, far more bounded gap than "two people wrote two layouts." In practice, on the platforms that matter, the cards are indistinguishable. The mitigation &lt;em&gt;is&lt;/em&gt; the architecture: one function, one API, one set of inputs.&lt;/p&gt;

&lt;p&gt;A few reasonable alternatives, and why I passed on them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Satori / SVG-to-image.&lt;/strong&gt; Satori is excellent for OG images, but it renders a subset of CSS flexbox to SVG — it's a &lt;em&gt;different&lt;/em&gt; rendering model from Canvas 2D. Adopting it for the server would mean the in-browser card (Canvas) and the OG card (Satori) are two renderers again, with the exact drift I was trying to kill. The whole point was one routine; Satori reintroduces two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Headless browser screenshots.&lt;/strong&gt; Spinning up Chromium per OG request would give pixel-perfect parity with the browser card — at the cost of hundreds of megabytes of runtime, slow cold starts, and a fragile dependency for a toy that's meant to ship as one small Docker container. The card is a few shapes and some text; paying for a whole browser to draw it is the wrong trade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A static SVG template.&lt;/strong&gt; Cheapest to render, but SVG text layout, wrapping, and font handling diverge from Canvas, and you're back to maintaining a second layout that has to chase the first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;@napi-rs/canvas&lt;/code&gt; won because it's the only option that lets the server run the &lt;em&gt;same code&lt;/em&gt; as the browser. Every alternative trades that away for something else.&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;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Renderers&lt;/td&gt;
&lt;td&gt;1 — single &lt;code&gt;drawCard()&lt;/code&gt; routine, run in browser and on the server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Module format&lt;/td&gt;
&lt;td&gt;UMD (&lt;code&gt;card-core.js&lt;/code&gt;) — &lt;code&gt;module.exports&lt;/code&gt; in Node, global in the browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server canvas&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@napi-rs/canvas&lt;/code&gt; — Skia-backed, same Canvas 2D API as the browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OG endpoint logic&lt;/td&gt;
&lt;td&gt;3 lines — create canvas, call shared core, &lt;code&gt;toBuffer('image/png')&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Card size&lt;/td&gt;
&lt;td&gt;1080×1080 PNG, dimensions from &lt;code&gt;core.W&lt;/code&gt; / &lt;code&gt;core.H&lt;/code&gt; (single source)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Font fallback&lt;/td&gt;
&lt;td&gt;Per-glyph chain — Russo One, Noto (Thai/Arabic/JP/KR), Noto Color Emoji&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fonts&lt;/td&gt;
&lt;td&gt;OFL faces fetched at build time, baked into the Docker image&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OG image cache&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;immutable, max-age=31536000&lt;/code&gt; — deterministic output never re-renders&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime deps&lt;/td&gt;
&lt;td&gt;3 total (Express, @napi-rs/canvas, ioredis)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy&lt;/td&gt;
&lt;td&gt;Single Docker container on Coolify&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;If two outputs must match, generate them from one source.&lt;/strong&gt; The reliable way to make the browser card and the unfurl card identical isn't to carefully keep two renderers in sync — it's to have one renderer. Any time you find yourself mirroring layout logic across environments, ask whether a shared routine can replace both.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pick the API both runtimes already speak.&lt;/strong&gt; Canvas 2D works in the browser and, via &lt;code&gt;@napi-rs/canvas&lt;/code&gt;, in Node. Writing against that bare common API — and nothing environment-specific — is what makes the same function portable. UMD packaging is the small glue that lets one file load in both places with no build step.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The browser hides whole subsystems; the server makes you rebuild them.&lt;/strong&gt; Font fallback is the classic example. What's automatic and invisible in a browser becomes an explicit per-glyph fallback chain plus a build step to ship the font files. Budget for the seams — isomorphism removes duplicated &lt;em&gt;logic&lt;/em&gt;, not duplicated &lt;em&gt;environments&lt;/em&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Determinism and isomorphism compound.&lt;/strong&gt; A deterministic engine gives you a cacheable answer; an isomorphic render gives you a picture that provably matches what users see. Together they let you cache the OG image &lt;code&gt;immutable&lt;/code&gt; for a year — write once, serve from the edge forever.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;"Identical" is an architecture, not a guarantee.&lt;/strong&gt; Two rendering engines can still differ at the antialiasing and text-metrics level. You can't eliminate that by writing more code — you eliminate the &lt;em&gt;structural&lt;/em&gt; drift by running one routine against one API with one set of inputs, and accept that the residual gap is bounded and, in practice, invisible.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full project — the deterministic Wrong Engine, 17-language i18n, server-side per-link SEO, 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 half of this story, see &lt;a href="https://iurii.rogulia.fi/blog/wrongulator-deterministic" rel="noopener noreferrer"&gt;why the wrong answer is the same every time&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>napirscanvas</category>
      <category>express</category>
    </item>
    <item>
      <title>PDF Tamper Detection API for Go: Integration Guide</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Sat, 18 Jul 2026 10:00:35 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/pdf-tamper-detection-api-for-go-integration-guide-2j1a</link>
      <guid>https://dev.to/iurii_rogulia/pdf-tamper-detection-api-for-go-integration-guide-2j1a</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/pdf-verification-go-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. The forged bank statement, the altered invoice, the doctored payslip — none of it reaches a human reviewer untouched. By the time your Go handler has written a row to the database and returned &lt;code&gt;201&lt;/code&gt;, the document’s claims have already propagated into your business logic. The right place to catch the structural-tampering layer 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 a Go service — from the first curl command to an idiomatic client built on &lt;code&gt;net/http&lt;/code&gt;, &lt;code&gt;encoding/json&lt;/code&gt;, and &lt;code&gt;context&lt;/code&gt;, with a typed result struct, error handling that distinguishes retryable failures from permanent ones, and a small bank-statement gate that decides accept / reject / review. The code compiles and runs the real request flow against the documented error codes; 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;. If you are integrating from Node.js, Python, or PHP instead, see the &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 check id, &lt;code&gt;GET /result/{id}&lt;/code&gt; returns the flat verdict object with &lt;code&gt;status&lt;/code&gt; being 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;Minimum integration is the standard library — &lt;code&gt;net/http&lt;/code&gt; plus &lt;code&gt;encoding/json&lt;/code&gt;, no third-party dependency.&lt;/li&gt;
&lt;li&gt;Production-grade client: a typed &lt;code&gt;Result&lt;/code&gt; struct, &lt;code&gt;context.Context&lt;/code&gt; timeouts, a retry loop that backs off on 5xx and 429 only, and a parsed &lt;code&gt;Retry-After&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;Gate&lt;/code&gt; example 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;Go 1.21+ (for &lt;code&gt;errors.Join&lt;/code&gt;, &lt;code&gt;slog&lt;/code&gt;, and the stable &lt;code&gt;context&lt;/code&gt; ergonomics used below)&lt;/li&gt;
&lt;li&gt;An HTPBE API key (Dashboard → copy key)&lt;/li&gt;
&lt;li&gt;No external modules — everything below is standard library&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 Go, 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: &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;"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 real response carries every field documented in the struct below.) 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.&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: The Result Struct
&lt;/h2&gt;

&lt;p&gt;Define a struct that mirrors the &lt;code&gt;GET /result/{id}&lt;/code&gt; response. Go’s &lt;code&gt;encoding/json&lt;/code&gt; ignores unknown fields by default, so new API fields never break unmarshalling. Use pointer types (&lt;code&gt;*int64&lt;/code&gt;, &lt;code&gt;*string&lt;/code&gt;) for the fields the API documents as nullable — that lets you distinguish “absent” from a genuine zero value, which matters for timestamps and the producer string.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;htpbe&lt;/span&gt;

&lt;span class="c"&gt;// Result mirrors the flat GET /result/{id} response.&lt;/span&gt;
&lt;span class="c"&gt;// Nullable fields use pointers so "absent" is distinguishable from zero.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Result&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt;       &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"id"`&lt;/span&gt;
    &lt;span class="n"&gt;Filename&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"filename"`&lt;/span&gt;
    &lt;span class="n"&gt;FileSize&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;  &lt;span class="s"&gt;`json:"file_size"`&lt;/span&gt;
    &lt;span class="n"&gt;PageCount&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;   &lt;span class="s"&gt;`json:"page_count"`&lt;/span&gt;

    &lt;span class="n"&gt;AlgorithmVersion&lt;/span&gt;        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"algorithm_version"`&lt;/span&gt;
    &lt;span class="n"&gt;CurrentAlgorithmVersion&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"current_algorithm_version"`&lt;/span&gt;
    &lt;span class="n"&gt;OutdatedWarning&lt;/span&gt;         &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"outdated_warning"`&lt;/span&gt;

    &lt;span class="c"&gt;// Primary verdict: "intact" | "modified" | "inconclusive"&lt;/span&gt;
    &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"status"`&lt;/span&gt;
    &lt;span class="c"&gt;// StatusReason is present only when Status == "inconclusive":&lt;/span&gt;
    &lt;span class="c"&gt;// "consumer_software_origin" | "online_editor_origin" |&lt;/span&gt;
    &lt;span class="c"&gt;// "scanned_document" | "unverifiable_metadata"&lt;/span&gt;
    &lt;span class="n"&gt;StatusReason&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"status_reason"`&lt;/span&gt;

    &lt;span class="n"&gt;Origin&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// "consumer_software" | "institutional" | "unknown" |&lt;/span&gt;
        &lt;span class="c"&gt;// "online_editor" | "scanned"&lt;/span&gt;
        &lt;span class="n"&gt;Type&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;  &lt;span class="s"&gt;`json:"type"`&lt;/span&gt;
        &lt;span class="n"&gt;Software&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"software"`&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="s"&gt;`json:"origin"`&lt;/span&gt;

    &lt;span class="c"&gt;// "certain" | "high" | "none" | null&lt;/span&gt;
    &lt;span class="n"&gt;ModificationConfidence&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"modification_confidence"`&lt;/span&gt;

    &lt;span class="n"&gt;Creator&lt;/span&gt;          &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"creator"`&lt;/span&gt;
    &lt;span class="n"&gt;Producer&lt;/span&gt;         &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"producer"`&lt;/span&gt;
    &lt;span class="n"&gt;CreationDate&lt;/span&gt;     &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;int64&lt;/span&gt;  &lt;span class="s"&gt;`json:"creation_date"`&lt;/span&gt;     &lt;span class="c"&gt;// Unix seconds&lt;/span&gt;
    &lt;span class="n"&gt;ModificationDate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;int64&lt;/span&gt;  &lt;span class="s"&gt;`json:"modification_date"`&lt;/span&gt; &lt;span class="c"&gt;// Unix seconds&lt;/span&gt;
    &lt;span class="n"&gt;PDFVersion&lt;/span&gt;       &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"pdf_version"`&lt;/span&gt;

    &lt;span class="n"&gt;DateSequenceValid&lt;/span&gt;         &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="s"&gt;`json:"date_sequence_valid"`&lt;/span&gt;
    &lt;span class="n"&gt;MetadataCompletenessScore&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;  &lt;span class="s"&gt;`json:"metadata_completeness_score"`&lt;/span&gt;

    &lt;span class="n"&gt;XrefCount&lt;/span&gt;            &lt;span class="kt"&gt;int&lt;/span&gt;  &lt;span class="s"&gt;`json:"xref_count"`&lt;/span&gt;
    &lt;span class="n"&gt;HasIncrementalUpdates&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="s"&gt;`json:"has_incremental_updates"`&lt;/span&gt;
    &lt;span class="n"&gt;UpdateChainLength&lt;/span&gt;    &lt;span class="kt"&gt;int&lt;/span&gt;  &lt;span class="s"&gt;`json:"update_chain_length"`&lt;/span&gt;

    &lt;span class="n"&gt;HasDigitalSignature&lt;/span&gt;        &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="s"&gt;`json:"has_digital_signature"`&lt;/span&gt;
    &lt;span class="n"&gt;SignatureCount&lt;/span&gt;             &lt;span class="kt"&gt;int&lt;/span&gt;  &lt;span class="s"&gt;`json:"signature_count"`&lt;/span&gt;
    &lt;span class="n"&gt;SignatureRemoved&lt;/span&gt;           &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="s"&gt;`json:"signature_removed"`&lt;/span&gt;
    &lt;span class="n"&gt;ModificationsAfterSignature&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="s"&gt;`json:"modifications_after_signature"`&lt;/span&gt;

    &lt;span class="n"&gt;ObjectCount&lt;/span&gt;      &lt;span class="kt"&gt;int&lt;/span&gt;  &lt;span class="s"&gt;`json:"object_count"`&lt;/span&gt;
    &lt;span class="n"&gt;HasJavascript&lt;/span&gt;    &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="s"&gt;`json:"has_javascript"`&lt;/span&gt;
    &lt;span class="n"&gt;HasEmbeddedFiles&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="s"&gt;`json:"has_embedded_files"`&lt;/span&gt;

    &lt;span class="c"&gt;// Stable HTPBE_* marker ids, e.g. ["HTPBE_SIGNATURE_REMOVED"].&lt;/span&gt;
    &lt;span class="c"&gt;// Empty when Status is "intact" or "inconclusive".&lt;/span&gt;
    &lt;span class="n"&gt;ModificationMarkers&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"modification_markers"`&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 a strong signal to route for review — the kind of origin you would not expect from a real payroll system, covering both consumer apps and freely available HTML-to-PDF renderers. 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_MULTIPLE_REVISION_LAYERS&lt;/code&gt;, &lt;code&gt;HTPBE_POST_SIGNATURE_EDIT&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.&lt;/p&gt;

&lt;p&gt;It is worth defining named constants for the verdicts so the rest of your codebase never compares against bare string literals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;StatusIntact&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"intact"&lt;/span&gt;
    &lt;span class="n"&gt;StatusModified&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"modified"&lt;/span&gt;
    &lt;span class="n"&gt;StatusInconclusive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"inconclusive"&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: 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. The retry loop and your business logic both need to branch on the status code, so wrap every non-2xx response in a typed error that carries it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;htpbe&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="s"&gt;"fmt"&lt;/span&gt;

&lt;span class="c"&gt;// APIError is returned for any non-2xx response from the API.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;APIError&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;StatusCode&lt;/span&gt;      &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;Code&lt;/span&gt;            &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="c"&gt;// machine-readable code from the JSON body, when present&lt;/span&gt;
    &lt;span class="n"&gt;Message&lt;/span&gt;         &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;RetryAfterSecs&lt;/span&gt;  &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="c"&gt;// parsed from Retry-After on 429; 0 when absent&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;APIError&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="kt"&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="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"htpbe: %d %s: %s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&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;e&lt;/span&gt;&lt;span class="o"&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;e&lt;/span&gt;&lt;span class="o"&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="c"&gt;// Retryable reports whether retrying the same request could succeed.&lt;/span&gt;
&lt;span class="c"&gt;// Only 5xx and 429 are transient; 4xx (other than 429) are permanent.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;APIError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Retryable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;bool&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;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;500&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;429&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: The Client
&lt;/h2&gt;

&lt;p&gt;Here is a complete client on the standard library. &lt;code&gt;Verify&lt;/code&gt; chains both calls and returns the full result; callers pass a &lt;code&gt;context.Context&lt;/code&gt; so the whole round trip honours a deadline or a cancelled request.&lt;/p&gt;

&lt;p&gt;A subtlety the retry logic gets right: &lt;code&gt;POST /analyze&lt;/code&gt; is the billable, job-creating step. Each successful POST starts a new analysis and draws a credit. &lt;code&gt;GET /result/{id}&lt;/code&gt; is a free read. So the two steps are retried &lt;em&gt;independently&lt;/em&gt; — the POST is retried on its own until it yields an &lt;code&gt;id&lt;/code&gt;, and once that &lt;code&gt;id&lt;/code&gt; is in hand a failed GET is retried by re-reading the same result, never by replaying the POST. Wrapping the whole flow in one retry loop would re-submit (and re-bill) a fresh analysis every time a transient GET failure occurred; this client never does that.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;htpbe&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"bytes"&lt;/span&gt;
    &lt;span class="s"&gt;"context"&lt;/span&gt;
    &lt;span class="s"&gt;"encoding/json"&lt;/span&gt;
    &lt;span class="s"&gt;"errors"&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;
    &lt;span class="s"&gt;"io"&lt;/span&gt;
    &lt;span class="s"&gt;"net/http"&lt;/span&gt;
    &lt;span class="s"&gt;"strconv"&lt;/span&gt;
    &lt;span class="s"&gt;"strings"&lt;/span&gt;
    &lt;span class="s"&gt;"time"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;defaultBaseURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://api.htpbe.tech/v1"&lt;/span&gt;

&lt;span class="c"&gt;// Client is a reusable, concurrency-safe HTPBE API client.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Client&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;apiKey&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;baseURL&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;httpClient&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;
    &lt;span class="n"&gt;maxRetries&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// Option configures a Client.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Option&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&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="c"&gt;// WithHTTPClient overrides the default *http.Client. A nil value is ignored&lt;/span&gt;
&lt;span class="c"&gt;// so the option can never produce a nil-deref at request time.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;WithHTTPClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hc&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&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="n"&gt;Option&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;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&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="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;hc&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;httpClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hc&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;func&lt;/span&gt; &lt;span class="n"&gt;WithBaseURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&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;Option&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;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&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="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;baseURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;WithMaxRetries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&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;Option&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;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&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="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maxRetries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// New constructs a Client. The apiKey is required.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiKey&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;opts&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="n"&gt;Option&lt;/span&gt;&lt;span class="p"&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;Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&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="n"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&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;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"htpbe: API key is required"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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;apiKey&lt;/span&gt;&lt;span class="o"&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;baseURL&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;defaultBaseURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;http&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="n"&gt;Timeout&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;35&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;maxRetries&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&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="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&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;opt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c"&gt;// Tolerate a trailing slash so callers can pass either form of the base URL.&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;baseURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimRight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;baseURL&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;analyzeRequest&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;URL&lt;/span&gt;              &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"url"`&lt;/span&gt;
    &lt;span class="n"&gt;OriginalFilename&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"original_filename,omitempty"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;analyzeResponse&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"id"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;errorBody&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Error&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"error"`&lt;/span&gt;
    &lt;span class="n"&gt;Code&lt;/span&gt;  &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"code"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// Verify submits a PDF URL and returns the full verdict. The two steps are&lt;/span&gt;
&lt;span class="c"&gt;// retried independently: POST /analyze (the billable step) is retried until&lt;/span&gt;
&lt;span class="c"&gt;// it yields an id, then GET /result/{id} (a free read) is retried on its own.&lt;/span&gt;
&lt;span class="c"&gt;// A failed GET never replays the POST, so a transient read failure cannot&lt;/span&gt;
&lt;span class="c"&gt;// create a duplicate analysis job. The context governs cancellation and the&lt;/span&gt;
&lt;span class="c"&gt;// overall deadline.&lt;/span&gt;
&lt;span class="c"&gt;//&lt;/span&gt;
&lt;span class="c"&gt;// originalFilename is optional; pass it so the result's `filename` field&lt;/span&gt;
&lt;span class="c"&gt;// shows a human-readable name instead of an opaque storage key.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&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="n"&gt;Verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&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="kt"&gt;string&lt;/span&gt;&lt;span class="p"&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;Result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&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="n"&gt;pdfURL&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&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;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"htpbe: pdfURL is required"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// Step 1: submit for analysis (billable — retried in isolation).&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;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&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="kt"&gt;error&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;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;submitAnalysis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&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="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// Step 2: read the result (free — retried in isolation, never replays POST).&lt;/span&gt;
    &lt;span class="k"&gt;var&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;Result&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&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="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&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;e&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&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;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&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="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// withRetry runs op until it succeeds, backing off only on Retryable() API&lt;/span&gt;
&lt;span class="c"&gt;// errors (5xx, 429). It honours a parsed Retry-After and the context deadline.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&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="n"&gt;withRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&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="kt"&gt;error&lt;/span&gt;&lt;span class="p"&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="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;lastErr&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&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;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maxRetries&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="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="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c"&gt;// Honour a server-supplied Retry-After on 429; otherwise&lt;/span&gt;
            &lt;span class="c"&gt;// exponential backoff: 1s, 2s, 4s.&lt;/span&gt;
            &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;lt;&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="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;
            &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;apiErr&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;APIError&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;As&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lastErr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;apiErr&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;apiErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RetryAfterSecs&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RetryAfterSecs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;After&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;op&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;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&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;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;lastErr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;apiErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;asAPIError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;apiErr&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;apiErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Retryable&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="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="c"&gt;// permanent — do not retry&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="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"htpbe: exhausted retries: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lastErr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&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="n"&gt;submitAnalysis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&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="kt"&gt;string&lt;/span&gt;&lt;span class="p"&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="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&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;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;analyzeRequest&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="o"&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="o"&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;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"htpbe: marshal request: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&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;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRequestWithContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MethodPost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;baseURL&lt;/span&gt;&lt;span class="o"&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;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewReader&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Bearer "&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&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;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Content-Type"&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;resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Do&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"htpbe: analyze request: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&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;Close&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;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parseError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ar&lt;/span&gt; &lt;span class="n"&gt;analyzeResponse&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&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="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ar&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"htpbe: decode analyze response: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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="n"&gt;ar&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"htpbe: analyze response missing 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;return&lt;/span&gt; &lt;span class="n"&gt;ar&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&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="n"&gt;getResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&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;Result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&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;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRequestWithContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MethodGet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;baseURL&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="s"&gt;"/result/"&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Bearer "&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&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;resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Do&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"htpbe: result request: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&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;Close&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;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="m"&gt;2&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;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parseError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="n"&gt;Result&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&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="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"htpbe: decode result: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// parseError builds a typed APIError from a non-2xx response.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;parseError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&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="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LimitReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&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="m"&gt;1&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="m"&gt;16&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;eb&lt;/span&gt; &lt;span class="n"&gt;errorBody&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;eb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// best effort — body may not be JSON&lt;/span&gt;

    &lt;span class="n"&gt;apiErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;APIError&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&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="o"&gt;:&lt;/span&gt;       &lt;span class="n"&gt;eb&lt;/span&gt;&lt;span class="o"&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="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;eb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&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="n"&gt;apiErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt; &lt;span class="o"&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;apiErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&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="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&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;case&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusUnauthorized&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="c"&gt;// 401&lt;/span&gt;
        &lt;span class="n"&gt;apiErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"invalid API key — check the HTPBE_API_KEY environment variable"&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusPaymentRequired&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="c"&gt;// 402&lt;/span&gt;
        &lt;span class="n"&gt;apiErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt; &lt;span class="o"&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="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusRequestEntityTooLarge&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="c"&gt;// 413&lt;/span&gt;
        &lt;span class="n"&gt;apiErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"PDF exceeds the 10 MB size limit"&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusUnprocessableEntity&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="c"&gt;// 422&lt;/span&gt;
        &lt;span class="n"&gt;apiErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"the URL did not return a valid PDF file"&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusTooManyRequests&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="c"&gt;// 429&lt;/span&gt;
        &lt;span class="n"&gt;apiErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RetryAfterSecs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parseRetryAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Retry-After"&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;apiErr&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// parseRetryAfter handles the delay-seconds form ("30") and the HTTP-date&lt;/span&gt;
&lt;span class="c"&gt;// form, clamping the result to a sane [1, 600] range. Returns 0 when absent&lt;/span&gt;
&lt;span class="c"&gt;// or unparseable so callers fall back to their own backoff.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;parseRetryAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&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;int&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;h&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;secs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Atoi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&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;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secs&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="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParseTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&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;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;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Until&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Seconds&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="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="kt"&gt;int&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;lo&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;lo&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;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;hi&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;hi&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;v&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;asAPIError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;APIError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;apiErr&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;APIError&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;As&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;apiErr&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;apiErr&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;nil&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;APIError.Retryable()&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt; for it — surface it to your billing logic 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;parseError&lt;/code&gt; parses (handling both &lt;code&gt;delay-seconds&lt;/code&gt; and &lt;code&gt;HTTP-date&lt;/code&gt; forms, clamped to &lt;code&gt;[1, 600]&lt;/code&gt;) and stashes on &lt;code&gt;APIError.RetryAfterSecs&lt;/code&gt;. The retry loop reads that value before falling back to exponential backoff.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 5: 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 go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;htpbe&lt;/span&gt;

&lt;span class="c"&gt;// Decision is the routing outcome for a verified document.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Accept&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"accept"&lt;/span&gt;
    &lt;span class="n"&gt;Reject&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"reject"&lt;/span&gt;
    &lt;span class="n"&gt;Review&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"review"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// GateInstitutional maps a verdict to a decision for documents that&lt;/span&gt;
&lt;span class="c"&gt;// claim institutional origin (bank statements, payslips, diplomas).&lt;/span&gt;
&lt;span class="c"&gt;// For these, "inconclusive" is treated as strictly as "modified":&lt;/span&gt;
&lt;span class="c"&gt;// a document that should have come from a bank's system but looks&lt;/span&gt;
&lt;span class="c"&gt;// like it was built in Word does not get the benefit of the doubt.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;GateInstitutional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&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="n"&gt;Decision&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&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;case&lt;/span&gt; &lt;span class="n"&gt;StatusModified&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Reject&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;StatusInconclusive&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="c"&gt;// A bank statement that comes back inconclusive should not be&lt;/span&gt;
        &lt;span class="c"&gt;// auto-accepted: it typically came from consumer software rather&lt;/span&gt;
        &lt;span class="c"&gt;// than a bank's own system, which is a signal to route for review&lt;/span&gt;
        &lt;span class="c"&gt;// — not proof of tampering. Route to a human.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Review&lt;/span&gt;
    &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="c"&gt;// StatusIntact&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Accept&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 an HTTP handler 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 go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"context"&lt;/span&gt;
    &lt;span class="s"&gt;"encoding/json"&lt;/span&gt;
    &lt;span class="s"&gt;"errors"&lt;/span&gt;
    &lt;span class="s"&gt;"log/slog"&lt;/span&gt;
    &lt;span class="s"&gt;"net/http"&lt;/span&gt;
    &lt;span class="s"&gt;"os"&lt;/span&gt;
    &lt;span class="s"&gt;"time"&lt;/span&gt;

    &lt;span class="s"&gt;"yourapp/htpbe"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;verifyRequest&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;DocumentURL&lt;/span&gt;      &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"document_url"`&lt;/span&gt;
    &lt;span class="n"&gt;OriginalFilename&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"original_filename"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&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;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;htpbe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"HTPBE_API_KEY"&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&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;"htpbe init failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"err"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exit&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;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandleFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"POST /api/documents"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="n"&gt;verifyRequest&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&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="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&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;DocumentURL&lt;/span&gt; &lt;span class="o"&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;http&lt;/span&gt;&lt;span class="o"&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;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;`{"error":"document_url is required"}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusBadRequest&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="c"&gt;// Bound the whole two-step round trip to 40 seconds.&lt;/span&gt;
        &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="m"&gt;40&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;cancel&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;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&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;DocumentURL&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;OriginalFilename&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;apiErr&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;htpbe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APIError&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;As&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;apiErr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;apiErr&lt;/span&gt;&lt;span class="o"&gt;.&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;case&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusUnauthorized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusPaymentRequired&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
                    &lt;span class="c"&gt;// Configuration / billing error — never expose details to the caller.&lt;/span&gt;
                    &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&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;"htpbe misconfigured"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;apiErr&lt;/span&gt;&lt;span class="o"&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;http&lt;/span&gt;&lt;span class="o"&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;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;`{"error":"verification temporarily unavailable"}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusServiceUnavailable&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;case&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusUnprocessableEntity&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&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;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;`{"error":"the URL did not return a valid PDF"}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusUnprocessableEntity&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;case&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusRequestEntityTooLarge&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&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;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;`{"error":"PDF must be under 10 MB"}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusRequestEntityTooLarge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&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;"htpbe verify failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"err"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&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;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;`{"error":"verification failed"}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusBadGateway&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="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;htpbe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GateInstitutional&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="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;htpbe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reject&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;writeJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusUnprocessableEntity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;map&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;any&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="s"&gt;"decision"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;             &lt;span class="s"&gt;"reject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"reason"&lt;/span&gt;&lt;span class="o"&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="s"&gt;"modification_markers"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ModificationMarkers&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;htpbe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Review&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;writeJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusAccepted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;map&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;any&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="s"&gt;"decision"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;      &lt;span class="s"&gt;"review"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"status_reason"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusReason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="c"&gt;// Accept&lt;/span&gt;
            &lt;span class="n"&gt;writeJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusOK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;map&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;any&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="s"&gt;"decision"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"accept"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"check_id"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;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;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"listening on :8080"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ListenAndServe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;":8080"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;writeJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&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;v&lt;/span&gt; &lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&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="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Content-Type"&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;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteHeader&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;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewEncoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&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;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: 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 2–5 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 the opaque storage key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Store the upload privately, mint a 5-minute presigned GET URL, verify.&lt;/span&gt;
&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s"&gt;"incoming/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;".pdf"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;s3Client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PutObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PutObjectInput&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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;Body&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ContentType&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;aws&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;presigned&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;presigner&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PresignGetObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetObjectInput&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithPresignExpires&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Minute&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;presigned&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&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;The same pattern works with Google Cloud Storage (&lt;code&gt;SignedURL&lt;/code&gt; on a bucket handle) or Cloudflare R2 (S3-compatible — reuse this 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 RFC 1918 ranges to close the SSRF surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batch Processing, Test Mode, and Quota
&lt;/h2&gt;

&lt;p&gt;A few operational details, kept short.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Synchronous flow.&lt;/strong&gt; Analysis is synchronous: &lt;code&gt;POST /analyze&lt;/code&gt; blocks until the verdict is computed, then returns the id, and the response also carries a &lt;code&gt;Location&lt;/code&gt; header pointing at the result URL. There is no queue to poll and no webhook to register — by the time &lt;code&gt;analyze&lt;/code&gt; returns, &lt;code&gt;GET /result/{id}&lt;/code&gt; is ready. &lt;code&gt;Verify&lt;/code&gt; chains both, so one call gives you the full verdict.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Batch work.&lt;/strong&gt; For a backlog of statements or a portfolio of claims, fan out across a bounded number of goroutines and let the shared &lt;code&gt;Client&lt;/code&gt; reuse its connection pool. Keep the worker count modest: the client retries on &lt;code&gt;429&lt;/code&gt;, but capping concurrency means you rarely hit capacity in the first place.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;verifyBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;htpbe&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="n"&gt;urls&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="k"&gt;map&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="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;htpbe&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="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;workers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;8&lt;/span&gt; &lt;span class="c"&gt;// stay within your plan's concurrency comfort zone&lt;/span&gt;
    &lt;span class="n"&gt;sem&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;chan&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;workers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;map&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="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;htpbe&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="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;mu&lt;/span&gt; &lt;span class="n"&gt;sync&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Mutex&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;wg&lt;/span&gt; &lt;span class="n"&gt;sync&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WaitGroup&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;wg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&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;sem&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{}{}&lt;/span&gt;
        &lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdfURL&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;wg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="k"&gt;func&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;lt;-&lt;/span&gt;&lt;span class="n"&gt;sem&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;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&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="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"verify failed"&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="n"&gt;pdfURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"err"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;results&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
            &lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;wg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Wait&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&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;&lt;strong&gt;Test mode.&lt;/strong&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. 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;. Point &lt;code&gt;Verify&lt;/code&gt; at these in your &lt;code&gt;testing&lt;/code&gt; suite to cover every branch of the gate; for handler unit tests without the network, point the client at an &lt;code&gt;httptest.Server&lt;/code&gt; via &lt;code&gt;WithBaseURL&lt;/code&gt; and serve canned JSON for &lt;code&gt;/analyze&lt;/code&gt; and &lt;code&gt;/result/{id}&lt;/code&gt;. Keep test and live keys in separate environment files and never commit either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reviewing past checks.&lt;/strong&gt; &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; for audit dashboards or weekly reports (&lt;code&gt;c.baseURL+"/checks?status=modified&amp;amp;limit=50"&lt;/code&gt;, same &lt;code&gt;Authorization&lt;/code&gt; header as the other calls).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quota.&lt;/strong&gt; 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. Handle the 402 so a quota boundary never silently drops a check, and watch consumption on the dashboard.&lt;/p&gt;

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

&lt;p&gt;Structural analysis has honest limits, and a Go service making automated decisions should encode them:&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 post-creation — it is structurally &lt;code&gt;intact&lt;/code&gt;. The fraud happened at authorship, not at the byte level. This is 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 created from scratch.&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 fields 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 defence. 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 Go 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 2–5 seconds; a goroutine or queue consumer returns instantly and defers the verdict. Sync suits low-volume B2B onboarding; async suits high-volume portals.&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. For genuinely user-generated content it may be acceptable as-is — that is what &lt;code&gt;GateInstitutional&lt;/code&gt; encodes, and 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/api" 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 from $15/mo — 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 Go client branches on.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>tutorial</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Adding Semantic Search to Internal Docs in 200 Lines</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Fri, 17 Jul 2026 10:00:48 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/adding-semantic-search-to-internal-docs-in-200-lines-39fj</link>
      <guid>https://dev.to/iurii_rogulia/adding-semantic-search-to-internal-docs-in-200-lines-39fj</guid>
      <description>&lt;p&gt;Someone on your team asks in chat: "how do I cancel a customer's order after it shipped?" Your wiki has the answer. It's a page titled &lt;strong&gt;Returns and reversals — post-fulfilment procedure&lt;/strong&gt;. Nobody finds it, because they searched for "cancel order" and the document never uses the word "cancel." So they ping a colleague, who re-explains a process that was already written down two years ago.&lt;/p&gt;

&lt;p&gt;That gap is what semantic search over internal docs closes. Keyword search matches strings; people ask questions in their own words. This article is a compact, working recipe — chunk, embed, query by meaning — built on Postgres and &lt;code&gt;pgvector&lt;/code&gt;, in roughly two hundred lines. It is strictly about &lt;strong&gt;retrieval&lt;/strong&gt;: finding the right document. Not generating an answer on top of it. That distinction is the whole point, and I'll come back to why it matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Keyword Search Runs Out
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;LIKE '%cancel%'&lt;/code&gt; and even Postgres full-text search both match tokens. They are excellent when the searcher and the author happen to use the same words. They fall apart the moment they don't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"how do I cancel a shipped order" vs. a doc titled "cancellation policy" — the policy page might never contain the verb the user typed.&lt;/li&gt;
&lt;li&gt;"the app is slow after login" vs. "performance degradation on session initialization" — zero shared content words, same meaning.&lt;/li&gt;
&lt;li&gt;"expense reimbursement" vs. "travel claims" — synonyms a keyword index treats as unrelated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can paper over some of this with synonym dictionaries and stemming, and full-text search with a good &lt;code&gt;tsvector&lt;/code&gt; configuration genuinely helps. But you are maintaining a hand-curated thesaurus forever, and it still misses paraphrases nobody anticipated. Semantic search attacks the problem from the other side: it compares the &lt;strong&gt;meaning&lt;/strong&gt; of the query to the meaning of each document, not the surface words.&lt;/p&gt;

&lt;p&gt;The mechanism is embeddings. An embedding model maps a piece of text to a vector — a list of numbers — such that texts with similar meaning land close together in that space. "Cancel a shipped order" and "post-fulfilment reversal procedure" end up near each other even with no shared words. Search then becomes: embed the query, find the nearest document vectors, return those documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pipeline in Three Steps
&lt;/h2&gt;

&lt;p&gt;The entire system is three moves:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Chunk&lt;/strong&gt; each document into pieces of a sensible size, with a little overlap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embed&lt;/strong&gt; every chunk once, at ingest time, and store the vectors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query&lt;/strong&gt;: embed the incoming question, find the top-k nearest chunks by cosine distance, return them with a link back to the source.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. There's no model being asked to write prose, no agent loop, no streaming. Just "given this question, here are the five most relevant passages and where they came from."&lt;/p&gt;

&lt;h3&gt;
  
  
  Storage: Postgres + pgvector
&lt;/h3&gt;

&lt;p&gt;If you already run Postgres, you do not need a separate vector database to start. &lt;a href="https://github.com/pgvector/pgvector" rel="noopener noreferrer"&gt;&lt;code&gt;pgvector&lt;/code&gt;&lt;/a&gt; is a Postgres extension that adds a &lt;code&gt;vector&lt;/code&gt; column type and distance operators. It keeps your documents and their embeddings in the same database you already back up, query, and monitor — which is reason enough for most internal-tooling cases. (I keep the rest of my Postgres habits in &lt;a href="https://iurii.rogulia.fi/blog/postgresql-production-patterns" rel="noopener noreferrer"&gt;PostgreSQL production patterns&lt;/a&gt;; the same indexing discipline applies here.)&lt;/p&gt;

&lt;p&gt;The schema. I'm using OpenAI's &lt;code&gt;text-embedding-3-small&lt;/code&gt;, which produces 1536-dimensional vectors, so the column is &lt;code&gt;vector(1536)&lt;/code&gt;:&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="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;doc_chunks&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;BIGSERIAL&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="k"&gt;source&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&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="c1"&gt;-- file path, page ID, or ticket URL&lt;/span&gt;
  &lt;span class="n"&gt;title&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&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="c1"&gt;-- for citation in results&lt;/span&gt;
  &lt;span class="n"&gt;url&lt;/span&gt;         &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                 &lt;span class="c1"&gt;-- link back to the original&lt;/span&gt;
  &lt;span class="n"&gt;chunk_index&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="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;-- position within the source doc&lt;/span&gt;
  &lt;span class="n"&gt;content&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&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="c1"&gt;-- the raw chunk text, returned to the user&lt;/span&gt;
  &lt;span class="n"&gt;embedding&lt;/span&gt;   &lt;span class="n"&gt;VECTOR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;created_at&lt;/span&gt;  &lt;span class="n"&gt;TIMESTAMPTZ&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="n"&gt;NOW&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 vector index is the part people get wrong. &lt;code&gt;pgvector&lt;/code&gt; offers two index types. &lt;code&gt;ivfflat&lt;/code&gt; partitions vectors into lists and is fast to build but needs you to set the list count and &lt;code&gt;probes&lt;/code&gt; at query time. &lt;code&gt;hnsw&lt;/code&gt; builds a graph, is slower to build and uses more memory, but gives better recall at a given speed and needs no list tuning. For an internal corpus that fits comfortably in memory, I default to &lt;code&gt;hnsw&lt;/code&gt;. Crucially, the index operator class must match the distance operator you query with — cosine distance is &lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt;, so the index uses &lt;code&gt;vector_cosine_ops&lt;/code&gt;:&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;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;doc_chunks&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A note on honesty: for a corpus of a few hundred chunks, you don't even need an index — or &lt;code&gt;pgvector&lt;/code&gt; — at all. A sequential scan over a few hundred vectors is milliseconds, and you could hold the whole thing in memory and compute cosine similarity in plain TypeScript. The Postgres path earns its keep when the corpus grows, when you want the data sitting next to the rest of your application state, and when concurrent queries matter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Chunking
&lt;/h3&gt;

&lt;p&gt;Chunking turns each document into the units you'll actually retrieve. The goal: each chunk should be a coherent, self-contained passage, big enough to carry meaning, small enough that its embedding represents one topic rather than a blur of several.&lt;/p&gt;

&lt;p&gt;I split on structure first (headings, then paragraphs) and only fall back to a hard token cap when a section is too long. Overlap carries a little context across boundaries so a sentence split across two chunks still retrieves. This is illustrative — paragraph-aware, not exhaustive — but it's the shape I actually use:&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;// chunk.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;encoding_for_model&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tiktoken&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;MAX_TOKENS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// target chunk size&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;OVERLAP_TOKENS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// carry-over between adjacent chunks&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;enc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;encoding_for_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text-embedding-3-small&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;tokenLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Chunk&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;content&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;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;chunkDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;markdown&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;Chunk&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Split on blank lines (paragraphs / heading blocks) first.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;markdown&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\n{2,}&lt;/span&gt;&lt;span class="sr"&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;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunks&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;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;current&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;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;currentTokens&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="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;block&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;blocks&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;blockTokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tokenLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// A single oversized block: hard-split it on token count.&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;blockTokens&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_TOKENS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;chunks&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;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
        &lt;span class="nx"&gt;currentTokens&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="nx"&gt;chunks&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="nf"&gt;splitOversized&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
      &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentTokens&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;blockTokens&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_TOKENS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;chunks&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;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
      &lt;span class="c1"&gt;// Start the next chunk with a tail of the previous one for overlap.&lt;/span&gt;
      &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;overlapTail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;OVERLAP_TOKENS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;currentTokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tokenLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;current&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;block&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;currentTokens&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;blockTokens&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;chunks&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;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;chunks&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;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&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;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;splitOversized&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;block&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;block&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;out&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;=&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;let&lt;/span&gt; &lt;span class="nx"&gt;start&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="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;MAX_TOKENS&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;OVERLAP_TOKENS&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;slice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;MAX_TOKENS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;out&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;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;overlapTail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blocks&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;targetTokens&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="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;tail&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;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;count&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;blocks&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;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&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="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;targetTokens&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;tail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unshift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;tokenLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tail&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;h3&gt;
  
  
  Embedding and Ingesting
&lt;/h3&gt;

&lt;p&gt;Embed each chunk once, at ingest, and store the vector alongside its source metadata. Batch the calls — the embeddings endpoint accepts many inputs per request, which is both faster and cheaper than one call per chunk:&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;// ingest.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;OpenAI&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chunkDocument&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./chunk&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;openai&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;OpenAI&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&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;OPENAI_API_KEY&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;pool&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;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;connectionString&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;DATABASE_URL&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;EMBEDDING_MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text-embedding-3-small&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;SourceDoc&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&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="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;title&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;url&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;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;embedBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;texts&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&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;res&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;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;embeddings&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="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EMBEDDING_MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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;d&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;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ingest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SourceDoc&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;chunkDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;embeddings&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;embedBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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;client&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;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BEGIN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Re-ingest cleanly: drop the old chunks for this source first.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DELETE FROM doc_chunks WHERE source = $1&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;doc&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;`INSERT INTO doc_chunks (source, title, url, chunk_index, content, embedding)
         VALUES ($1, $2, $3, $4, $5, $6)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="nx"&gt;doc&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="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&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="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="c1"&gt;// pgvector accepts a vector literal: '[0.1,0.2,...]'&lt;/span&gt;
          &lt;span class="s2"&gt;`[&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;]`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;COMMIT&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;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ROLLBACK&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;DELETE&lt;/code&gt;-then-&lt;code&gt;INSERT&lt;/code&gt; per source makes re-ingesting a single edited document idempotent: change a wiki page, re-run ingest for that one source, and its old chunks are replaced. For a large corpus where most documents are unchanged between runs, hash the content and skip embedding when the hash matches the stored one — embedding calls are cheap individually but add up across thousands of chunks. I cover that and the rest of the embedding-cost surface in &lt;a href="https://iurii.rogulia.fi/blog/openai-cost-control-production" rel="noopener noreferrer"&gt;reducing OpenAI API costs in production&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Query: Nearest Neighbours by Cosine Distance
&lt;/h3&gt;

&lt;p&gt;At query time, embed the question with the &lt;strong&gt;same model&lt;/strong&gt; used at ingest, then ask Postgres for the nearest chunks. The &lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt; operator is cosine distance — smaller is closer — so you &lt;code&gt;ORDER BY embedding &amp;lt;=&amp;gt; $1&lt;/code&gt; and &lt;code&gt;LIMIT k&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// search.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;OpenAI&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pg&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;openai&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;OpenAI&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&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;OPENAI_API_KEY&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;pool&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;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;connectionString&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;DATABASE_URL&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;SearchHit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;title&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;url&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="nl"&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="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;content&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;distance&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="c1"&gt;// 0 = identical direction, 2 = opposite&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;search&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&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;SearchHit&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;res&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;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;embeddings&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="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text-embedding-3-small&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// must match the ingest model&lt;/span&gt;
    &lt;span class="na"&gt;input&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="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;queryVector&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`[&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;]`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SearchHit&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`SELECT title, url, source, content,
            embedding &amp;lt;=&amp;gt; $1 AS distance
     FROM doc_chunks
     ORDER BY embedding &amp;lt;=&amp;gt; $1
     LIMIT $2`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;queryVector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole retrieval system. Chunking, ingest, and search together land in the low hundreds of lines — call it the order of two hundred — because the hard parts of a full RAG stack are deliberately absent. No answer generation, no reranker, no UI, no streaming. The output is a ranked list of real passages with a &lt;code&gt;title&lt;/code&gt;, a &lt;code&gt;url&lt;/code&gt;, and a &lt;code&gt;distance&lt;/code&gt; you can show as a relevance hint. Wire it to a search box and a list of links, and people find documents by meaning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chunking Is Where the Quality Lives
&lt;/h2&gt;

&lt;p&gt;If you take one thing from this article: the embedding model is rarely your bottleneck. Chunking is. A worse model with good chunks beats a great model with bad ones.&lt;/p&gt;

&lt;p&gt;Chunks that are too large blur multiple topics into one vector, so the chunk matches everything weakly and nothing strongly. Chunks that are too small lose the context that made them meaningful — a sentence retrieved without its surrounding paragraph is often useless to whoever reads it. Split a procedure in the middle of a numbered list and the retrieved fragment answers half a question.&lt;/p&gt;

&lt;p&gt;The levers that move relevance more than model choice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Split on structure, not character count.&lt;/strong&gt; Honour headings and paragraph boundaries. A chunk that maps to one section of a doc retrieves far better than one cut at an arbitrary 500-character mark.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Carry metadata.&lt;/strong&gt; Store &lt;code&gt;title&lt;/code&gt; and &lt;code&gt;url&lt;/code&gt; with every chunk. You need them to cite the source and link back — the entire value proposition is "here's the document," not "here's an orphan paragraph."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tune overlap.&lt;/strong&gt; A small overlap (10–15% of chunk size) keeps boundary-straddling ideas retrievable. Too much overlap inflates storage and returns near-duplicate hits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no universal chunk size. 400 tokens is a reasonable default for prose handbooks; dense reference material or short FAQ entries want different settings. You find yours by running real queries from your team against the index and reading what comes back.&lt;/p&gt;

&lt;p&gt;
  slug="ai-integration"&lt;br&gt;
  text="Have the docs but can't find them by meaning? I build semantic search over internal wikis, handbooks, and ticket history — Postgres-native, retrieval-first, no hallucinated answers."&lt;br&gt;
/&amp;gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Semantic Search Falls Short — Honestly
&lt;/h2&gt;

&lt;p&gt;Pure vector search is a tool, not a search oracle. The places it disappoints are predictable, so plan for them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It loses to keyword matching on exact terms.&lt;/strong&gt; Error codes, function names, SKUs, ticket IDs, acronyms — embeddings &lt;em&gt;generalize&lt;/em&gt;, which is exactly wrong when the user wants &lt;code&gt;ERR_2043&lt;/code&gt; and not "errors that feel similar." For any corpus where exact tokens matter, the answer is a &lt;strong&gt;hybrid&lt;/strong&gt;: run vector search and keyword (Postgres full-text or BM25) in parallel and combine the rankings. Add a reranking step when the stakes justify the extra latency and cost. If your internal docs are full of identifiers, treat hybrid as the baseline, not an upgrade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chunking is fragile and has no settled answer.&lt;/strong&gt; Too coarse and you retrieve noise; too fine and you retrieve context-free fragments. The right size depends on your content, and you only learn it by testing against your own queries. Expect to re-tune it after you see real usage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embeddings go stale, and re-embedding has a cost.&lt;/strong&gt; Edit a document and its old chunks no longer match the new text — you must re-embed that source. Worse: if you switch embedding models, vectors from the old model and the new one live in incompatible spaces and &lt;strong&gt;cannot be compared&lt;/strong&gt;. Changing models means re-embedding the entire corpus. Pin your model version and treat a model change as a migration, not a config tweak.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It returns similar, not correct.&lt;/strong&gt; Nearest-neighbour search gives you the passages closest in meaning to the query. "Closest" is not "right." The top hit can be a confidently-worded but outdated policy; the embedding has no notion of which document is authoritative or current. You must validate ranking quality on real queries from your team and keep the source documents trustworthy — garbage in, confidently-ranked garbage out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sometimes you don't need it at all.&lt;/strong&gt; A few dozen documents? Ctrl+F and Postgres full-text search are simpler, free, and good enough — adding an embedding pipeline is over-engineering. When exact matching matters more than meaning, keyword search is the right primary, not a fallback. Reach for semantic search when the corpus is large enough that browsing fails and paraphrase is the actual problem.&lt;/p&gt;

&lt;p&gt;Every one of these is manageable. None of them is hidden if you go in expecting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Search Returns Documents. A Chatbot Writes Answers.
&lt;/h2&gt;

&lt;p&gt;This is the line worth drawing clearly. Everything above &lt;strong&gt;retrieves&lt;/strong&gt; — it hands back real passages and links, and it never invents anything, because there's no generation step to invent with. That's a feature: a search box that returns the actual returns-policy page cannot hallucinate a returns policy. For "nobody can find the doc," retrieval alone solves the problem.&lt;/p&gt;

&lt;p&gt;If you want a bot that reads those passages and writes a natural-language answer on top of them — "To cancel a shipped order, create a reversal in the admin panel, then…" — that's the next layer: retrieval-augmented generation. It adds an LLM, a confidence threshold so it escalates instead of guessing, source citation, and a streaming UI, plus a new failure mode the search-only version doesn't have (a model can phrase a wrong answer fluently). I built that end to end for an e-commerce support desk — across 25 languages, resolving 70% of tickets without a human — in &lt;a href="https://iurii.rogulia.fi/blog/rag-chatbot-ecommerce" rel="noopener noreferrer"&gt;the RAG chatbot architecture write-up&lt;/a&gt;. The semantic search here is the retrieval half of that system, useful entirely on its own.&lt;/p&gt;

&lt;p&gt;Most teams I talk to think they need the chatbot. They need the search. Build the retrieval layer first, watch what people actually ask, and only add generation if returning documents turns out not to be enough.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keyword search matches words; people ask by meaning.&lt;/strong&gt; Semantic search closes that gap by comparing the meaning of the query to the meaning of each chunk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The pipeline is three steps:&lt;/strong&gt; chunk with overlap, embed once at ingest, query by cosine distance (&lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt;) for the top-k nearest chunks. On Postgres + &lt;code&gt;pgvector&lt;/code&gt; it's roughly two hundred lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunking, not the model, decides quality.&lt;/strong&gt; Split on structure, carry &lt;code&gt;title&lt;/code&gt;/&lt;code&gt;url&lt;/code&gt; metadata for citation, tune overlap, and test against your own queries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Go hybrid for exact terms.&lt;/strong&gt; Error codes, SKUs, and acronyms need keyword/BM25 alongside vectors; pin your embedding model, because changing it means re-embedding everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrieval ≠ generation.&lt;/strong&gt; Search returns documents and cannot hallucinate. A RAG chatbot writes an answer on top and can. Build search first.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you're sitting on a wiki, a handbook, and years of tickets that your team can't search by meaning, that's exactly the kind of &lt;a href="https://iurii.rogulia.fi/services/ai-integration" rel="noopener noreferrer"&gt;AI integration&lt;/a&gt; I do — retrieval-first, on the Postgres you already run, with the honest limits built in from the start rather than discovered later.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>node</category>
      <category>postgres</category>
      <category>ai</category>
    </item>
    <item>
      <title>SBA-7a Loan Stip Fraud Detection: Post-PPP Lessons</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Fri, 17 Jul 2026 10:00:39 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/sba-7a-loan-stip-fraud-detection-post-ppp-lessons-2ef7</link>
      <guid>https://dev.to/iurii_rogulia/sba-7a-loan-stip-fraud-detection-post-ppp-lessons-2ef7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/sba-business-loan-stip-fraud" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt;. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The PPP wave was, in retrospect, the largest controlled experiment in small-business stip-doc fraud the lending industry has ever observed. The DOJ has publicly disclosed thousands of prosecutions; the SBA Office of the Inspector General has publicly estimated PPP and EIDL fraud losses in the tens of billions across the program. Whatever the exact number turns out to be after all enforcement and recovery cycles close, two facts are no longer in dispute: small-business document fraud is large, and the dominant attack pattern was not synthetic identity. It was real people uploading altered or fabricated PDFs — bank statements, tax returns, payroll registers, voided checks — built with mainstream consumer tools.&lt;/p&gt;

&lt;p&gt;That has reshaped how fintech business lenders and SBA-7a preferred lenders run stip-doc review for the products that came after: 7(a) term loans, EIDL successors, working-capital lines, conventional small-business loans. Across fintech business lenders, SBA-7a preferred lenders, and platforms such as Funding Circle, Lendio, Bluevine, OnDeck, Fundbox, Square Capital, Stripe Capital, Live Oak, Newtek, and Celtic Bank, post-PPP review playbooks have converged on four recurring fraud patterns. This article walks through each one, the structural signals that flag it, and the honest limits of what file-level forensics can and cannot resolve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1 — Altered Business Bank Statements
&lt;/h2&gt;

&lt;p&gt;The most common pattern, and the one closest to the consumer-lending playbook. A borrower downloads a real PDF statement from Wells Fargo Business, Chase Business, Bluevine, Mercury, or Brex, opens it in Adobe Acrobat or an online editor, and changes the figures that matter: average daily balance, ending balance, deposit count, NSF lines.&lt;/p&gt;

&lt;p&gt;Structurally the editor leaves the same fingerprints it leaves on consumer statements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;producer&lt;/code&gt; field shifts from the bank’s server-side engine to a consumer editor — Adobe Acrobat, iLovePDF, PDF24, Smallpdf, Preview. Public marker: &lt;code&gt;HTPBE_ONLINE_EDITOR_ORIGIN&lt;/code&gt; or &lt;code&gt;HTPBE_EDITING_TOOL_FINGERPRINT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A second cross-reference layer appears, because every save-after-edit appends a new xref. Public marker: &lt;code&gt;HTPBE_MULTIPLE_REVISION_LAYERS&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The modification timestamp lands hours or days after the declared creation timestamp. Public marker: &lt;code&gt;HTPBE_DATES_DISAGREE&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A statement from Chase Business that was generated by &lt;code&gt;chase.com&lt;/code&gt; server-side and re-saved through Acrobat on Tuesday afternoon will carry all three signals. The verdict comes back &lt;code&gt;modified&lt;/code&gt; with high confidence. The consumer-side analogue is covered 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;, and the broader workflow context is in &lt;a href="https://htpbe.tech/blog/pdf-fraud-detection-loan-origination" rel="noopener noreferrer"&gt;PDF fraud detection in loan origination&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Caveat that matters in practice: smaller business banking apps and credit unions sometimes export through generic print drivers. A &lt;code&gt;inconclusive&lt;/code&gt; verdict on a statement claimed to be from one of the major business banks is itself a flag — route to verification. The same verdict on a small community-bank statement is closer to noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 2 — Fabricated Business Tax Returns
&lt;/h2&gt;

&lt;p&gt;The most loss-prone document class and the one where honesty about scope matters most.&lt;/p&gt;

&lt;p&gt;Business tax returns — Form 1120, 1120-S, and 1065 — were the structurally weakest control during PPP. Thousands of borrowers submitted returns that had never been filed with the IRS, because nothing in the upload-and-review workflow ever crossed back to the IRS to confirm filing. Two distinct attack types showed up in the post-loss reviews:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Type A — Edited real returns.&lt;/strong&gt; The borrower starts from a genuine filed return and uses Acrobat to inflate gross receipts, net income, or owner compensation. This is structurally identical to bank-statement editing and produces the same markers: editor fingerprint, second revision layer, date disagreement. Structural forensics catches this type cleanly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Type B — Clean rebuilds from scratch.&lt;/strong&gt; The borrower (or a paid forger) generates a tax return from scratch using a programmatic PDF library — PDFKit, ReportLab, or a one-off Puppeteer template. The output is born synthetic: no editing history, no producer mismatch, no incremental updates. The fields are made up but the file looks pristine. Structural forensics returns &lt;code&gt;inconclusive&lt;/code&gt; because the document was generated by a consumer-class toolchain, which is the correct verdict for what the byte layer actually reveals.&lt;/p&gt;

&lt;p&gt;The IRS Form 4506-C tax-transcript request is the ground-truth verification for U.S. business tax returns — it pulls the IRS’s own record of what was actually filed and reconciles the borrower-supplied figures against it. In higher-control SBA-7a workflows, 4506-C is often treated as a hard gate on every tax return. The role of structural forensics on this document class is to catch Type A cheaply on day one and to sequence 4506-C ordering more efficiently — the &lt;code&gt;modified&lt;/code&gt; files go to the front of the queue; the &lt;code&gt;intact&lt;/code&gt; files still need 4506-C but the structural record adds context to the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 3 — Forged Voided Checks and Banking Attestations
&lt;/h2&gt;

&lt;p&gt;A small document class with disproportionate downside. The voided check or bank-letter attestation supplied at funding determines which routing and account number the loan proceeds get wired to. A successful swap at this stage moves the money to an account the borrower controls but the lender has never seen mentioned in the application.&lt;/p&gt;

&lt;p&gt;Two attack flavours:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Edited voided check.&lt;/strong&gt; Borrower opens a screenshot or PDF of their own check in Acrobat and overwrites the routing or account digits. Structural signals fire as on any edited document: editor producer, incremental update layer, glyph-level edits if individual digits were replaced (&lt;code&gt;HTPBE_GLYPH_LEVEL_EDIT&lt;/code&gt;, &lt;code&gt;HTPBE_CHARACTER_OVERLAY_EDIT&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rebuilt voided check.&lt;/strong&gt; Borrower generates the image in any drawing tool, exports to PDF. Born-synthetic, returns &lt;code&gt;inconclusive&lt;/code&gt; for the same reason as tax returns above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The compensating control here is operational, not structural: a callback to the bank using a phone number from an independent source — the bank’s public website, not a number printed on the document. Structural forensics catches the editor-altered version on day one and reduces the queue that needs callback verification. It does not replace the callback for a born-synthetic rebuild.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 4 — PPP Forgiveness Applications (Retrospective)
&lt;/h2&gt;

&lt;p&gt;This pattern is unusual because it is backward-looking, but it is now a live workstream at multiple SBA-7a lenders. The SBA Office of the Inspector General continues to audit PPP forgiveness decisions, and lenders defending those audits need to demonstrate the documentary basis on which forgiveness was approved.&lt;/p&gt;

&lt;p&gt;For lenders that kept the original PDF application files, running structural forensics on those files now produces an audit-trail artifact: a persistent &lt;code&gt;check_id&lt;/code&gt;, the verdict at the time of analysis, the markers present, the producer string, the timestamp layers. If a particular forgiveness file later becomes the subject of an OIG question, the lender has a contemporaneous structural record alongside the underwriter’s notes.&lt;/p&gt;

&lt;p&gt;This is not a fraud-detection use case in the live-pipeline sense — the loans are already funded and forgiven. It is an audit-defence and discovery use case. Most of the lenders running this work batch-process the historical application files through the API once, store the &lt;code&gt;check_id&lt;/code&gt; against the loan record, and surface it on demand when an OIG inquiry lands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Check Fits in an SBA-7a Stip Workflow
&lt;/h2&gt;

&lt;p&gt;SBA-7a and conventional small-business loan files move on a 30–60 day clock. Adding a 1–4 second per-document structural check does not move the critical path. The integration points that have worked in production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;At stip-doc receipt&lt;/strong&gt; — every uploaded business bank statement, tax return, voided check, and payroll register is sent to the API immediately. The verdict and markers attach to the document record in the LOS. &lt;code&gt;modified&lt;/code&gt; files route to a fraud-ops queue before the credit decision; &lt;code&gt;inconclusive&lt;/code&gt; files route based on what was claimed to be uploaded; &lt;code&gt;intact&lt;/code&gt; files proceed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Before funding wire&lt;/strong&gt; — the voided check or bank-letter on file is re-checked at funding. This is the last point at which an account number could have been swapped between underwriting and disbursement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Before SBA-7a guarantee package finalisation&lt;/strong&gt; — for 7(a) loans, the package submitted to SBA for the guarantee includes the underlying stip docs. Running the check immediately before package assembly ensures the documents in the guarantee file match the structural record from intake.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A minimal integration call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.htpbe.tech/v1/analyze &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$HTPBE_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "url": "https://files.example-lender.com/stips/1120-2025-borrower-12345.pdf",
    "tool": "sba-7a-stip-review"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response carries a verdict (&lt;code&gt;intact&lt;/code&gt;, &lt;code&gt;modified&lt;/code&gt;, or &lt;code&gt;inconclusive&lt;/code&gt;), public marker IDs, and a persistent &lt;code&gt;check_id&lt;/code&gt; that becomes the audit-trail anchor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calibrating the Queue
&lt;/h2&gt;

&lt;p&gt;Public datasets on small-business document tamper rates are thin, and lenders that have measured internally rarely publish. As planning assumptions — not benchmarks — lenders building a queue capacity model have used these ranges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Subprime business lending&lt;/strong&gt; (MCAs, alternative term loans, high-risk EIDL successors): plan for roughly &lt;strong&gt;5–10%&lt;/strong&gt; of stip docs to return &lt;code&gt;modified&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prime small-business lending&lt;/strong&gt; (Live Oak / Celtic / Newtek SBA-7a books, conventional small-business term loans at major banks): plan for roughly &lt;strong&gt;1–2%&lt;/strong&gt; of stip docs to return &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; rates depend on the document mix — expect higher rates on tax returns (more rebuild attacks, more legitimate consumer-tool exports from accountants) than on bank statements from major business-banking platforms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat these as planning numbers. The real distribution at your shop is a function of channel, broker mix, product, and ticket size. Re-measure after the first quarter of live data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Structural Forensics Cannot Do
&lt;/h2&gt;

&lt;p&gt;Stated plainly so it does not have to be guessed at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Clean rebuilds return &lt;code&gt;inconclusive&lt;/code&gt;, not &lt;code&gt;modified&lt;/code&gt;.&lt;/strong&gt; A tax return generated from scratch in PDFKit, a voided check rebuilt in a drawing tool, a bank statement assembled in a templated forgery service — none of these will be caught by structural-byte analysis. They will be caught by 4506-C (for tax returns), by bank callback (for routing numbers), and by document-content rules at other vendors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;intact&lt;/code&gt; does not mean the figures are real.&lt;/strong&gt; It means the document was not modified after creation. A real bank statement with real fraudulent transactions inside it (kiting, structured deposits) is structurally &lt;code&gt;intact&lt;/code&gt;. Behavioural fraud at the transaction layer is not the file layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;inconclusive&lt;/code&gt; is not a verdict against the borrower.&lt;/strong&gt; Many legitimate small-business documents return &lt;code&gt;inconclusive&lt;/code&gt; — accountants exporting from Drake or Lacerte through generic print drivers, bookkeepers re-saving QuickBooks reports, small banks that use consumer-class PDF pipelines. The right action on &lt;code&gt;inconclusive&lt;/code&gt; is calibrated escalation based on what was claimed to be uploaded, not auto-decline.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Audit Trail and Loss-Cause Attribution
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;analyze&lt;/code&gt; call returns a persistent &lt;code&gt;check_id&lt;/code&gt; queryable through &lt;code&gt;GET /api/v1/result/{check_id}&lt;/code&gt;. Storing it against the loan record adds two operational signals beyond the live fraud-screening use case (the retrospective PPP-forgiveness pattern above is the third):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Loss-cause attribution.&lt;/strong&gt; When a loan defaults and the post-mortem asks “were the documents real,” the structural record from intake answers part of that question alongside underwriter notes and the 4506-C on file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Broker performance review.&lt;/strong&gt; Aggregated by submitting broker, the &lt;code&gt;modified&lt;/code&gt; and &lt;code&gt;inconclusive&lt;/code&gt; rates surface which channels are sending higher-risk paper.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Integration documentation lives at &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;/api&lt;/a&gt;; &lt;a href="https://htpbe.tech/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; scales with monthly check volume.&lt;/p&gt;

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

&lt;p&gt;This article is written for the people who actually own this decision:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Head of Credit Risk&lt;/strong&gt; at a fintech small-business lender deciding whether to add a structural-forensics layer on top of an existing KYB + bank-data + 4506-C stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Director of Fraud Operations&lt;/strong&gt; at an SBA-7a preferred lender building a stip-doc review playbook that has to defend audit positions years after origination.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VP Origination&lt;/strong&gt; at a regional bank with a small-business book asking what the post-PPP review cycles actually changed about the way loan files should be screened.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same four attack patterns surface across adjacent SMB lending verticals — merchant cash advance and revenue-based finance underwriting (where bank statements are the primary signal), equipment finance (where forged voided checks at funding are the high-leverage attack), invoice factoring (where altered invoices and aging schedules play the role tax returns play here), and conventional non-SBA term loans at regional banks. The 4506-C control is especially central in SBA-7a tax-return review; the structural-forensics layer is not SBA-specific. The end-to-end view of how the same filter fits across these flows is in the &lt;a href="https://htpbe.tech/blog/document-fraud-detection-fintech-workflow" rel="noopener noreferrer"&gt;document fraud detection fintech workflow&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;How does structural forensics differ from a 4506-C tax-transcript pull?&lt;/strong&gt;&lt;br&gt;
4506-C asks the IRS what the borrower actually filed; structural forensics asks whether the PDF supplied to the lender was edited after it was generated. They answer different questions. 4506-C is the ground-truth control for tax-return content; structural forensics catches the edited-real-return subset cheaply on day one and lets you sequence 4506-C orders more efficiently. Use both for tax returns; structural-only is often sufficient as a first-pass screen for bank statements, where the bank’s own portal is the institutional reference and structural signals reliably identify post-portal editing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will this slow down our 30–60 day SBA-7a clock?&lt;/strong&gt;&lt;br&gt;
A typical analyze call returns in 1–4 seconds. At stip-doc receipt the result is back before the document has been routed to a human reviewer. There is no measurable impact on the funding clock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens on a clean PDFKit rebuild of a forged tax return?&lt;/strong&gt;&lt;br&gt;
The verdict is &lt;code&gt;inconclusive&lt;/code&gt;. The document was generated by a consumer-class toolchain, so structural integrity cannot be evaluated against an institutional baseline. The right downstream action is 4506-C verification, which is the ground-truth control for whether the return was filed at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the historical-PPP audit-defence use case actually useful, given the loans are closed?&lt;/strong&gt;&lt;br&gt;
For lenders defending active OIG inquiries, yes — the structural record from the original application files becomes a contemporaneous artifact in the audit response. For lenders with closed and clean books, it is optional. The batch-process cost is one-time and small relative to even a single OIG dispute.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>fintech</category>
      <category>fraud</category>
      <category>api</category>
    </item>
    <item>
      <title>My Checklist for Reviewing AI-Generated Code</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Thu, 16 Jul 2026 10:01:05 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/my-checklist-for-reviewing-ai-generated-code-2iop</link>
      <guid>https://dev.to/iurii_rogulia/my-checklist-for-reviewing-ai-generated-code-2iop</guid>
      <description>&lt;p&gt;The agent handed me a function that fetched a Stripe customer, read &lt;code&gt;customer.tax_ids.data[0].value&lt;/code&gt;, and used it as the VAT number for an invoice. Clean code. Typed. Named well. It read perfectly. It also assumed every customer has exactly one tax ID at index zero, that the array is never empty, and that the first entry is the VAT number rather than, say, an Australian ABN. None of those assumptions hold. The function would work in every test I'd bother to write by eye and break the first time a real customer had two tax IDs or none. I almost merged it, because nothing about reading it told me to stop.&lt;/p&gt;

&lt;p&gt;That is the entire problem with &lt;strong&gt;reviewing AI-generated code&lt;/strong&gt;, and it's why I keep a separate checklist for it. When I &lt;a href="https://iurii.rogulia.fi/blog/when-ai-is-faster-than-a-senior" rel="noopener noreferrer"&gt;decided where to delegate to an agent in the first place&lt;/a&gt;, I closed with a line: how to review what the agent produces is a whole discipline of its own. This is that discipline. The companion to it is &lt;a href="https://iurii.rogulia.fi/blog/ai-agent-codebase-prompts" rel="noopener noreferrer"&gt;the prompts that prevent bad output before generation&lt;/a&gt; — that's the upstream half. This is the downstream half: what to look for once the code already exists, when prevention didn't catch everything, because it never does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AI Code Fails in a Different Place
&lt;/h2&gt;

&lt;p&gt;Human code and AI code fail in different places, and that difference is the whole reason a generic review is the wrong tool.&lt;/p&gt;

&lt;p&gt;When a person writes code, the defects cluster where the person struggled. The awkward function reads awkwardly. The half-understood API gets used hesitantly, with a comment that says "not sure this is right." The reviewer's instinct — slow down where the code looks uncertain — works, because the code's surface correlates with the author's confidence.&lt;/p&gt;

&lt;p&gt;AI code has no such tell. It is uniformly fluent. The function that's subtly wrong reads exactly as smoothly as the function that's correct, because fluency is what the model optimizes — it produces the most plausible-looking continuation, and plausible-looking is the entire failure mode. The defect doesn't sit where the prose got awkward. There is no awkward prose. This is the class I call &lt;strong&gt;confidently wrong&lt;/strong&gt;: code that is articulate, idiomatic, well-named, and incorrect in a way its own surface will never reveal.&lt;/p&gt;

&lt;p&gt;So the reviewer's normal instinct actively misfires. "It reads well" is evidence of nothing. The model is good at exactly the signal you were using as a proxy for correctness. A review tuned for human code — skim the clean parts, slow down at the messy ones — sails straight past the bugs, because for AI code there are no messy parts to slow down at.&lt;/p&gt;

&lt;p&gt;The checklist below is ordered by &lt;strong&gt;cost of error&lt;/strong&gt;, not by how often each defect appears. That ordering is the opinion in this article. You will not catch everything — review never does, and some defects only surface under production load — so spend your attention where being wrong is most expensive. A cosmetic naming miss in a one-off script and a missing tenant filter in a billing query are not the same risk, and a checklist that treats them equally wastes the scarce thing, which is your attention.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Hallucinated APIs and Signatures
&lt;/h2&gt;

&lt;p&gt;Highest on the list because it's the most AI-specific defect and the cheapest to catch — if you actually run the code instead of reading it.&lt;/p&gt;

&lt;p&gt;Models invent. They produce method names that should exist, config fields that sound right, function arguments in a plausible order, package versions that were never published. The invention is confident and consistent — the model will use the hallucinated helper three times in the same file, which makes it look deliberate.&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;// The model "remembered" a method that doesn't exist.&lt;/span&gt;
&lt;span class="c1"&gt;// zod has .parse() and .safeParse() — there is no .validate().&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="nx"&gt;schema&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="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Plausible argument order, wrong reality. Stripe's&lt;/span&gt;
&lt;span class="c1"&gt;// charges.create takes one params object, not positional args.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charges&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="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is not "read more carefully." You cannot read your way to catching a hallucinated API, because the whole point is that it looks like a real one. The fix is mechanical: let the &lt;strong&gt;type checker and the compiler&lt;/strong&gt; do the reading. A hallucinated method on a typed library fails &lt;code&gt;tsc&lt;/code&gt; instantly. A wrong argument shape fails type-checking. For untyped surfaces — a config key, a CLI flag, an environment variable the model invented — there is no substitute for &lt;strong&gt;actually running the code path&lt;/strong&gt;, not eyeballing it and nodding. If the line never executed in front of you, you don't know the API exists. Treat "I read it and it looked fine" as equivalent to "I didn't check."&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Missing Domain Invariants
&lt;/h2&gt;

&lt;p&gt;This is the most dangerous category and the hardest to automate, because the defect is the absence of something the model could not have known to include.&lt;/p&gt;

&lt;p&gt;Your domain has rules that live nowhere in public training data. Orders are scoped by &lt;code&gt;tenant_id&lt;/code&gt;. Soft-deleted rows must be filtered. A refund can't exceed the captured amount. A user can only see their own organization's records. The model writes a flawless query against your schema and silently omits the invariant, because the invariant exists in your head and your migrations, not in the millions of repositories it learned from.&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;// The model wrote a correct, well-formed query.&lt;/span&gt;
&lt;span class="c1"&gt;// It is also a cross-tenant data leak, because it&lt;/span&gt;
&lt;span class="c1"&gt;// doesn't know this table is multi-tenant.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orders&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;select&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;ordersTable&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;ordersTable&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;paid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// What your domain actually requires:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orders&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;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;ordersTable&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;ordersTable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&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;ordersTable&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;paid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The compiler is no help here — both versions type-check, both run, both return rows. The first one returns the &lt;em&gt;wrong&lt;/em&gt; rows, and in a demo with one tenant it looks identical to the correct one. This is why I review every data-access path the agent writes against a question the model can't answer for itself: &lt;em&gt;what is true about this data that isn't written in the code?&lt;/em&gt; Tenant scoping, ownership checks, status guards, monetary bounds. If your invariants live only in tribal knowledge, the agent will violate them every time, and so will every new hire — which is an argument for &lt;a href="https://iurii.rogulia.fi/blog/ai-agent-codebase-prompts" rel="noopener noreferrer"&gt;writing them into the agent's persistent context&lt;/a&gt; so the prevention layer catches what it can before the code reaches review.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Security Defaults
&lt;/h2&gt;

&lt;p&gt;The model learned from public code, and a lot of public code is insecure. It reproduces the common patterns it saw, and the common pattern is frequently the unsafe one.&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="c1"&gt;# String interpolation straight into SQL — the model has
# seen this thousands of times in tutorials and answers.
&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&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;SELECT * FROM users WHERE email = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;
&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Parameterized — what you actually want.
&lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM users WHERE email = %s&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;email&lt;/span&gt;&lt;span class="p"&gt;,))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The recurring offenders, in order of how often I find them: SQL built by string concatenation instead of parameters; missing input validation on anything that crosses a trust boundary; secrets hardcoded or logged in plaintext; insecure defaults (permissive CORS, &lt;code&gt;verify=False&lt;/code&gt; on TLS, debug mode left on); and IDOR — an endpoint that takes a resource ID from the request and never checks the caller owns it. That last one overlaps with the missing-invariant category, and it's worth flagging twice precisely because it's invisible in a read: the code that fetches &lt;code&gt;order/:id&lt;/code&gt; and returns it looks complete. The authorization check that should be there is, again, an &lt;em&gt;absence&lt;/em&gt;, and absences don't show up when you're reading what's present.&lt;/p&gt;

&lt;p&gt;Run new code mentally against &lt;a href="https://owasp.org/www-project-top-ten/" rel="noopener noreferrer"&gt;the OWASP Top 10&lt;/a&gt; — not as a compliance ritual, but because the model's training-data priors point at exactly those failure modes.&lt;/p&gt;

&lt;p&gt;
  slug="fractional-cto"&lt;br&gt;
  text="Standing up the review discipline for AI-assisted teams — what gets read line by line, what a CI gate rejects automatically, who owns the merge decision — is the kind of structure my fractional CTO engagements put in place early."&lt;br&gt;
/&amp;gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  4. Edge Cases and Error Handling
&lt;/h2&gt;

&lt;p&gt;The model writes the happy path beautifully and stops there. It was trained on code that demonstrates the intended use, not code that survives the inputs nobody intended.&lt;/p&gt;

&lt;p&gt;The specific gaps, every time: empty collections (the &lt;code&gt;[0]&lt;/code&gt; access from my opening, the &lt;code&gt;.reduce&lt;/code&gt; with no initial value on an empty array); &lt;code&gt;null&lt;/code&gt; and &lt;code&gt;undefined&lt;/code&gt; where the model assumed a value; swallowed exceptions; happy-path-only logic with no else; missing timeouts and retries on external calls; numeric boundaries — zero, negative, overflow, floating-point money.&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;// The model writes this and considers the task done.&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;chargeCustomer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Silent. The charge failed, the order is marked paid,&lt;/span&gt;
  &lt;span class="c1"&gt;// and nobody will know until the books don't reconcile.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A swallowed exception is the worst of these because it doesn't just fail to handle the error — it actively hides it, turning a loud, recoverable failure into a silent, expensive one. When I see an empty &lt;code&gt;catch {}&lt;/code&gt; or a &lt;code&gt;catch&lt;/code&gt; that logs and returns &lt;code&gt;null&lt;/code&gt;, I reject it. Errors propagate or they're handled concretely; there is no third option. The model adds these defensively because it has seen a lot of code that does, and almost all of that code was wrong, too.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Tests That Assert Nothing
&lt;/h2&gt;

&lt;p&gt;The agent is good at producing green tests. Green is not the same as meaningful, and a test suite that passes while testing nothing is worse than no suite, because it manufactures false confidence.&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;// This passes. It also tests nothing — the mock returns&lt;/span&gt;
&lt;span class="c1"&gt;// what the assertion checks for. It's a tautology dressed&lt;/span&gt;
&lt;span class="c1"&gt;// as a test.&lt;/span&gt;
&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;returns the user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;repo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;vi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;mockResolvedValue&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Test&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repo&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Test&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;That test verifies that a mock returns what you told the mock to return. The real logic — how &lt;code&gt;getUser&lt;/code&gt; handles a missing user, a repo error, an invalid ID — is untested, and the green checkmark says otherwise. So I review the &lt;em&gt;tests themselves&lt;/em&gt;, not their presence or their pass/fail. The questions: does this test fail if I break the behavior it claims to cover? Does it mock the exact thing it's supposed to verify? Does it assert on the contract that matters, or on an incidental shape? A test you can't make fail by introducing the bug it's named after is decoration. Asking the model to test code it just wrote tends to produce exactly this — it tests the implementation's assumptions back to itself, bugs included.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Codebase Conventions and Consistency
&lt;/h2&gt;

&lt;p&gt;The model writes code that is generically correct and locally foreign. It doesn't know your patterns, so it invents reasonable-looking new ones that quietly fork your codebase.&lt;/p&gt;

&lt;p&gt;The signs: it duplicates a helper that already exists instead of importing it; it introduces a new error-handling style alongside your established one; it logs with &lt;code&gt;console.log&lt;/code&gt; when you have a structured logger; it picks a different naming or file-placement convention than the surrounding code. None of it is &lt;em&gt;wrong&lt;/em&gt; in isolation. All of it is drift, and drift compounds — three sessions downstream you have two ways to do everything and no one decided to.&lt;/p&gt;

&lt;p&gt;This is where the prevention layer earns its keep: most of this is catchable before review by &lt;a href="https://iurii.rogulia.fi/blog/ai-agent-codebase-prompts" rel="noopener noreferrer"&gt;feeding the agent your conventions up front&lt;/a&gt; and by lint rules that reject the divergence deterministically. What review adds on top is the judgment a linter can't encode — "this is technically fine but it's not how we do it here." When I see a reinvented helper, the fix is one line back to the model: check &lt;code&gt;lib/&lt;/code&gt; for an existing one and use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Over-Engineering
&lt;/h2&gt;

&lt;p&gt;The model loves to build for a future you didn't ask about. A factory where a function would do. A configuration object with options nobody will set. An abstraction layer wrapping a single concrete call, justified by "flexibility" and "extensibility" that no requirement demanded.&lt;/p&gt;

&lt;p&gt;This is the inverse failure of the others — not a missing safeguard but a surplus of architecture. It's lower on the list because it's not a correctness bug; it's a maintenance tax. But it's an AI-specific tendency worth naming, because the model has read a lot of "enterprise-grade" code and reaches for its ceremonies by default. The reviewer's job here is subtraction. If an abstraction has exactly one implementation and no concrete second use case on the roadmap, it's speculative — inline it. The cost of a premature abstraction is paid by every person who later has to understand the indirection to change behavior that was never variable to begin with.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Dependencies and Licenses
&lt;/h2&gt;

&lt;p&gt;When the agent reaches for a package, three things need checking, and none of them are visible in the diff that adds the import line.&lt;/p&gt;

&lt;p&gt;Is the package real and the version published — or did the model hallucinate it (back to category one, now in &lt;code&gt;package.json&lt;/code&gt;)? Is it maintained, or an abandoned repo whose last commit was four years ago? Is the license compatible with yours — a GPL dependency pulled into a closed-source product is a legal problem, not a technical one? And what does it drag in transitively — a one-line utility that pulls a hundred packages and three megabytes is rarely worth it when the standard library or six lines of your own would do.&lt;/p&gt;

&lt;p&gt;Adding a dependency is a project-level decision the model makes as a per-task convenience. That asymmetry is exactly why the agent shouldn't add packages without a human deciding the trade-off — preferably enforced by a CI check on &lt;code&gt;package.json&lt;/code&gt; diffs rather than left to catch at review.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Hidden Performance Problems
&lt;/h2&gt;

&lt;p&gt;Last because it's the most context-dependent — what's a problem at scale is invisible at demo size, so this is the category most likely to pass review and surface in production. The model optimizes for "works," not "works at ten thousand rows."&lt;/p&gt;

&lt;p&gt;The classic is the N+1: a loop that runs a query per iteration, correct and fast with three records, a self-inflicted denial of service with three thousand.&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;// Correct output. One query per order. With 5,000 orders,&lt;/span&gt;
&lt;span class="c1"&gt;// 5,000 round-trips to the database.&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;order&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;orders&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;customer&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;customers&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;customers&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;order&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="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;customer&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 query. The model rarely reaches for this on its own.&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;orders&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;o&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;o&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;customers&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;customers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&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;inArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customers&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other recurring offenders: rendering work inside loops that triggers re-renders per item, building a data structure with the wrong access pattern (a linear scan where a &lt;code&gt;Map&lt;/code&gt; lookup belonged), loading a whole table to count it. Catching these requires asking a question the model doesn't ask itself — &lt;em&gt;what happens to this when the input is a thousand times bigger?&lt;/em&gt; — and that question is a senior reflex, not a thing you read off the page. The code is correct. It just doesn't scale, and correctness is the only thing its surface advertises.&lt;/p&gt;

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

&lt;p&gt;Three, because a checklist sold as a guarantee is a lie.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It doesn't catch everything.&lt;/strong&gt; Review is a filter, not a proof. Race conditions, defects that only appear under concurrent load, the bug that needs production data volume to manifest — these survive any read-through and surface in production regardless of how disciplined the review was. The checklist lowers the rate and the cost of escaped defects. It doesn't drive them to zero, and anyone who tells you their review process does is selling something.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reviewing this code can cost more than writing it.&lt;/strong&gt; This is the uncomfortable one. When I already hold the full solution in my head, checking the model's version of it line by line — hunting the one subtle deviation from what I intended — can take longer than just typing my own. On those tasks the delegation is a net loss even though generation felt instant. That cost is precisely &lt;em&gt;why&lt;/em&gt; the checklist matters: if review is the expensive part, it has to be done well, and done well means systematic rather than vibes. Cheap review on cheap-to-verify tasks; expensive, structured review on expensive-to-verify ones. The checklist is for the second kind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is a 2026 snapshot.&lt;/strong&gt; Every item here describes a current-model tendency, and the models move. Hallucinated APIs are already less frequent than a year ago. Some categories will shrink; the root ones — missing domain invariants, security priors, the absence of consequence-modeling — depend on things a public-data interpolation engine structurally can't have, so I expect them to age slowest. Re-run the list as the tools change; the priorities will drift, the top of the list less than the bottom. And to be clear: good AI code exists. Most of what the agent writes for me on well-trodden tasks is fine, and paranoia applied uniformly is just slow. The whole point of ordering by cost of error is to &lt;em&gt;not&lt;/em&gt; review everything as if it were a billing query.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Copy-Paste Checklist
&lt;/h2&gt;

&lt;p&gt;Priority-ordered, by cost of error. Attach it to your PR template for AI-generated changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## AI Code Review Checklist&lt;/span&gt;

Read order: top items first — they're ordered by cost of error, not frequency.
"It reads well" is not evidence. The model is fluent; fluency is the failure mode.
&lt;span class="p"&gt;
1.&lt;/span&gt;  [ ] Hallucinated APIs — does it type-check AND actually run?
        (Don't trust untyped configs/flags/env vars by reading.)
&lt;span class="p"&gt;2.&lt;/span&gt;  [ ] Domain invariants present — tenant scoping, ownership checks,
        status guards, monetary bounds. What's true about this data
        that isn't written in the code?
&lt;span class="p"&gt;3.&lt;/span&gt;  [ ] Security — parameterized queries (no string-built SQL),
        input validated at trust boundaries, no secrets in code/logs,
        no IDOR (does it check the caller owns the resource?).
&lt;span class="p"&gt;4.&lt;/span&gt;  [ ] Edge cases — empty collections, null/undefined, no swallowed
        catch {}, timeouts + retries on external calls, numeric bounds.
&lt;span class="p"&gt;5.&lt;/span&gt;  [ ] Tests assert behavior — does each test FAIL if I break the
        thing it names? Not testing mocks back to themselves?
&lt;span class="p"&gt;6.&lt;/span&gt;  [ ] Conventions — uses existing helpers, your error layer, your
        logger, your naming. No reinvented patterns.
&lt;span class="p"&gt;7.&lt;/span&gt;  [ ] No over-engineering — abstractions with one implementation
        and no concrete second use case get inlined.
&lt;span class="p"&gt;8.&lt;/span&gt;  [ ] Dependencies — package real, maintained, license-compatible,
        not dragging heavy transitives. Human approved the add.
&lt;span class="p"&gt;9.&lt;/span&gt;  [ ] Performance — no per-iteration DB queries (N+1), right data
        structure, no loading a table to count it. Scales past demo size.

If review costs more than rewriting would have: rewrite.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;AI code fails where it reads best.&lt;/strong&gt; Fluency is what the model optimizes, so "it looks clean" is evidence of nothing. A review tuned for human code — slow down at the messy parts — sails past AI defects because there are no messy parts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order by cost of error, not frequency.&lt;/strong&gt; Attention is the scarce resource. A missing tenant filter and a cosmetic naming miss are not the same risk; review them differently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hallucinated APIs are caught by running, not reading.&lt;/strong&gt; The type checker and an actual execution path catch what no careful read can, because the whole defect is that it looks real.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing invariants are the dangerous class.&lt;/strong&gt; The model can't know your domain rules, so it omits them silently and the compiler won't complain. Review every data path for what's true but unwritten.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The checklist is a filter, not a proof — and that's exactly why it's needed.&lt;/strong&gt; Review never catches everything, and on some tasks it costs more than rewriting. When review is the expensive part, do it systematically.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>aicoding</category>
      <category>codereview</category>
      <category>engineering</category>
      <category>webdev</category>
    </item>
    <item>
      <title>PDF Integrity Report: June 2026</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Thu, 16 Jul 2026 10:00:50 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/pdf-integrity-report-june-2026-10if</link>
      <guid>https://dev.to/iurii_rogulia/pdf-integrity-report-june-2026-10if</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/pdf-integrity-report-june-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;h2&gt;
  
  
  The Shape of the Verdicts
&lt;/h2&gt;

&lt;p&gt;Last month the flagged share climbed past seven in ten and, for the first time, "certain" verdicts overtook "high-confidence" ones. In June both of those moves reversed. The flagged share &lt;strong&gt;fell back to just under half&lt;/strong&gt; — near where it sat in March — and &lt;strong&gt;"high-confidence" reclaimed the lead&lt;/strong&gt; inside the flagged set.&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. May&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;▲ back to just over half&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High-confidence modification&lt;/td&gt;
&lt;td&gt;▲ retook the largest flagged bucket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Certain modification&lt;/td&gt;
&lt;td&gt;▼ slipped back below "high"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The reversal is almost entirely a &lt;strong&gt;traffic-mix effect, and it is the cleanest illustration we have yet published of why the flagged share is not a fraud rate.&lt;/strong&gt; In May the traffic leaned hard toward the API, where callers skew toward files that are &lt;em&gt;already&lt;/em&gt; modified — developers testing an integration with known-bad documents, and forger-bridge uploads probing whether a fake gets caught. That population stacks converging evidence, which lands in the "certain" tier and pushes the flagged share up.&lt;/p&gt;

&lt;p&gt;In June the mix flipped: &lt;strong&gt;roughly four in five submissions came through the browser-based free checker rather than the API.&lt;/strong&gt; Web traffic is a broader, messier population — curious first-time users, ordinary documents, genuine intake. A larger slice of it comes back clean, and the files that &lt;em&gt;do&lt;/em&gt; flag tend to trip one strong signal rather than a stack of them, so they land in "high-confidence" rather than "certain."&lt;/p&gt;

&lt;p&gt;Same engine, same thresholds, a very different headline number — driven by &lt;em&gt;who showed up&lt;/em&gt;, not by any change in how documents are made. Read the flagged share every month as a statement about the submitting population, never as a population-wide fraud rate.&lt;/p&gt;




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

&lt;p&gt;Under the shifting headline number, the evidence mix among flagged documents kept moving in the direction we have tracked all year: the &lt;strong&gt;classical first-order tells stayed common, while newer second-order checks kept widening what gets caught.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Up, or newly firing:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Generator-identity forgery&lt;/strong&gt; — a document whose stated origin has been rewritten to disguise where it actually came from. A dedicated check shipped early in June and a late-June broadening extended it to files rebuilt after issue but still dressed up as untouched originals. It fires whenever the declared producer and the structural fingerprint tell two different stories.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overlay and cover-and-replace edits&lt;/strong&gt; — substitute values layered on top of, or concealing, otherwise-untouched original content to change what a page reads. New detection classes this month moved a long-standing hard case from "occasionally caught" toward routinely caught.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edited-in-an-editor&lt;/strong&gt; — a document reopened in an interactive PDF editor after creation, a value changed, and saved back. New coverage this month recognises that round-trip independently of which editing application did it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Flat or down in share:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Date-field inconsistencies&lt;/strong&gt; — still the most common single finding, but no longer growing; the easy timestamp tells are increasingly being cleaned before submission.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing creation date&lt;/strong&gt; — eased back to roughly a fifth of all files, down from the near-quarter it reached in May. Still elevated, still worth watching, but the monotonic climb paused.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Post-signature modification&lt;/strong&gt; — down in share, mostly because signed documents were again a thin slice of the month.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The year-long pattern held: a forger who has learned to scrub creation dates and avoid an incremental-update trail does not necessarily know to reconcile the structural fingerprint of the tool they rebuilt the file with against the origin they claim. The second-order checks are where those cases surface.&lt;/p&gt;




&lt;h2&gt;
  
  
  Incremental Updates: Still Almost Every Time
&lt;/h2&gt;

&lt;p&gt;The cleanest signal we track stayed the cleanest. Files carrying incremental updates were flagged in the &lt;strong&gt;vast majority of cases — roughly seven in eight&lt;/strong&gt; — easing only slightly from May's near-total rate. The average revision chain on those files sat around three appends.&lt;/p&gt;

&lt;p&gt;The mechanism is unchanged: incremental updates let content be appended after the original write. Legitimate workflows produce them — signature application, annotation, form-fill — but on the population reaching the tool, those clean cases remain a small minority. When an incremental update shows up on a document submitted for tamper detection, it is still very close to synonymous with post-creation editing.&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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The editor round-trip (verdict: certain).&lt;/strong&gt; A "bank statement" looks clean to the eye. Structurally it was opened in an interactive PDF editor after its original creation, a figure was changed, and it was saved back — leaving the fingerprint of an editing pass over what claims to be an untouched issuer original. Edited-in-an-editor: the newer coverage this month recognises that round-trip no matter which editor did it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The overlay patch (verdict: high → certain).&lt;/strong&gt; A payslip reads correctly, cell by cell. Structurally, substitute values were layered on top of the original page content — the underlying figures are still there, quietly covered by the numbers the forger wanted shown. Overlay and cover-and-replace detection targets exactly this: the page you see is not the page underneath.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The borrowed generator identity (verdict: certain).&lt;/strong&gt; A document declares an institutional producer in its origin fields, but the binary structure carries the fingerprint of a consumer tool that rebuilt it. The stated origin has been rewritten to disguise where the file actually came from. Generator-identity forgery — the check that treats "claims one origin, structurally is another" as a flag in its own right.&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 camera or scanner capture — one full-page image, no born-digital text. Structurally it was digitally rendered into that single image and then presented as a scan, a way a fabricated file is made to look like an innocent photograph of a paper original. A new June check separates that synthetic render from a genuine scan, including ordinary phone captures, which continue to pass into the not-certifiable ceiling rather than being flagged.&lt;/p&gt;




&lt;h2&gt;
  
  
  Document Origin
&lt;/h2&gt;

&lt;p&gt;The origin mix partly unwound May's shift. &lt;strong&gt;Scanned documents fell back to roughly an eighth of submissions&lt;/strong&gt;, slipping below consumer-software exports again, while institutional documents remained the plurality at a little over four in ten.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Origin classification&lt;/th&gt;
&lt;th&gt;Direction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Institutional (server-side / enterprise generators)&lt;/td&gt;
&lt;td&gt;plurality, ~four in ten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consumer software ("Cannot Verify")&lt;/td&gt;
&lt;td&gt;▲ back above scanned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scanned ("Cannot Verify")&lt;/td&gt;
&lt;td&gt;▼ eased to ~an eighth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Online editor / unknown / other&lt;/td&gt;
&lt;td&gt;small shares&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Scans and consumer-software exports fall into a "Cannot Verify" bucket where the structural layer deliberately returns a conservative inconclusive verdict rather than an intact-or-modified call — forcing a binary verdict on those formats would generate false positives in both directions. Several of June's releases sharpened that boundary in both directions: extending scanned-document recognition to more multifunction copier and companion-app output that had been misread as born-digital, while sparing genuine machine-issued bills that carry lightweight postal-mailing marks from being mistaken for a full-page scan. A scan can still never earn an "intact" verdict here — re-scanning a tampered printout is a known way to launder edits out of the structural record.&lt;/p&gt;




&lt;h2&gt;
  
  
  Digital Signatures
&lt;/h2&gt;

&lt;p&gt;Signed documents were again a thin slice of the month — too small a base to quote a meaningful rate, so we keep it qualitative. The pattern that did appear is the one we report every month: 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. Checking integrity at the structural layer, not the signature-validation layer, is what catches that.&lt;/p&gt;




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

&lt;p&gt;June shipped &lt;strong&gt;sixteen versions&lt;/strong&gt; — a steadier month than May's twenty-nine, and weighted toward broadening detection rather than the release-a-day pace of the previous month. The work split the usual three ways, with two firsts worth calling out.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New detection categories&lt;/strong&gt; — generator-identity forgery, overlay and cover-and-replace edits, edited-in-an-editor round-trips, a synthetic-render-dressed-as-a-scan check, files rebuilt inside a graphics-design tool from a source held on the operator's own machine, and dangling internal references left behind by a rebuild. Several of these closed cases that had previously slipped through certified as originals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The first deliberately-undisclosed signal.&lt;/strong&gt; For the first time the catalogue carries a proprietary integrity check whose &lt;em&gt;mechanism we do not describe&lt;/em&gt; — we acknowledge it exists and that it is strong, corroborated evidence when it fires, but we hold back how it works so a forger cannot read the description and engineer around it. Every other check stays described in plain outcome terms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A retirement.&lt;/strong&gt; We removed a standalone document-identifier consistency check: reviewed against a large corpus of genuine files, those identifier records were found to differ legitimately on a single clean render across many established generators, so on their own they produced false positives while adding nothing the structural checks did not already cover. Every modification it could ever evidence remains caught independently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;False-positive reductions&lt;/strong&gt; — roughly half the releases narrowed heuristics misfiring on legitimate document classes: genuine scanner hardware output, single-pass institutional renders, print-rendered layouts, table-layout documents, machine-composed financial and retirement statements, and government forms with long author-to-issue gaps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Wider coverage cuts against the falling flagged share, not with it: a share of the documents flagged in June would have passed under the early-June algorithm. The headline number fell anyway — which is exactly why the traffic-mix framing above matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Software Ecosystem
&lt;/h2&gt;

&lt;p&gt;The recurring fingerprints held. &lt;strong&gt;Online manipulation services as intermediate steps&lt;/strong&gt; — a service in the producer field with a different application in the creator field, the signature of a compress / merge / page-extract step between creation and submission. &lt;strong&gt;Design-tool origin&lt;/strong&gt; — vector- and consumer-design applications appearing where a system-generated producer belongs, on documents that purport to be business records; June added a specific check for files rebuilt inside a graphics tool from a locally-held source, a construction pattern no institution uses to issue its own statements. &lt;strong&gt;Programmatic manipulation libraries&lt;/strong&gt; — where the signal is no longer the spoofable producer string but the structural fingerprint the library leaves at the binary level, which is where the generator-identity-forgery work is aimed.&lt;/p&gt;




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

&lt;p&gt;Concentration loosened slightly. &lt;strong&gt;PDF 1.7 slipped just under half the sample&lt;/strong&gt;, down from over half in May, with 1.4 taking a larger second share and 1.3, 1.6 and 1.5 splitting most of the rest. PDF 2.0, despite nearly a decade of availability, stayed a rounding-error share.&lt;/p&gt;




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

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

&lt;ul&gt;
&lt;li&gt;The flagged share fell back to just under half — and the reversal is the clearest example yet that this number tracks &lt;em&gt;who submitted documents&lt;/em&gt; (a web-dominated month), not a population fraud rate.&lt;/li&gt;
&lt;li&gt;"High-confidence" verdicts reclaimed the lead from "certain" as the traffic mix flipped from API-heavy to browser-heavy.&lt;/li&gt;
&lt;li&gt;Incremental-update files were still flagged in the vast majority of cases — roughly seven in eight, the cleanest single signal we track.&lt;/li&gt;
&lt;li&gt;Newer second-order checks — generator-identity forgery, overlay and cover-and-replace edits, edited-in-an-editor — kept widening coverage against the classical date and incremental-update tells.&lt;/li&gt;
&lt;li&gt;Scanned share eased back to roughly an eighth; missing creation dates paused their climb at about a fifth.&lt;/li&gt;
&lt;li&gt;Sixteen algorithm versions shipped, including the first deliberately-undisclosed proprietary signal and the retirement of an unreliable identifier check.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every pattern here comes from the same forensic engine 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;&lt;em&gt;This report covers checks processed by HTPBE in June 2026. File contents are not stored or analyzed; only structural metadata signals are retained. All figures are aggregate and anonymized.&lt;/em&gt;&lt;/p&gt;

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