<?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>Building a Verified n8n Node That Is Exposed as an AI Agent Tool</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Wed, 04 Nov 2026 11:01:12 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/building-a-verified-n8n-node-that-is-exposed-as-an-ai-agent-tool-3g53</link>
      <guid>https://dev.to/iurii_rogulia/building-a-verified-n8n-node-that-is-exposed-as-an-ai-agent-tool-3g53</guid>
      <description>&lt;p&gt;I published &lt;a href="https://iurii.rogulia.fi/projects/n8n-vat-validation-node" rel="noopener noreferrer"&gt;&lt;code&gt;n8n-nodes-vatnode&lt;/code&gt;&lt;/a&gt;, a verified n8n community node for EU VAT validation. One line in the node class, &lt;code&gt;usableAsTool: true&lt;/code&gt;, also makes n8n generate an agent-facing Tool variant of it. That line is easy to add and easy to under-think, because it changes who reads your code. In a normal node the reader is a person looking at a canvas. In the tool variant, the reader of your descriptions is a model.&lt;/p&gt;

&lt;p&gt;This article is about two things: what that flag does, as far as the types and n8n’s loader code show, and what the path to a verified node looks like when you build for it from the start. One limit up front: I have not run this node inside an agent or evaluated what an agent does with it. This covers how the tool variant is built, not agent behaviour.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Flag Does
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;usableAsTool&lt;/code&gt; is not described on the docs pages I checked (node base files, file structure, verification guidelines, submission). The evidence is elsewhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;n8n-workflow&lt;/code&gt; types declare &lt;code&gt;usableAsTool?: true | UsableAsToolDescription&lt;/code&gt;, commented as ‘whether the node will be wrapped for tool-use by AI Agents’.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;@n8n/node-cli&lt;/code&gt; template guide says to set it to &lt;code&gt;true&lt;/code&gt; to allow use as an agent tool, and to leave it off if the node works heavily with binary data, which tools don’t support.&lt;/li&gt;
&lt;li&gt;In n8n’s source, &lt;code&gt;createAiTools&lt;/code&gt; (&lt;code&gt;packages/cli/src/tool-generation/ai-tools.ts&lt;/code&gt;) takes every node with a truthy &lt;code&gt;usableAsTool&lt;/code&gt; and builds a copy: &lt;code&gt;Tool&lt;/code&gt; appended to the name, inputs cleared, the output set to &lt;code&gt;AiTool&lt;/code&gt;. It injects a required &lt;code&gt;toolDescription&lt;/code&gt; string parameter that defaults to the node’s description. If the node has a resource or operation property, it also adds a &lt;code&gt;descriptionType&lt;/code&gt; option, automatic or manual. Credentials are copied across.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code I read has no condition on whether the node is built in or a community package. I have not checked every code path that could add one, and I did not verify that the Tool variant is available on a given instance; that may depend on its configuration.&lt;/p&gt;

&lt;p&gt;The text a model sees comes from strings you wrote for a human dropdown: the node &lt;code&gt;description&lt;/code&gt;, each operation’s &lt;code&gt;description&lt;/code&gt; and &lt;code&gt;action&lt;/code&gt;, each parameter’s &lt;code&gt;description&lt;/code&gt;. Those strings are now your tool contract. I haven’t verified how the automatic description type composes them, so I treat all of them as if they will be read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing Operations for a Reader That Can Spend Money
&lt;/h2&gt;

&lt;p&gt;The node has two resources and four operations. Two of them are the interesting pair, because they answer the same question at different prices:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Validate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;validate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Verify a VAT number against the official EU VIES register. Requires an API key and spends one request from your quota.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Validate a VAT number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="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="s1"&gt;Check Format&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checkFormat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Check a VAT number against its country pattern. Runs offline: no API key, no network call, no quota.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Check the format of a VAT number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cost is written into the description, in plain words, on each operation. A person scanning the dropdown benefits. So does anything choosing between the two from text alone. I did the same split in the &lt;a href="https://iurii.rogulia.fi/blog/mcp-server-vat-validation" rel="noopener noreferrer"&gt;MCP server for the same API&lt;/a&gt;, for the same reason: a caller who only needs a cheap answer shouldn’t be pushed toward the expensive one.&lt;/p&gt;

&lt;p&gt;This is a mitigation, not a guarantee. A tool variant of a quota-spending operation exposes a quota-spending operation to an agent. What I did is state the cost where the model reads it, and make the free path complete on its own: the credential is only required for &lt;code&gt;validate&lt;/code&gt;, so Check Format and both rate operations work with no key attached.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vatnodeApi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;displayOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;show&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;validate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="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 node-level &lt;code&gt;description&lt;/code&gt; is &lt;code&gt;Validate EU VAT numbers against VIES and look up EU VAT rates&lt;/code&gt;, which says nothing about cost. The default &lt;code&gt;toolDescription&lt;/code&gt; is that string. If you ship a spending operation, check what your default tool description actually says, and consider whether it should say more. I have not tested which of these strings a model weighs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Errors a Reader Can Act On
&lt;/h2&gt;

&lt;p&gt;A tool call that fails with a stack trace gives a model nothing to correct. Two small decisions apply to people and models alike.&lt;/p&gt;

&lt;p&gt;First, parameters typed as strings are not always strings. An expression like &lt;code&gt;{{ $json.vatId }}&lt;/code&gt; resolves to &lt;code&gt;undefined&lt;/code&gt; on an item without that field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toStringParam&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;value&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="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&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;In Validate, the empty string then hits an explicit check and throws &lt;code&gt;The VAT number is empty&lt;/code&gt;, which is a message the caller can act on, instead of a &lt;code&gt;TypeError&lt;/code&gt; from &lt;code&gt;.toUpperCase()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Second, input errors and upstream errors are different types, and the loop isolates failures per item:&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="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;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="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;continueOnFail&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;returnData&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="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;pairedItem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;item&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;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// Input problems are ours, not the API's – keep them out of NodeApiError.&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;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;NodeOperationError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NodeOperationError&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;getNode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;itemIndex&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NodeApiError&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;getNode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;JsonObject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;itemIndex&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pairedItem&lt;/code&gt; keeps the link from each output to its input row. The &lt;code&gt;NodeOperationError&lt;/code&gt; versus &lt;code&gt;NodeApiError&lt;/code&gt; split tells the user whether their input or the upstream failed.&lt;/p&gt;

&lt;p&gt;One more cost detail, in the credential. n8n runs the credential test when you save or test a credential, so a test that validates a real number spends quota each time. The test sends the reserved number &lt;code&gt;XX0000001&lt;/code&gt; instead:&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;// XX0000001 is the reserved test VAT number: it never reaches VIES and never&lt;/span&gt;
&lt;span class="c1"&gt;// spends quota, so the test only proves that the key is accepted.&lt;/span&gt;
&lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ICredentialTestRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.vatnode.dev&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/v1/vat/XX0000001&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;responseCode&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The vatnode API key is invalid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;responseCode&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;This vatnode API key is not allowed to validate VAT numbers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Verification Path
&lt;/h2&gt;

&lt;p&gt;Verification is a review by n8n, and what it checks is public in the &lt;a href="https://docs.n8n.io/integrations/creating-nodes/deploy/submit-community-nodes/" rel="noopener noreferrer"&gt;submission&lt;/a&gt; and &lt;a href="https://docs.n8n.io/integrations/creating-nodes/build/reference/verification-guidelines/" rel="noopener noreferrer"&gt;verification guidelines&lt;/a&gt; pages. The ones that shaped this build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No runtime dependencies.&lt;/strong&gt; The format check needs a pattern per country. Hardcoding a pattern for each of 45 countries (plus an EL alias for Greece) creates a second source of truth, and a dependency is not allowed. So &lt;code&gt;eu-vat-rates-data&lt;/code&gt; is a &lt;code&gt;devDependency&lt;/code&gt;, and &lt;code&gt;scripts/generate-patterns.mjs&lt;/code&gt; writes the patterns into &lt;code&gt;countries.generated.ts&lt;/code&gt; at build time. The published package carries the patterns and nothing to install. The only other dependency, &lt;code&gt;n8n-workflow&lt;/code&gt;, is a peer dependency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One service per package, MIT license, matching repo and author.&lt;/strong&gt; The npm repository URL has to point at the public GitHub repo, and the author has to match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scaffolded with &lt;code&gt;n8n-node&lt;/code&gt;.&lt;/strong&gt; Strongly suggested rather than required. The scaffold gives you the lint rules, the release script and the &lt;code&gt;n8n&lt;/code&gt; block in &lt;code&gt;package.json&lt;/code&gt;, including &lt;code&gt;"strict": true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provenance from GitHub Actions.&lt;/strong&gt; From 1 May 2026, n8n does not accept verified nodes published from a local machine. The publish workflow runs on a version tag, with the permissions it needs and no more:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.*.*"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v*.*.*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;publish&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workflow publishes from GitHub Actions with npm provenance. The first release used a granular npm token; from the second release the package has been published through npm Trusted Publishing. &lt;code&gt;publishConfig.provenance&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;. Releases need &lt;code&gt;@n8n/node-cli&lt;/code&gt; 0.23.0 or later.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A README with usage, authentication details and example workflows.&lt;/strong&gt; The repo ships three importable templates: validating a number from a form, deciding reverse charge before invoicing, and re-checking customer numbers on a schedule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Submission through the n8n Creator Portal.&lt;/strong&gt; The review applies to the version you submit.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limits and Trade-offs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Verification is a review, and review can reject. n8n’s page says it may reject nodes that compete with its paid features. Build the checklist in, but don’t plan a launch around the outcome.&lt;/li&gt;
&lt;li&gt;The no-dependencies rule is a real constraint. Anything you’d normally import has to be generated, inlined or written. For patterns, generation works. For something like a parsing library it may not.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;usableAsTool&lt;/code&gt; puts your node in front of a caller you can’t see. For a read-only free operation that’s harmless. For a spending or destructive one, the description is the only guard in this design. I haven’t evaluated whether it’s enough.&lt;/li&gt;
&lt;li&gt;The flag is wrong for nodes that work heavily with binary data, per the template guide.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to Check Before You Submit
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Package name is &lt;code&gt;n8n-nodes-*&lt;/code&gt; or &lt;code&gt;@scope/n8n-nodes-*&lt;/code&gt;, with the &lt;code&gt;n8n-community-node-package&lt;/code&gt; keyword.&lt;/li&gt;
&lt;li&gt;Nodes and credentials are listed in the &lt;code&gt;n8n&lt;/code&gt; attribute of &lt;code&gt;package.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;npm run lint&lt;/code&gt; passes, and &lt;code&gt;npx @n8n/scan-community-package n8n-nodes-yourpackage&lt;/code&gt; is clean.&lt;/li&gt;
&lt;li&gt;No runtime dependencies. Generate at build time what you need.&lt;/li&gt;
&lt;li&gt;MIT license; repository URL and author match the public GitHub repo.&lt;/li&gt;
&lt;li&gt;Published by a GitHub Actions workflow with provenance, not from your laptop.&lt;/li&gt;
&lt;li&gt;Every operation and parameter has a &lt;code&gt;description&lt;/code&gt; written for a reader who can’t ask a follow-up, and operations that cost something say so.&lt;/li&gt;
&lt;li&gt;Empty and missing inputs produce readable errors; &lt;code&gt;continueOnFail&lt;/code&gt; and &lt;code&gt;pairedItem&lt;/code&gt; are handled.&lt;/li&gt;
&lt;li&gt;The credential test does not spend anything.&lt;/li&gt;
&lt;li&gt;The README has usage, auth and example workflows.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;If you’re building a node for your own API, the points above are the checklist I’d want. If you only need your tool or API to appear as a working step in n8n, you don’t need to learn any of this.&lt;/p&gt;




&lt;p&gt;If you have an API that should be a step in n8n, or exposed to an AI agent as a tool with its cost stated plainly, &lt;a href="https://iurii.rogulia.fi/contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt; – that is &lt;a href="https://iurii.rogulia.fi/services/api-integrations" rel="noopener noreferrer"&gt;API integration&lt;/a&gt; work.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://iurii.rogulia.fi/projects/n8n-vat-validation-node" rel="noopener noreferrer"&gt;n8n VAT validation node – project page&lt;/a&gt; – what the node does and how it was released&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://iurii.rogulia.fi/blog/mcp-server-vat-validation" rel="noopener noreferrer"&gt;Building an MCP Server for VAT Validation: Why It’s Two Tools, Not One&lt;/a&gt; – the same free-versus-spending split in a different agent interface&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://iurii.rogulia.fi/blog/build-time-vat-fx-rates" rel="noopener noreferrer"&gt;Caching VAT and FX Rates at Build Time, Not on the Request Path&lt;/a&gt; – the build-time data idea in another setting&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.n8n.io/integrations/creating-nodes/deploy/submit-community-nodes/" rel="noopener noreferrer"&gt;n8n: submit community nodes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.n8n.io/integrations/creating-nodes/build/reference/verification-guidelines/" rel="noopener noreferrer"&gt;n8n: verification guidelines&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>n8n</category>
      <category>typescript</category>
      <category>node</category>
      <category>n8nworkflow</category>
    </item>
    <item>
      <title>Document Forgery Detection Methods Compared</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Wed, 04 Nov 2026 11:00:52 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/document-forgery-detection-methods-compared-4n9a</link>
      <guid>https://dev.to/iurii_rogulia/document-forgery-detection-methods-compared-4n9a</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/document-forgery-detection-methods-structural-vs-content" 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 lender receives a payslip as part of a loan application. The PDF came from a real payroll system at a real employer. Before uploading it, the applicant opened it in a PDF editor, raised the gross pay, adjusted the tax and net lines so they still add up, and saved the file again.&lt;/p&gt;

&lt;p&gt;On screen the payslip looks exactly like the one the payroll system produced. The extraction step reads every figure cleanly. The arithmetic checks pass, because the applicant did the arithmetic. Nobody calls the employer, because the application looks ordinary. The only place that might record what happened is the file itself: it typically now carries evidence of being modified after the payroll system created it.&lt;/p&gt;

&lt;p&gt;That scenario is a composite, not a specific case. It shows why document forgery detection is not one method: different methods answer different questions, and the edited payslip passes every check that asks the wrong one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Document forgery detection: the four method classes
&lt;/h2&gt;

&lt;p&gt;Document forgery detection splits into four classes of method. Each asks a different question:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Manual review&lt;/strong&gt; – does a trained person, looking at the document, believe it?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content-based checks&lt;/strong&gt; – is what the document says consistent with itself and with other data sources?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity and issuer checks&lt;/strong&gt; – is this the right person, and did the stated issuer actually issue this document?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structural PDF forensics&lt;/strong&gt; – was the file changed after it was created?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Content checks judge what the document says; structural checks judge the file’s history. Each misses what the other sees. Manual review and issuer confirmation sit on either side of them: one is human judgement on the surface, the other is the only route to the issuer’s own answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Manual review: what a human sees and misses
&lt;/h2&gt;

&lt;p&gt;A reviewer reads the document the way its recipient is meant to: logo, layout, names, dates, totals. Good reviewers catch a lot: an employer that doesn’t exist, a salary that doesn’t fit the job title, a story that doesn’t hang together across the application. Human judgement is also where context lives.&lt;/p&gt;

&lt;p&gt;What a reviewer cannot see is the file underneath the page. A PDF that was edited and re-saved can render exactly like the original, so the edit is invisible to anyone looking at it. Manual review also does not scale: every document costs the same human time, and attention drops on the hundredth file of the day. We compare the two approaches directly on &lt;a href="https://htpbe.tech/pdf-tamper-detection-vs-manual-review" rel="noopener noreferrer"&gt;PDF tamper detection vs manual review&lt;/a&gt;, and we break down &lt;a href="https://htpbe.tech/blog/htpbe-vs-idenfy-vs-manual-document-verification" rel="noopener noreferrer"&gt;the cost side of manual review&lt;/a&gt; separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Content-based checks: what they validate and what slips through
&lt;/h2&gt;

&lt;p&gt;Content-based methods automate the question ‘is what this document says believable?’. The class covers several techniques:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Extraction and OCR&lt;/strong&gt; – pulling text and figures out of the document so they can be checked by software rather than by eye.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal consistency&lt;/strong&gt; – does the arithmetic work, do the dates line up, do the totals match the line items, do the account numbers have the right format?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visual inspection&lt;/strong&gt; – font, alignment and pixel-level analysis, including AI models trained to spot visual manipulation in a page image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data cross-checks&lt;/strong&gt; – comparing the extracted values against an outside source such as a bank feed, a payroll provider or a company registry.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These methods catch things structure never can. A document with inconsistent figures fails an arithmetic check whatever its file history. A visual model can flag a pasted-in block that doesn’t match the surrounding page. And a data cross-check is the only automated method on this list that tests whether the content is &lt;em&gt;true&lt;/em&gt;: if the payroll provider reports a different salary, the payslip is wrong regardless of how it was made.&lt;/p&gt;

&lt;p&gt;The limit is that content checks evaluate the result of an edit, not the act of editing. A careful edit to a real document can leave content that is internally coherent and visually clean. Unless a cross-check against an independent source is available for that exact figure, the edited payslip from the opening passes. Cross-checks also depend on access: not every applicant connects a bank account, and not every employer, landlord or supplier is reachable through a feed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identity and issuer checks
&lt;/h2&gt;

&lt;p&gt;Identity checks (KYC) confirm that the person in front of you is who they say they are – a government ID, a selfie, a liveness check. They answer a question about the person, not about the payslip or bank statement that person uploads. A real applicant with a valid passport can still submit an edited document. We go through that gap in detail in &lt;a href="https://htpbe.tech/blog/kyc-vs-document-forensics-pillar" rel="noopener noreferrer"&gt;KYC vs document forensics&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Issuer confirmation – calling the employer, the bank or the registrar – is the strongest evidence that a document was actually issued with the values it shows. It is also slow, manual and often impossible at volume, so most teams reserve it for files something else has already flagged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Structural PDF forensics
&lt;/h2&gt;

&lt;p&gt;Structural forensics does not interpret what the page says; it examines how the file was built and changed. A PDF is a structured container with its own records, not a picture of a page. Editors can append revisions to it, it carries metadata naming the software that produced it and when, digital signatures cover a defined part of it, and pages can be assembled from more than one source. When a file is opened and saved again, those records usually change, and that change is what structural forensics looks for.&lt;/p&gt;

&lt;p&gt;HTPBE works only at this layer. It does not extract or interpret the figures, run OCR, check arithmetic, compare values with outside data or judge what the page looks like. It answers one question – was this PDF modified after it was created – and reports outcomes such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;‘Content changed after the document was digitally signed’&lt;/li&gt;
&lt;li&gt;‘Document re-saved by another tool after its original creation’&lt;/li&gt;
&lt;li&gt;‘Evidence that a digital signature was stripped after signing’&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are labels from the public list on &lt;a href="https://htpbe.tech/how" rel="noopener noreferrer"&gt;how HTPBE works&lt;/a&gt;, which as of October 2026 holds 63 checks across 8 categories. The result is a verdict – &lt;code&gt;intact&lt;/code&gt;, &lt;code&gt;modified&lt;/code&gt; or &lt;code&gt;inconclusive&lt;/code&gt; – plus the named markers that support it. There is no numeric risk score.&lt;/p&gt;

&lt;p&gt;Each verdict has a narrow meaning. &lt;code&gt;modified&lt;/code&gt; means the file shows evidence of being edited after creation; it is not a finding of fraud, and it should route the file to a person rather than trigger an automatic rejection. &lt;code&gt;inconclusive&lt;/code&gt; means the file offers limited structural evidence either way, typically because it came from consumer software, a scanner or a print-to-PDF step – scans and print-to-PDF output never return &lt;code&gt;intact&lt;/code&gt;. &lt;a href="https://htpbe.tech/blog/what-inconclusive-means-pdf-verification-results" rel="noopener noreferrer"&gt;What inconclusive means&lt;/a&gt; covers how to handle that outcome. &lt;code&gt;intact&lt;/code&gt; means no structural edit evidence was found.&lt;/p&gt;

&lt;p&gt;This is document tamper detection in the narrow sense, and it is one part of PDF fraud detection, not all of it. &lt;a href="https://htpbe.tech/blog/anatomy-of-modified-pdf-algorithm-detects" rel="noopener noreferrer"&gt;The anatomy of a modified PDF&lt;/a&gt; walks through what changes inside an edited file, &lt;a href="https://htpbe.tech/blog/binary-pdf-modification-detection-technical-approach" rel="noopener noreferrer"&gt;binary PDF modification detection&lt;/a&gt; goes further into the format, and the product overview is on &lt;a href="https://htpbe.tech/pdf-tamper-detection" rel="noopener noreferrer"&gt;PDF tamper detection&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side-by-side comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;What it inspects&lt;/th&gt;
&lt;th&gt;Evidence it produces&lt;/th&gt;
&lt;th&gt;Scales?&lt;/th&gt;
&lt;th&gt;Blind spot&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Manual review&lt;/td&gt;
&lt;td&gt;The rendered page and the surrounding application&lt;/td&gt;
&lt;td&gt;A reviewer’s judgement&lt;/td&gt;
&lt;td&gt;Poorly – time per file is fixed&lt;/td&gt;
&lt;td&gt;Edits that leave the page looking identical&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content-based checks&lt;/td&gt;
&lt;td&gt;Extracted text, figures, fonts, pixels&lt;/td&gt;
&lt;td&gt;Inconsistencies, visual anomalies, data mismatches&lt;/td&gt;
&lt;td&gt;Yes, where extraction works&lt;/td&gt;
&lt;td&gt;Consistent edits to a real file, when no outside source covers the changed figure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity and issuer checks&lt;/td&gt;
&lt;td&gt;The person; the issuer’s own records&lt;/td&gt;
&lt;td&gt;Identity match; issuer confirmation&lt;/td&gt;
&lt;td&gt;Identity checks yes; issuer confirmation rarely&lt;/td&gt;
&lt;td&gt;Identity: the document itself. Issuer: volume and unreachable issuers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structural PDF forensics&lt;/td&gt;
&lt;td&gt;The file’s construction and revision history&lt;/td&gt;
&lt;td&gt;Verdict plus named markers&lt;/td&gt;
&lt;td&gt;Yes – one check per file&lt;/td&gt;
&lt;td&gt;Content false from the start; whether the content is true&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every blind spot in that last column is addressed, at least in part, by another row, which is the case for layering them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What none of these methods can prove
&lt;/h2&gt;

&lt;p&gt;None of the four classes, alone, proves that a document is authentic.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;intact&lt;/code&gt; structural result means no evidence of editing was found in the file. It does not mean the issuer produced it, that the figures are correct or that the applicant is who they claim to be. A document whose content was false from the start carries no evidence of a later edit, because nothing was changed afterwards, and a well-resourced forger can sometimes produce a file that shows no structural sign of editing. Structural modification detection does not claim to catch those cases; the reasoning is in &lt;a href="https://htpbe.tech/faq/can-someone-create-fake-pdf-from-scratch" rel="noopener noreferrer"&gt;can someone create a fake PDF from scratch?&lt;/a&gt;. Content and issuer checks are the layers that address them.&lt;/p&gt;

&lt;p&gt;The reverse also holds. A clean content check means the numbers are coherent, not that the file is untouched. A matched identity means the person is real, not that their documents are. And no automated method replaces an expert examiner or the issuer’s own confirmation when a decision is consequential. Every result here is an input to a human decision, not the decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  PDF tampering detection in a layered workflow
&lt;/h2&gt;

&lt;p&gt;In practice, PDF tampering detection sits at intake, before a document reaches the more expensive steps. The shape is similar across industries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lending&lt;/strong&gt; – check bank statements and payslips at upload; route &lt;code&gt;modified&lt;/code&gt; files to an underwriter before the application moves on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HR and background screening&lt;/strong&gt; – check diplomas, reference letters and employment records as they arrive, and contact the issuer for flagged files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Insurance&lt;/strong&gt; – check repair estimates, invoices and medical bills before claim scoring; &lt;a href="https://htpbe.tech/blog/insurance-fraud-detection-methods" rel="noopener noreferrer"&gt;insurance fraud detection methods&lt;/a&gt; covers that vertical in depth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accounts payable&lt;/strong&gt; – check supplier invoices before payment, and confirm bank detail changes by phone through a known contact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The structural check is automated and runs per file, so it can sit on every upload and leave human and issuer time for the files that earned it. More examples by document type are on &lt;a href="https://htpbe.tech/use-cases/detect-fake-documents" rel="noopener noreferrer"&gt;detect fake documents&lt;/a&gt;, and the integration view is on the &lt;a href="https://htpbe.tech/document-fraud-detection-api" rel="noopener noreferrer"&gt;document fraud detection API&lt;/a&gt; page.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to choose and combine methods
&lt;/h2&gt;

&lt;p&gt;Decide which questions you need answered and where each answer is cheapest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Can you get the data from the source?&lt;/strong&gt; If a bank feed or payroll connection exists for a figure, a data cross-check tests whether it is true. Use it where it is available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do you know who the person is?&lt;/strong&gt; If not, you need an identity check as well – a clean document from the wrong person is still the wrong answer, and a person who passes an identity check can still submit an edited file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Are the documents PDFs that applicants or suppliers could have edited?&lt;/strong&gt; Then a structural check on every file is designed to catch the real-document-edited case that content checks can miss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What happens on a flag?&lt;/strong&gt; Decide in advance who reviews a &lt;code&gt;modified&lt;/code&gt; or &lt;code&gt;inconclusive&lt;/code&gt; result and when you contact the issuer. A flag without a review path tends to become an automatic rejection, which is the wrong use of any of these outputs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the structural layer is the one you are missing, try it on a file you already have. New accounts start with 5 welcome credits, then checks are pay-per-check. For developers, &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;the HTPBE API&lt;/a&gt; is one POST with a PDF URL, returning the verdict and named markers, and test keys are free on every plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is document forgery detection?
&lt;/h3&gt;

&lt;p&gt;Document forgery detection is the set of methods used to decide whether a submitted document can be relied on. In practice it is four different questions answered by four different method classes – manual review (does a trained person believe it), content-based checks (is what it says consistent with itself and with other data sources), identity and issuer checks (is this the right person, and did the issuer actually issue it), and structural PDF forensics (was the file changed after it was created). Each class fails in different places, so they are normally layered rather than chosen between.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can document forgery be detected without reading the document’s content?
&lt;/h3&gt;

&lt;p&gt;Some of it can. Structural PDF forensics does not interpret the figures or names on the page, and it does not judge what the page looks like. It examines how the file was built and changed – revision layers, metadata and timestamps, signature coverage, how pages were assembled – and reports whether the file carries evidence of being edited after it was created. It is built to catch a real document that was opened and re-saved with new figures, even when the edit is invisible on screen. A document whose content was false from the start carries no such evidence, because nothing was changed afterwards, and structural forensics says nothing about whether the content is true. Those questions belong to content and issuer checks.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between structural PDF forensics and content-based document checks?
&lt;/h3&gt;

&lt;p&gt;Content-based checks look at what the document says and shows – extracted text and numbers, arithmetic, fonts and pixels, and how the data compares with bank feeds, payroll records or registries. Structural PDF forensics looks at the file itself – whether it was modified after it was produced. Content checks tell you whether the numbers and pixels look right; structural checks tell you whether the file was edited after it was made. Each misses what the other sees, which is why they work best together.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does an intact result prove a document is authentic?
&lt;/h3&gt;

&lt;p&gt;No. An intact verdict means no structural evidence of editing was found in the file. It does not prove the issuer produced the document, that the person submitting it is who they claim to be, or that the figures are true. A document whose content was false from the start carries no evidence of a later edit, because nothing was changed afterwards. Treat intact as one input to the decision, and confirm with the issuer when the stakes call for it.&lt;/p&gt;

&lt;p&gt;The case for reading a file’s own record, and five questions to put to any file-level check, are in &lt;a href="https://htpbe.tech/blog/binary-pdf-modification-detection-technical-approach" rel="noopener noreferrer"&gt;why PDF modification checks read the file, not the page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>fintech</category>
      <category>fraud</category>
      <category>forensics</category>
    </item>
    <item>
      <title>VAT Validation in Next.js Server Actions</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Wed, 04 Nov 2026 10:00:50 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/vat-validation-in-nextjs-server-actions-25i4</link>
      <guid>https://dev.to/iurii_rogulia/vat-validation-in-nextjs-server-actions-25i4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/vat-validation-nextjs-server-actions" rel="noopener noreferrer"&gt;vatnode.dev&lt;/a&gt;. The version on vatnode.dev is the canonical source — refer to it for the latest content.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the implementation for validating a VAT ID inside a Next.js Server Action at form submission: a &lt;code&gt;useActionState&lt;/code&gt; hook, a server-only call to vatnode, and a response mapping that distinguishes an invalid number from a check that didn’t complete. For validation that runs live as the customer types, see &lt;a href="https://vatnode.dev/blog/vat-validation-react-checkout-form" rel="noopener noreferrer"&gt;Real-Time VAT Validation in a React Checkout Form&lt;/a&gt; instead – this post is about validating once, on submit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not as-you-type here
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://nextjs.org/docs/app/guides/server-actions" rel="noopener noreferrer"&gt;Next.js Server Actions and Mutations guide&lt;/a&gt; (checked 2 Oct 2026, docs for Next.js 16) says Next.js dispatches Server Actions from a given client one at a time: a second invocation waits for the first to resolve before it’s sent. Fine for a single submit button, not for a debounced per-keystroke check – a vatnode call can take several seconds when VIES itself is slow, so the action for the ninth keystroke queues behind the one for the fifth, and the UI falls further behind the input with every character typed.&lt;/p&gt;

&lt;p&gt;A route handler doesn’t have this restriction – each &lt;code&gt;fetch&lt;/code&gt; is an independent HTTP request, which is why the &lt;a href="https://vatnode.dev/blog/vat-validation-react-checkout-form" rel="noopener noreferrer"&gt;React checkout form post&lt;/a&gt; uses one for live validation. A Server Action is the right tool for the submit, the moment that sets the order’s VAT treatment (subject to the re-check before invoicing).&lt;/p&gt;

&lt;h2&gt;
  
  
  A Server Action is a public POST endpoint
&lt;/h2&gt;

&lt;p&gt;Per the same guide, a Server Action is a route any client sending the matching POST request can reach, whether or not your UI renders a button pointing at it. Next.js checks the request’s &lt;code&gt;Origin&lt;/code&gt; against its &lt;code&gt;Host&lt;/code&gt; as a CSRF defense, but that’s not application-level authorization – it stops a cross-site form from POSTing to your action, not a well-formed request from a tool that isn’t your form.&lt;/p&gt;

&lt;p&gt;For a VAT check, that means an action calling vatnode is a proxy to your API quota. Three things follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Validate the input shape locally before making the upstream call – a malformed string shouldn’t reach vatnode at all.&lt;/li&gt;
&lt;li&gt;If the form sits behind a login, protect it with your app’s own session check, the same as any other mutation.&lt;/li&gt;
&lt;li&gt;Return a shaped result, not whatever vatnode’s response body happens to contain – the Next.js docs call this ‘constrain return values.’&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Keep the key server-only
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;lib/vatnode.ts&lt;/code&gt; holds the call and nothing touches it from client code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/vatnode.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;server-only&lt;/span&gt;&lt;span class="dl"&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;VatValidationResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
  &lt;span class="nx"&gt;companyName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="nx"&gt;consultationNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;checkId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;verifiedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;VatnodeErrorEnvelope&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
    &lt;span class="na"&gt;requestId&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;VatCheckResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VatValidationResult&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&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="nl"&gt;code&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;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;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;checkVat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;VatCheckResponse&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;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VATNODE_API_KEY&lt;/span&gt;
  &lt;span class="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;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;VATNODE_API_KEY is not set&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;let&lt;/span&gt; &lt;span class="na"&gt;res&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://api.vatnode.dev/v1/vat/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-store&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="c1"&gt;// Keep this above vatnode's own upstream budget, not below it — a&lt;/span&gt;
      &lt;span class="c1"&gt;// short timeout here aborts checks vatnode would have completed.&lt;/span&gt;
      &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Network failure, the timeout firing, or a non-JSON body from a proxy.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&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;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NETWORK_ERROR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Could not reach vatnode.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;VatValidationResult&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;envelope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;VatnodeErrorEnvelope&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;ok&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;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UNKNOWN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unknown error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;server-only&lt;/code&gt; import turns an accidental client-component import of this module into a build error. &lt;code&gt;process.env.VATNODE_API_KEY&lt;/code&gt; – no &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix – stays out of the browser bundle entirely. If it’s missing, throw at call time; there’s no sane fallback for a missing credential.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local format check without breaking test mode
&lt;/h2&gt;

&lt;p&gt;Normalize the raw input the same way vatnode’s own API does – trim, uppercase, strip spaces, hyphens, and dots – then run it past &lt;code&gt;validateFormat&lt;/code&gt; from the npm package &lt;code&gt;eu-vat-rates-data&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\s\-&lt;/span&gt;&lt;span class="sr"&gt;.&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





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

&lt;span class="nf"&gt;validateFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DE123456789&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;span class="nf"&gt;validateFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;XX0000001&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;validateFormat&lt;/code&gt; runs the ID against per-country regex patterns, and &lt;code&gt;XX&lt;/code&gt; isn’t a country – it’s vatnode’s test-mode prefix with no pattern of its own, so it fails the check. Let it through explicitly:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;XX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;validateFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// reject locally&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skipping this check wouldn’t break anything: a &lt;code&gt;vat_live_&lt;/code&gt; key sent an &lt;code&gt;XX&lt;/code&gt; id still gets &lt;code&gt;400 INVALID_FORMAT&lt;/code&gt; back from vatnode, and a malformed ID sent to a &lt;code&gt;vat_test_&lt;/code&gt; key gets the same &lt;code&gt;400&lt;/code&gt; without hitting a fixture. The local check just avoids the round trip. Format-valid is not the same as registered – Greek VAT numbers use the prefix &lt;code&gt;EL&lt;/code&gt;, not &lt;code&gt;GR&lt;/code&gt;. Northern Ireland uses &lt;code&gt;XI&lt;/code&gt;, for goods only.&lt;/p&gt;

&lt;h2&gt;
  
  
  The action, wired to useActionState
&lt;/h2&gt;

&lt;p&gt;React 19’s &lt;code&gt;useActionState(action, initialState)&lt;/code&gt; – imported from &lt;code&gt;react&lt;/code&gt;, not &lt;code&gt;react-dom&lt;/code&gt; – returns &lt;code&gt;[state, formAction, pending]&lt;/code&gt;, and the action itself receives &lt;code&gt;(prevState, formData)&lt;/code&gt;. A file with &lt;code&gt;'use server'&lt;/code&gt; at the top can only export async functions, so the state type and its initial value live in a separate module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/checkout/types.ts&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;CheckoutState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;valid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unverified&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="nx"&gt;companyName&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="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;initialCheckoutState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CheckoutState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/checkout/actions.ts&lt;/span&gt;
&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use server&lt;/span&gt;&lt;span class="dl"&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;validateFormat&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eu-vat-rates-data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;checkVat&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/vatnode&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&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;CheckoutState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./types&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\s\-&lt;/span&gt;&lt;span class="sr"&gt;.&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Stub — replace with your own persistence. Shown here only so the snippet&lt;/span&gt;
&lt;span class="c1"&gt;// compiles; the point is that it runs regardless of the VAT verdict.&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;saveBillingDetails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;verified&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="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;void&lt;/span&gt; &lt;span class="nx"&gt;vatId&lt;/span&gt;
  &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nx"&gt;verified&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;OPERATOR_ERRORS&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;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UNAUTHORIZED&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;INVALID_API_KEY&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;INVALID_REQUESTER&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;RATE_LIMITED&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;UNVERIFIED_MESSAGE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;We couldn't verify your VAT number right now. You can continue; VAT will be charged until it's confirmed.&lt;/span&gt;&lt;span class="dl"&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;checkVatAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;_prevState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CheckoutState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FormData&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;CheckoutState&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;vatId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&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;vatId&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Enter a VAT number.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;XX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;validateFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;This does not look like a VAT number.&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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkVat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INVALID_FORMAT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;This does not look like a VAT number.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Your side, not the customer's: a wrong key, a bad requester VAT, an empty quota.&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;OPERATOR_ERRORS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="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="s1"&gt;vatnode check needs attention&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveBillingDetails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unverified&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UNVERIFIED_MESSAGE&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveBillingDetails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;VIES reports this VAT number as not currently valid for intra-EU transactions.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveBillingDetails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;valid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;companyName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;companyName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;saveBillingDetails&lt;/code&gt; is a stub for whatever your app does with the result – attach it to an order, a customer record, whatever persistence you already have. It runs on every outcome except a malformed ID, including &lt;code&gt;unverified&lt;/code&gt;: billing details still get saved, just flagged for a later re-check.&lt;/p&gt;

&lt;p&gt;Expected outcomes – invalid, unverified – are returned from the action, not thrown. Nothing here throws except the missing-API-key case in &lt;code&gt;checkVat&lt;/code&gt;, which is a deploy misconfiguration, not a user-facing state.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://react.dev/reference/react-dom/components/form" rel="noopener noreferrer"&gt;React form docs&lt;/a&gt; say that after a form action succeeds, React resets every uncontrolled field in the form – and an action that returns an error as state has still completed. Return the submitted, normalized &lt;code&gt;vatId&lt;/code&gt; in state and bind the input to it with &lt;code&gt;defaultValue&lt;/code&gt;, or a rejected number vanishes from the field along with the error explaining why.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/checkout/VatIdField.tsx&lt;/span&gt;
&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&lt;/span&gt;&lt;span class="dl"&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;useActionState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&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;checkVatAction&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./actions&lt;/span&gt;&lt;span class="dl"&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;initialCheckoutState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./types&lt;/span&gt;&lt;span class="dl"&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;VatIdField&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;formAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useActionState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;checkVatAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;initialCheckoutState&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;statusId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useId&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;isInvalid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid&lt;/span&gt;&lt;span class="dl"&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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;formAction&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;label&lt;/span&gt; &lt;span class="na"&gt;htmlFor&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"vat-id"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;VAT number (optional)&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;label&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;statusId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isInvalid&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alert&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;status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"vat-id"&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"vatId"&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt;
        &lt;span class="na"&gt;inputMode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt;
        &lt;span class="na"&gt;autoComplete&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"off"&lt;/span&gt;
        &lt;span class="na"&gt;defaultValue&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vatId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;aria-invalid&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isInvalid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;aria-describedby&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;statusId&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Checking…&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;Validate VAT number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The label sits above the field; the status message sits between the label and the input, not below it, where a phone keyboard hides it; &lt;code&gt;aria-invalid&lt;/code&gt;/&lt;code&gt;aria-describedby&lt;/code&gt; tie the message to the field for assistive tech. The submit button disables only while &lt;code&gt;pending&lt;/code&gt;, with a visible ‘Checking…’ label – not based on whether the current input looks valid. &lt;code&gt;&amp;lt;form noValidate&amp;gt;&lt;/code&gt; isn’t needed here, since the input carries no HTML validation attributes of its own.&lt;/p&gt;

&lt;p&gt;If you redirect to a confirmation step on success, call &lt;code&gt;redirect()&lt;/code&gt; outside any &lt;code&gt;try&lt;/code&gt;/&lt;code&gt;catch&lt;/code&gt; – the &lt;a href="https://nextjs.org/docs/app/api-reference/functions/redirect" rel="noopener noreferrer"&gt;Next.js &lt;code&gt;redirect&lt;/code&gt; reference&lt;/a&gt; notes that &lt;code&gt;redirect()&lt;/code&gt; works by throwing, so catching it anywhere in the call chain swallows the navigation. The example skips the redirect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calling vatnode with a bounded timeout
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;checkVat&lt;/code&gt; sets &lt;code&gt;signal: AbortSignal.timeout(35_000)&lt;/code&gt;. vatnode’s own VIES budget is a 12-second attempt plus, for some failures, a 6-second retry, and for some countries a VIES outage also triggers a national-registry fallback before vatnode answers, so a slow check can take close to half a minute. A shorter timeout on your side aborts checks vatnode would have completed. Keep yours above that budget, not under it, and let vatnode return &lt;code&gt;504 UPSTREAM_TIMEOUT&lt;/code&gt; on its own schedule if VIES doesn’t answer.&lt;/p&gt;

&lt;p&gt;A thrown &lt;code&gt;fetch&lt;/code&gt; – a network failure, the abort firing, or a body that isn’t JSON – maps to &lt;code&gt;unverified&lt;/code&gt; above. There’s no retry loop inside the action: vatnode caches a transient &lt;code&gt;503&lt;/code&gt;/&lt;code&gt;504&lt;/code&gt; for the same VAT ID for 60 seconds (when the account has no requester VAT configured), so an immediate retry would just replay &lt;a href="https://vatnode.dev/blog/vat-api-idempotency-retries" rel="noopener noreferrer"&gt;the cached failure&lt;/a&gt;, not get a fresh answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mapping every outcome by error code, not HTTP status
&lt;/h2&gt;

&lt;p&gt;Switch on &lt;code&gt;error.code&lt;/code&gt; from the envelope &lt;code&gt;{ error: { code, message, requestId } }&lt;/code&gt;, not on HTTP status alone – &lt;code&gt;AUDIT_WRITE_FAILED&lt;/code&gt; and &lt;code&gt;VIES_ERROR&lt;/code&gt; both arrive at &lt;code&gt;502&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Response&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Action state&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;200&lt;/code&gt;, &lt;code&gt;valid: true&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The number checks out – &lt;code&gt;source&lt;/code&gt; says which system answered; &lt;code&gt;companyName&lt;/code&gt; may be &lt;code&gt;null&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;valid&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;200&lt;/code&gt;, &lt;code&gt;valid: false&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;VIES reports the number as not currently valid&lt;/td&gt;
&lt;td&gt;&lt;code&gt;invalid&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;400 INVALID_FORMAT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Malformed ID (also returned if a &lt;code&gt;vat_test_&lt;/code&gt; key gets a non-&lt;code&gt;XX&lt;/code&gt; id)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;invalid&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;401 UNAUTHORIZED&lt;/code&gt; / &lt;code&gt;INVALID_API_KEY&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Your key is missing or wrong – your config bug, not the customer’s input&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unverified&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;403 VIES_ERROR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;VIES reports the VAT ID or vatnode’s own IP as blocked (&lt;code&gt;VAT_BLOCKED&lt;/code&gt; / &lt;code&gt;IP_BLOCKED&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unverified&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;422 INVALID_REQUESTER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your account’s dashboard requester VAT is invalid in VIES – your setting, not theirs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unverified&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;429 RATE_LIMITED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Monthly quota exceeded&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unverified&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;502 VIES_ERROR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Unexpected VIES protocol error&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unverified&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;502 AUDIT_WRITE_FAILED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Check ran, record couldn’t be stored – verdict withheld, no quota spent, safe to retry&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unverified&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;503 VIES_UNAVAILABLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;VIES is unavailable (without a requester VAT, a national fallback was tried first where one exists for that country)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unverified&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;504 UPSTREAM_TIMEOUT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;VIES didn’t answer within vatnode’s own budget&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unverified&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;500 INTERNAL_ERROR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;vatnode-side failure unrelated to VIES or the input&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unverified&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every row except the first three says ‘the check didn’t complete’, not ‘the number is wrong’, so the action maps all of them to &lt;code&gt;unverified&lt;/code&gt;. The ones caused by your own setup – the key, the requester VAT, the quota – also get logged, because no customer can fix them. The full code set and how to handle each one outside the UI – retry, cache, log – is in &lt;a href="https://vatnode.dev/blog/handling-vies-errors-in-code" rel="noopener noreferrer"&gt;handling VIES errors in code&lt;/a&gt;; the contract they come from is in &lt;a href="https://vatnode.dev/docs/errors" rel="noopener noreferrer"&gt;the error reference&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Never block the order on an unverified check
&lt;/h2&gt;

&lt;p&gt;If the check can’t complete, don’t block the order. Treat the customer as unverified: charge VAT as you would for a customer without a valid VAT ID, re-run the check before the invoice is finalized, and – only for a customer in another member state – exempt the intra-Community supply of goods or apply the reverse charge for services once that later check confirms the number. A same-country customer is charged domestic VAT regardless of VAT ID status.&lt;/p&gt;

&lt;p&gt;If confirmation arrives after an invoice has gone out, correct it with a credit note and a new, correctly treated invoice – not by editing the original.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;valid: true&lt;/code&gt; answer with &lt;code&gt;source: 'VIES'&lt;/code&gt; means VIES confirmed the number is registered for intra-EU transactions at the moment of the check. Without a requester VAT, when VIES is down for that country, vatnode can fall back to the national VAT register instead – &lt;code&gt;source&lt;/code&gt; then names that register, and there’s no consultation number. When your account has a requester VAT configured, every check skips the cache, so &lt;code&gt;source&lt;/code&gt; is always &lt;code&gt;'VIES'&lt;/code&gt; or a named registry; without one, a repeat check within 15 minutes can return &lt;code&gt;'CACHE'&lt;/code&gt; instead, which tells you the number was valid recently, not which authority confirmed it. Store &lt;code&gt;source&lt;/code&gt; with the verdict so you know which system confirmed the number, when it isn’t &lt;code&gt;'CACHE'&lt;/code&gt;. In any case, it isn’t a certificate, and it doesn’t tell you which treatment – the exemption for goods or the reverse charge for services – the supply qualifies for.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;valid: false&lt;/code&gt; means VIES reports the number as not currently valid for intra-EU transactions – the customer may have a domestic-only registration, a typo, or a deregistered number. Charge VAT and let them correct it.&lt;/p&gt;

&lt;p&gt;Store the consultation number vatnode returns when your account has a requester VAT set in the dashboard’s Account details – it’s your evidence that you checked. The response also carries &lt;code&gt;checkId&lt;/code&gt; and &lt;code&gt;verifiedAt&lt;/code&gt;, which are worth storing alongside it.&lt;/p&gt;

&lt;p&gt;The Server Action above is the server-side check that happens at submit. ‘Re-run before the invoice’ is a separate, later server-side job – your invoice run, typically – since the action itself doesn’t retry a stale &lt;code&gt;unverified&lt;/code&gt; result on its own. The same pattern, applied to a full checkout and to a signup form, is covered in &lt;a href="https://vatnode.dev/blog/validate-vat-numbers-at-checkout" rel="noopener noreferrer"&gt;validate VAT numbers at checkout&lt;/a&gt; and &lt;a href="https://vatnode.dev/blog/validate-vat-numbers-at-signup" rel="noopener noreferrer"&gt;validate VAT numbers at signup&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing with a test key
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;vat_test_&lt;/code&gt; key accepts only &lt;code&gt;XX&lt;/code&gt;-prefixed IDs, makes no VIES call, and spends no quota:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;VAT ID&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;XX0000001&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;valid: true&lt;/code&gt;, with a company name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;XX0000002&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;valid: true&lt;/code&gt;, no company name or address&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;XX0000003&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;valid: false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;XX0000004&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;503 VIES_UNAVAILABLE&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;XX0000005&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;502 VIES_ERROR&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any other &lt;code&gt;XX…&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;200&lt;/code&gt;, &lt;code&gt;valid: false&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-&lt;code&gt;XX&lt;/code&gt; ID with a test key&lt;/td&gt;
&lt;td&gt;&lt;code&gt;400 INVALID_FORMAT&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That’s enough to exercise every branch in the action above without touching a live key. More on the full fixture set in &lt;a href="https://vatnode.dev/blog/vat-api-test-keys" rel="noopener noreferrer"&gt;test keys&lt;/a&gt; and &lt;a href="https://vatnode.dev/docs/test-mode" rel="noopener noreferrer"&gt;the test-mode docs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://vatnode.dev/register" rel="noopener noreferrer"&gt;Register&lt;/a&gt; for a free API key – 100 requests/month, no card. You get a test key straight away and create a live key in the &lt;a href="https://vatnode.dev/dashboard" rel="noopener noreferrer"&gt;dashboard&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vatnode.dev/docs" rel="noopener noreferrer"&gt;API reference&lt;/a&gt; for the full request/response contract.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vatnode.dev/pricing" rel="noopener noreferrer"&gt;Pricing&lt;/a&gt; if you outgrow the free tier.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vatnode.dev/guides/vat-validation-nodejs" rel="noopener noreferrer"&gt;EU VAT validation in Node.js&lt;/a&gt; for the server-side contract outside of Next.js.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vatnode.dev/vat-api" rel="noopener noreferrer"&gt;EU VAT validation API&lt;/a&gt; overview.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Why not call vatnode on every keystroke from a Server Action?&lt;/strong&gt;&lt;br&gt;
Next.js dispatches Server Actions from the same client one at a time – a second action waits for the first to finish. A debounced per-keystroke action queues behind whatever check is already in flight, and a vatnode call can take several seconds when VIES is slow. For live-typing validation, use a route handler instead; this post covers validating once, on submit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is a Server Action safe to call from an unauthenticated form?&lt;/strong&gt;&lt;br&gt;
A Server Action is a public POST endpoint – anyone who can send the same request body can invoke it, framework CSRF origin checks aside. If the action calls vatnode, it’s a proxy to your API quota, so validate the input shape before the upstream call, put the form behind your app’s own auth/session if it has one, and return a shaped result rather than the raw upstream body.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does useActionState reset on a failed submission?&lt;/strong&gt;&lt;br&gt;
React resets every uncontrolled field in the form after the action succeeds, and an action that returns a validation error as state has still succeeded. Return the submitted VAT ID in the action’s state and render the input with &lt;code&gt;defaultValue&lt;/code&gt; bound to it, or a rejected VAT ID disappears from the field along with the error message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should an unverified VAT check block the order?&lt;/strong&gt;&lt;br&gt;
No. Treat the customer as unverified – charge VAT as you would for a customer without a valid VAT ID, let the order proceed, and re-run the check before the invoice is finalized. VIES has no uptime guarantee, and blocking checkout on it turns an upstream outage into a lost order.&lt;/p&gt;

</description>
      <category>tax</category>
      <category>webdev</category>
      <category>api</category>
    </item>
    <item>
      <title>UK Visa Bank Statement Fraud: What UKVI Actually Checks</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Tue, 03 Nov 2026 11:00:49 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/uk-visa-bank-statement-fraud-what-ukvi-actually-checks-3jlc</link>
      <guid>https://dev.to/iurii_rogulia/uk-visa-bank-statement-fraud-what-ukvi-actually-checks-3jlc</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/uk-visa-bank-statement-fraud-ukvi" 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;Many people who search ‘how does UKVI verify bank statements’ are applicants. Others are the people helping them: an admissions officer at a university, a sponsor’s HR team, an immigration adviser assembling a bundle. The short answer is that the Home Office can check a bank statement with the bank that issued it. The public versions of its caseworker guidance describe when that happens only in outline and redact the operational detail.&lt;/p&gt;

&lt;p&gt;This article sets out what the published rules and guidance say, route by route, and how the Home Office describes checking financial evidence. Then it looks at a narrower question that sits on the intake side, before anything is submitted: whether the PDF statement an organisation is holding was edited after the bank produced it.&lt;/p&gt;

&lt;p&gt;Everything below reflects published gov.uk guidance as of October 2026. Immigration rules change several times a year. Each source is cited with the date of the version we read, so check the current page for the route you are working on before relying on any figure here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does UKVI verify bank statements?
&lt;/h2&gt;

&lt;p&gt;Yes, it can. The student financial-evidence guidance tells applicants directly: ‘&lt;a href="https://www.gov.uk/guidance/financial-evidence-for-student-and-child-student-route-applicants" rel="noopener noreferrer"&gt;We may verify the evidence with your bank&lt;/a&gt;’ (updated 31 December 2025). &lt;a href="https://www.gov.uk/guidance/immigration-rules/immigration-rules-appendix-finance" rel="noopener noreferrer"&gt;Appendix Finance&lt;/a&gt; (updated 3 August 2026) goes further at FIN 2.1: funds will not be counted if the decision maker cannot make satisfactory verification checks, if the institution holding them is not regulated, or if it does not keep electronic records.&lt;/p&gt;

&lt;p&gt;What the guidance does not describe is a check on every statement. The &lt;a href="https://assets.publishing.service.gov.uk/media/69c12c71b920af63be1c7654/Student_and_Child_Student.pdf" rel="noopener noreferrer"&gt;Student caseworker guidance&lt;/a&gt; (version 14.0, 3 August 2026) says verification is carried out where there is reasonable doubt about a document or where a risk profile instructs it, and the risk profiles themselves are redacted. The rules and guidance do set out the consequence when a document is found to be false, covered below. The suitability guidance’s definition of a false document includes a genuine document that has been altered or tampered with.&lt;/p&gt;

&lt;h2&gt;
  
  
  What UKVI asks for, route by route
&lt;/h2&gt;

&lt;p&gt;Financial evidence rules differ by route. Since October 2020, the general position under the Immigration Rules has been more permissive about format than many applicants expect: the &lt;a href="https://assets.publishing.service.gov.uk/media/5f9178c4d3bf7f35e5912981/CCS207_CCS1020373376-002_Explanatory_Memorandum_to_HC_813__PRINT_.pdf" rel="noopener noreferrer"&gt;Explanatory Memorandum to HC 813&lt;/a&gt; records that the old Appendix P list of acceptable banks was deleted and replaced with a general verification test, and that format rules were removed so electronic statements no longer needed a bank stamp on every page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Student route
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://www.gov.uk/guidance/financial-evidence-for-student-and-child-student-route-applicants" rel="noopener noreferrer"&gt;student financial-evidence guidance&lt;/a&gt; (updated 31 December 2025) sets the maintenance figure at £1,171 a month outside London and £1,529 a month in London, for up to nine months. The money must have been held for 28 consecutive days, and the most recent evidence must be dated no more than 31 days before the application. Paper statements and electronic statements downloaded from online banking are both accepted, provided they show the account holder’s name, the bank’s name and the balance. Nationals of listed countries do not have to submit the evidence with the application unless they are asked for it.&lt;/p&gt;

&lt;p&gt;Appendix Finance, at FIN 7.1 to 7.3, sets the general rules on dating: evidence must be dated within 31 days of the application, and the holding period is counted back from the closing balance.&lt;/p&gt;

&lt;h3&gt;
  
  
  Work routes
&lt;/h3&gt;

&lt;p&gt;For sponsored and endorsed work routes, the &lt;a href="https://www.gov.uk/guidance/financial-evidence-for-sponsored-or-endorsed-work-routes" rel="noopener noreferrer"&gt;financial evidence page&lt;/a&gt; (last updated 22 August 2022, so read it alongside the current Appendix Finance) describes a maintenance requirement that an A-rated sponsor can certify on the applicant’s behalf instead of the applicant showing personal savings. Where the applicant does provide statements, electronic statements are acceptable without a stamp if they show the date, name, institution and balance. The page also says the Home Office may check the evidence with the bank and may refuse the application if it cannot be verified.&lt;/p&gt;

&lt;h3&gt;
  
  
  Family routes
&lt;/h3&gt;

&lt;p&gt;Family applications are governed by &lt;a href="https://www.gov.uk/guidance/immigration-rules/immigration-rules-appendix-fm-se-family-members-specified-evidence" rel="noopener noreferrer"&gt;Appendix FM-SE&lt;/a&gt; (3 August 2026), which is more prescriptive than the other routes. Paragraph 1(a) requires statements on official bank stationery or, for electronic statements, a supporting letter from the bank or a bank stamp on every page.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.gov.uk/government/publications/financial-requirement-caseworker-guidance/financial-requirement-caseworker-guidance-accessible" rel="noopener noreferrer"&gt;financial requirement caseworker guidance&lt;/a&gt; (version 9.0, 12 September 2025) states that electronic statements need neither a stamp nor a letter. The two published documents read differently on this point, so advisers should check the current wording of the rule and guidance for the specific application rather than rely on either summary here. FM-SE also allows the decision maker, in some cases, to request missing documents or documents in the correct format (paragraph D).&lt;/p&gt;

&lt;p&gt;The same caseworker guidance says verification follows local processes, that funds can be disregarded if the bank refuses to verify them, and that ATM mini-statements are not accepted because they do not show enough information and are easy to fake. Much of the remaining procedure is redacted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Visitors
&lt;/h3&gt;

&lt;p&gt;There is no fixed amount for visitors. The &lt;a href="https://www.gov.uk/government/publications/visit-guidance/visit-caseworker-guidance-accessible--2" rel="noopener noreferrer"&gt;visit caseworker guidance&lt;/a&gt; (version 17.0, 25 February 2026) says recent large deposits may be queried for their source, and that documents a caseworker doubts may be verified at source.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Home Office checks financial evidence
&lt;/h2&gt;

&lt;p&gt;Read together, the published guidance describes three stages: caseworker review, verification at source when a document is in doubt, and a suitability decision if a document is found to be false.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caseworker review.&lt;/strong&gt; A caseworker assesses the evidence against the rules for the route: the amount, the holding period, the dates, the name and the institution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verification at source.&lt;/strong&gt; The &lt;a href="https://www.gov.uk/government/publications/document-verification-checks" rel="noopener noreferrer"&gt;document verification guidance&lt;/a&gt; (version 1.0, 12 December 2016, still listed on gov.uk) gives the questions a caseworker may put to a bank: whether the account and branch exist, whether the bank knows the applicant, whether the balance is correct, whether the statement is genuine, and whether the person who signed it works there. A finding that a document is false is recorded in a Document Verification Report.&lt;/p&gt;

&lt;p&gt;The Student caseworker guidance describes three outcomes for these checks: genuine, false and inconclusive. Where the Home Office’s own verification outcome is inconclusive, the document is discounted; if no other evidence meets the requirement, the caseworker refuses the application or refers the applicant for interview. Keep that term separate from the HTPBE verdict &lt;code&gt;inconclusive&lt;/code&gt; discussed below. They share a word and nothing else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Suitability and consequences.&lt;/strong&gt; &lt;a href="https://www.gov.uk/guidance/immigration-rules/immigration-rules-part-suitability" rel="noopener noreferrer"&gt;Part Suitability&lt;/a&gt; (3 August 2026) sets the outcomes. Under SUI 9.1, deception proven against the applicant, including the use of false documents, leads to mandatory refusal, and SUI 12.1 adds a 10-year re-entry ban after deception. Under SUI 10.1, false documents can lead to discretionary refusal whether or not the applicant knew the document was false, including where a third party supplied it. The &lt;a href="https://www.gov.uk/government/publications/suitability-false-representations-immigration-staff-guidance/part-suitability-deception-false-representations-false-documents-and-non-disclosure-of-relevant-facts-accessible" rel="noopener noreferrer"&gt;suitability guidance&lt;/a&gt; (version 1.0, 11 November 2025) defines a false document to include a genuine document that has been altered or tampered with, says the Home Office must prove deception on the balance of probabilities, and notes that discretionary refusals carry no ban.&lt;/p&gt;

&lt;p&gt;The public record stops there. It does not describe how the Home Office selects documents for checking beyond the redacted risk profiles, and we do not speculate about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three checks that answer different questions
&lt;/h2&gt;

&lt;p&gt;There are three kinds of check here. Each answers a different question, and none substitutes for another.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Content rules.&lt;/strong&gt; Does the statement show the right name, bank, balance and dates, and does the money meet the requirement for the route? This is the caseworker’s assessment, and the adviser’s or sponsor’s pre-submission review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source enquiries.&lt;/strong&gt; Does the bank confirm the account, the holder and the balance? Only the bank can answer this, and the Home Office decides when to ask.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File structure.&lt;/strong&gt; Was this PDF file edited after the software that produced it first wrote it? This is a property of the file, not of the account.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first two are the Home Office’s process. The third concerns something only the intake organisation holds: the files it has collected before anything is submitted. A university admissions team, a sponsor’s HR team or a regulated adviser (the regulator, formerly OISC, was &lt;a href="https://www.gov.uk/government/news/enhanced-agenda-and-identity-for-uk-immigration-advice-regulator" rel="noopener noreferrer"&gt;renamed the Immigration Advice Authority&lt;/a&gt; in January 2025) may receive a statement as a PDF attachment. That file has its own construction history, whatever the content shows.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://assets.publishing.service.gov.uk/media/69ce5bf2d6a236570a263dac/Student_Sponsor_Guidance_Doc_2_Version_04_2026.pdf" rel="noopener noreferrer"&gt;Student Sponsor Guidance Document 2&lt;/a&gt; (version 04/2026) sets no specific duty on sponsors to check applicants’ bank statements. It requires sponsors to act honestly, assess applicants before assigning a CAS and record the evidence they relied on. The specific authenticity duty at paragraph 5.73 concerns qualifications. A file-level check on statements is a choice an organisation makes for its own reasons, and what it buys is narrow: the chance to ask for a fresh copy from the bank before a file the organisation is unsure about goes into a bundle.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a file-level tamper check can and cannot tell you
&lt;/h2&gt;

&lt;p&gt;HTPBE is a PDF tamper-detection tool. It reads the structure of a PDF and returns one of three verdicts plus a list of named markers describing what it found.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;intact&lt;/code&gt; means no structural evidence of modification after the file was created. It does not mean the statement is genuine, that the balance is correct, or that the bank issued it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;modified&lt;/code&gt; means the file carries evidence that it was changed after it was first generated. Markers name the finding by outcome, for example &lt;code&gt;HTPBE_EDITING_TOOL_FINGERPRINT&lt;/code&gt; (signs of a known PDF editing tool), &lt;code&gt;HTPBE_MULTIPLE_REVISION_LAYERS&lt;/code&gt; (the document was modified after creation) or &lt;code&gt;HTPBE_ONLINE_EDITOR_ORIGIN&lt;/code&gt; (the file passed through an online PDF editing or conversion service).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;inconclusive&lt;/code&gt; means the file’s structure cannot settle the question either way. It is not a finding that the file was edited, and it is not a finding that it was not. Plenty of legitimate statements return it, including some downloaded directly from a bank.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it does not do matters as much:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It does not read figures. It does not check balances, totals, the 28-day holding period or the closing date.&lt;/li&gt;
&lt;li&gt;It does not establish who owns the account, and it does not confirm anything with the bank.&lt;/li&gt;
&lt;li&gt;It does not check identity or eligibility, it is not a KYC or identity-verification service, and it is not part of any Home Office process. Its output has no bearing on a visa decision.&lt;/li&gt;
&lt;li&gt;It does not judge whether what a statement says is true. A structural check speaks to the file’s edit history only, which is why the content review and the bank remain the controls for the content itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A &lt;code&gt;modified&lt;/code&gt; verdict is a statement about the file: it was edited after creation. It is not a finding of fraud, and it says nothing about who made the edit or why. People merge statements into one PDF, redact account numbers before emailing them, and convert files to meet upload limits, all for ordinary reasons.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do with a &lt;code&gt;modified&lt;/code&gt; or &lt;code&gt;inconclusive&lt;/code&gt; result
&lt;/h2&gt;

&lt;p&gt;Treat both as routing signals inside your own review, never as grounds for adverse action against a person on their own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;modified&lt;/code&gt;.&lt;/strong&gt; Ask for the statement again, obtained directly from the bank: a fresh download from online banking, or a letter or stamped statement where the route expects one. Note the result and what you asked for in your file. If the new copy raises the same question, that is a matter for your organisation’s normal escalation process and, where relevant, professional advice, not something the verdict decides by itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;inconclusive&lt;/code&gt;.&lt;/strong&gt; This is common on legitimate statements and is not a finding against the applicant. On its own it is not a reason to discount a statement or to treat the applicant differently, but it is not a pass either: it gives no structural assurance in either direction, so your content review carries the weight. Where your review needs a firmer answer, ask for the statement again, obtained directly from the bank, and bear in mind that a fresh bank download can itself return &lt;code&gt;inconclusive&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;intact&lt;/code&gt;.&lt;/strong&gt; No structural evidence of an edit after creation. Your content review still applies, and so does every check the Home Office makes after submission.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it fits in an intake workflow
&lt;/h2&gt;

&lt;p&gt;The check sits at the point where a PDF statement arrives, before it goes into an application bundle or a CAS assessment. It answers one question about the file and leaves every other question to the people and processes that own them.&lt;/p&gt;

&lt;p&gt;For occasional checks, &lt;a href="https://htpbe.tech/auth/signup" rel="noopener noreferrer"&gt;create an account&lt;/a&gt;. New accounts normally receive 5 welcome credits, and further checks are pay-per-check; the options are on the &lt;a href="https://htpbe.tech/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt;. For volume, the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;API&lt;/a&gt; takes a PDF URL, and the result endpoint returns the verdict and named marker IDs, so the result can be stored against the case record. Test keys return deterministic sample results for wiring up an integration; they never analyse a real file. If you check applicants’ statements through any third-party tool, make sure your lawful basis and privacy notice cover it.&lt;/p&gt;

&lt;p&gt;For the wider picture, the &lt;a href="https://htpbe.tech/use-cases/immigration-document-fraud-detection" rel="noopener noreferrer"&gt;immigration document fraud detection hub&lt;/a&gt; and the &lt;a href="https://htpbe.tech/use-cases/immigration-document-fraud-detection/sponsor-licence-document-fraud-detection" rel="noopener noreferrer"&gt;sponsor licence document fraud page&lt;/a&gt; cover other document types. Share codes and right-to-work checks are covered in &lt;a href="https://htpbe.tech/blog/right-to-work-i9-document-fraud" rel="noopener noreferrer"&gt;right-to-work and I-9 document fraud&lt;/a&gt;. If your team also handles statements outside immigration, see &lt;a href="https://htpbe.tech/blog/how-lenders-verify-bank-statements" rel="noopener noreferrer"&gt;how lenders verify bank statements&lt;/a&gt;, the &lt;a href="https://htpbe.tech/blog/how-to-spot-fake-bank-statement-guide" rel="noopener noreferrer"&gt;guide to spotting an altered bank statement&lt;/a&gt; and the &lt;a href="https://htpbe.tech/use-cases/fake-bank-statement-detection" rel="noopener noreferrer"&gt;bank statement tamper detection use case&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does UKVI contact the bank to check a statement?
&lt;/h3&gt;

&lt;p&gt;It can. The published guidance tells applicants the Home Office may verify financial evidence with the bank, and caseworker guidance describes verification at source when there is reasonable doubt about a document or a risk profile instructs a check. The detailed procedure is redacted from the public versions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is an HTPBE &lt;code&gt;inconclusive&lt;/code&gt; verdict the same as the Home Office’s inconclusive verification outcome?
&lt;/h3&gt;

&lt;p&gt;No. The Home Office’s own verification outcome of inconclusive means its enquiry with the issuer could not settle whether a document is genuine. An HTPBE &lt;code&gt;inconclusive&lt;/code&gt; verdict means the file’s structure cannot settle the question either way: it is neither a finding that the file was edited nor a finding that it was not. It is common on legitimate statements and is not on its own a reason to discount one, but it gives no structural assurance either, so the content review carries the weight.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can a tamper check confirm the funds requirement is met?
&lt;/h3&gt;

&lt;p&gt;No. HTPBE reads the structure of the PDF file, not its figures. It does not check balances, the 28-day holding period, who owns the account or whether the bank issued the statement. Those questions belong to the decision maker and, where it chooses to ask, to the bank.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>fintech</category>
      <category>fraud</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What a Fractional CTO Actually Costs</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 02 Nov 2026 11:01:13 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/what-a-fractional-cto-actually-costs-511f</link>
      <guid>https://dev.to/iurii_rogulia/what-a-fractional-cto-actually-costs-511f</guid>
      <description>&lt;p&gt;Most articles about fractional CTO cost answer the wrong question. They quote a day rate, multiply it by two days a week, and present the annual figure like that settles it. It doesn’t. The number that matters isn’t what you pay – it’s what you’re buying instead, and whether you need it at all yet.&lt;/p&gt;

&lt;p&gt;So let’s do the honest version. What does part-time senior technical leadership actually cost, what does it replace, and when does the math flip to a full-time hire – or to spending nothing at all for another six months?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(This piece assumes you already know what the role covers. If you’re still working that out, &lt;a href="https://iurii.rogulia.fi/blog/what-fractional-cto-does" rel="noopener noreferrer"&gt;start here&lt;/a&gt; first – the numbers below make more sense once the scope is clear.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Prices You’re Choosing Between
&lt;/h2&gt;

&lt;p&gt;When a non-technical founder has technical decisions piling up and no one qualified to make them, there are really three options on the table, and each has a different price.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A full-time CTO.&lt;/strong&gt; The salary depends heavily on country and stage – often somewhere in the €120,000–180,000 range in the EU – plus employer costs, plus equity in many cases, plus the six-month search to find one, plus the risk that the person you hire at that price turns out to be wrong for a company your size. The salary is the smallest part of that sentence. The search and the mis-hire risk are the expensive parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An agency.&lt;/strong&gt; You pay a blended rate covering their salaries, overhead, sales team, and margin. The people who scoped your project are rarely the people who build it, and nobody is accountable to you for the architecture two years from now. Agencies are good at delivering defined work. They’re structurally bad at owning your company’s long-term technical direction, because your company isn’t their company.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A fractional CTO.&lt;/strong&gt; Senior leadership, part-time – typically one to three days a week. You’re paying for judgment, not hours: the architecture calls, the hiring decisions, the vendor evaluations, the ‘don’t build that’ that saves you six weeks. The monthly cost lands well below a full-time salary because you’re buying a fraction of a person’s time, not a fraction of their seniority.&lt;/p&gt;

&lt;p&gt;Notice the trick in that comparison. The fractional CTO isn’t cheaper because they’re less experienced. They’re cheaper because you’re only buying the part you actually need – the senior judgment – without the full-time overhead of a role your stage can’t yet keep busy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Break-Even Actually Sits
&lt;/h2&gt;

&lt;p&gt;The break-even isn’t a spreadsheet line. It’s a question about how much genuine senior decision-making your company generates per week.&lt;/p&gt;

&lt;p&gt;A company making one architecture-shaping decision a month does not need a full-time CTO. At that stage a full-timer spends most of the week inventing work to justify the seat – or worse, over-engineering, building for scale you don’t have, adding complexity that becomes someone’s problem later. You’d be paying a six-figure salary for a person busy 20% of the time on the things only they can do.&lt;/p&gt;

&lt;p&gt;That same company still feels the pain when those monthly decisions get made badly or not at all. That’s the gap a fractional CTO fills. Picture a CEO with a small in-house dev team where nobody internal is senior enough to make the hard architectural calls, and a full-time CTO doesn’t make sense at that stage. Two days a week is enough to untangle a deployment process, sit in on hiring, and help the team push back on a sales feature request that would have cost six weeks. The value isn’t in the hours. It’s in the six weeks that don’t get wasted.&lt;/p&gt;

&lt;p&gt;That’s the real math. If part-time senior judgment prevents one bad hire, kills one unnecessary rewrite, or stops one six-week detour, it has usually paid for a quarter of itself in a single decision. The cost of the engagement is small next to the cost of the mistakes it prevents – and mistakes are exactly what a company accumulates when technical decisions get made by guessing.&lt;/p&gt;

&lt;p&gt;
  slug="fractional-cto"&lt;br&gt;
  text="I hold the fractional CTO seat for founders who have real technical decisions piling up but not enough of them to justify a full-time hire. Part-time senior ownership – architecture, hiring, vendor calls – priced against the mistakes it prevents, not the hours it takes."&lt;br&gt;
/&amp;gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When You Do NOT Need One Yet
&lt;/h2&gt;

&lt;p&gt;Here’s the part the day-rate articles skip, because it costs them a sale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you’re pre-product-market-fit with one strong technical founder, you don’t need a fractional CTO.&lt;/strong&gt; You need to keep building and shipping. A technical founder who can make reasonable architecture calls is not improved by paying someone to second-guess them monthly. Spend the money on runway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you have no engineers and no product yet, you don’t need one either.&lt;/strong&gt; What you need is someone to build the first version – a senior contractor, or a technical co-founder if you can attract one. Hiring a fractional CTO to oversee a team that doesn’t exist is paying for management of nothing. The leadership need becomes real &lt;em&gt;around&lt;/em&gt; the moment you start hiring engineers, not before.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your technical work is genuinely defined and stable&lt;/strong&gt; – adding features to a system that works, maintaining integrations, routine development – a contractor does that better and for less. A fractional CTO’s rate is justified by decisions, not by keystrokes. Paying leadership rates for execution work is the most common way founders overspend on this.&lt;/p&gt;

&lt;p&gt;The pattern: you need a fractional CTO when you have more senior &lt;em&gt;decisions&lt;/em&gt; than senior &lt;em&gt;people to make them&lt;/em&gt; – and not before.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Full-Time Is Actually the Right Spend
&lt;/h2&gt;

&lt;p&gt;And the mirror of that: sometimes the fractional model is the wrong economy, and a full-time hire is genuinely cheaper in the long run.&lt;/p&gt;

&lt;p&gt;The clearest signal is volume. When technical decisions stop being monthly and become daily – when your team is large enough that direction, unblocking, and coordination need someone in the room every day – a fractional CTO embedded a day or two a week starts leaving value on the table. Someone part-time carries less context than a full-timer. Past a certain scale, the decisions made while they’re not in the room cost more than the salary you saved.&lt;/p&gt;

&lt;p&gt;The honest framing: the fractional model is a bridge, not a destination. A common case is a founder who has just raised and needs to double the team fast, but for whom a full-time CTO is a six-month search they don’t have time for. A fractional arrangement covers the gap – sets up the hiring loop, makes the infrastructure call before it becomes a fire – and when a permanent hire lands, the handover takes a week because everything was documented. That’s the model working as intended: it makes itself unnecessary.&lt;/p&gt;

&lt;p&gt;If your company already generates enough senior technical work to keep a full-timer genuinely busy, and you can afford the salary and the search, hire the full-timer. The fractional CTO’s honest job at that point is to help you hire them and hand over cleanly – not to stretch the engagement indefinitely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The One-Line Test
&lt;/h2&gt;

&lt;p&gt;Strip away the rates and the comparison comes down to a single question: &lt;strong&gt;how many decisions that only a senior technical person can make does your company face this month – and does anyone qualified currently make them?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the answer is ‘several, and no one’ – that’s the fractional CTO case, and the cost will look small against the first bad decision it prevents. If it’s ‘constant, and we need someone here every day’ – that’s the full-time case, and part-time is a false economy. If it’s ‘barely any, and our founder handles them fine’ – you don’t need either yet, and anyone telling you otherwise is selling you a title.&lt;/p&gt;

&lt;p&gt;The cost of a fractional CTO is real, but it’s rarely the number that decides this. The number that decides it is what a wrong technical call costs you – and at most stages, that number is a lot bigger than a few days a week of senior judgment.&lt;/p&gt;




&lt;p&gt;If you’re trying to work out whether your situation calls for part-time leadership, a full-time hire, or nothing yet, &lt;a href="https://iurii.rogulia.fi/contact" rel="noopener noreferrer"&gt;let’s talk it through&lt;/a&gt; – I’d rather tell you honestly which one the numbers point to than sell you the one that pays me.&lt;/p&gt;

</description>
      <category>business</category>
      <category>fractionalcto</category>
      <category>hiring</category>
      <category>decision</category>
    </item>
    <item>
      <title>Fake PDF Detector Tools: What They Check and Miss</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 02 Nov 2026 11:00:47 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/fake-pdf-detector-tools-what-they-check-and-miss-o9k</link>
      <guid>https://dev.to/iurii_rogulia/fake-pdf-detector-tools-what-they-check-and-miss-o9k</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/fake-pdf-detector-tools-what-they-miss" 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;You have a PDF in front of you – a bank statement from a tenant, a payslip from a candidate, an invoice from a supplier you have not paid before – and something about it bothers you. So you search for a fake document detector, and you get a page of tools that all claim to answer the same question.&lt;/p&gt;

&lt;p&gt;They don’t. Each tool in that list looks at a different part of the file, and each one misses things another would catch. Picking one without knowing which part it reads is how a document gets ‘checked’ and still gets through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Short answer: what a fake PDF detector actually does
&lt;/h2&gt;

&lt;p&gt;A ‘fake PDF detector’ is not one kind of tool. Some read the metadata fields, some look at the rendered page, some compare the text against expected values, and some examine the internal structure of the file for signs it was changed after it was created. None of them answers ‘is this document genuine?’ on its own – each answers a narrower question, and the useful choice is the one whose question matches what you need to know about the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The six kinds of fake PDF detector
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Kind of tool&lt;/th&gt;
&lt;th&gt;What it inspects&lt;/th&gt;
&lt;th&gt;Good at&lt;/th&gt;
&lt;th&gt;Where it stops&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Metadata viewers&lt;/td&gt;
&lt;td&gt;Document properties: producer, creator, creation and modified dates&lt;/td&gt;
&lt;td&gt;A fast, free look at what the file says about itself&lt;/td&gt;
&lt;td&gt;Shows fields without judging them; fields can be rewritten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Online ‘was this PDF edited’ checkers&lt;/td&gt;
&lt;td&gt;Varies by tool – often metadata, sometimes more&lt;/td&gt;
&lt;td&gt;A fast first answer, nothing to install&lt;/td&gt;
&lt;td&gt;Depth varies; ask what the result is based on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Visual and image-forensics tools&lt;/td&gt;
&lt;td&gt;The rendered page and embedded images&lt;/td&gt;
&lt;td&gt;Spotting visual anomalies in how the page looks&lt;/td&gt;
&lt;td&gt;A cleanly edited PDF can look perfect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OCR and content-consistency checkers&lt;/td&gt;
&lt;td&gt;The text and figures on the page&lt;/td&gt;
&lt;td&gt;Wrong totals, mismatched names, values that don’t add up&lt;/td&gt;
&lt;td&gt;Reads what the page says, not how the file was produced&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual review&lt;/td&gt;
&lt;td&gt;Everything a trained person can see and cross-check&lt;/td&gt;
&lt;td&gt;Context, judgement, calling the issuer&lt;/td&gt;
&lt;td&gt;Slow, inconsistent at volume, does not see how the file was built&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structural forensics&lt;/td&gt;
&lt;td&gt;The PDF’s internal construction and revision record&lt;/td&gt;
&lt;td&gt;Whether the file itself was changed after it was created&lt;/td&gt;
&lt;td&gt;Says nothing about whether the content is true or who issued it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rest of this article takes each row in turn. If you want the step-by-step version – how to do these checks yourself on one file – that is covered in &lt;a href="https://htpbe.tech/blog/how-to-check-if-pdf-has-been-edited" rel="noopener noreferrer"&gt;how to check if a PDF has been edited&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metadata viewers and online edit checkers
&lt;/h2&gt;

&lt;p&gt;A metadata viewer opens the document’s properties and shows you what is there: which application created the file, which one last saved it, when each happened. Our own &lt;a href="https://htpbe.tech/pdf-metadata-viewer" rel="noopener noreferrer"&gt;PDF metadata viewer&lt;/a&gt; is free and does exactly this, and for a quick look it is useful. If a statement that claims to come from a bank’s system lists a home word processor as the application that made it, you have learned something in ten seconds. Reading the two dates properly is a skill of its own – see &lt;a href="https://htpbe.tech/blog/check-pdf-creation-modification-dates" rel="noopener noreferrer"&gt;checking a PDF’s creation and modification dates&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The limit is that a viewer displays fields; it does not judge them. A producer field that says the file came from a bank’s statement system is a claim the file makes about itself, and that claim can be edited like any other text. A viewer will not tell you whether the file was changed after it was created. For more on that distinction, see &lt;a href="https://htpbe.tech/pdf-tamper-detection-vs-metadata-tools" rel="noopener noreferrer"&gt;PDF tamper detection vs metadata tools&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Online ‘was this PDF edited’ checkers sit one step further along: you upload a file and get an answer back. Depth varies between tools. Before you rely on any edited PDF checker, ask one question: is the result drawn from the metadata fields alone, or from the file’s structure as well? That tells you which of the questions in this article it is answering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Visual, AI-image and OCR checkers
&lt;/h2&gt;

&lt;p&gt;These tools work on what the page shows. Image-forensics tools look at the rendered page and its images for visual anomalies. OCR and content-consistency checkers extract the text and figures and test them: does the running balance add up, do the names and dates agree across the document, is the tax consistent with the gross.&lt;/p&gt;

&lt;p&gt;This is the class of tool that reads what the document says, so it is where wrong figures and altered values get caught. Structural analysis cannot answer ‘are these numbers right?’, and neither can a metadata viewer.&lt;/p&gt;

&lt;p&gt;The limit follows from the same fact. A cleanly edited PDF can look perfect and produce consistent figures. A tool that only reads the rendered page has nothing to object to: it tells you how the document looks, not what happened to the file.&lt;/p&gt;

&lt;p&gt;Detecting AI-generated documents is a different question again – whether content was produced by a generative model, not whether an existing file was changed. If that is your concern, we cover what works and what does not in &lt;a href="https://htpbe.tech/blog/detect-ai-generated-pdf" rel="noopener noreferrer"&gt;detecting AI-generated PDFs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Manual review
&lt;/h2&gt;

&lt;p&gt;A person who knows the document type is still the best tool for context. They notice a payslip dated on a Sunday, an employer address that does not match the company register, an account number in the wrong format for that bank. They can phone the issuer. No automated tool does that.&lt;/p&gt;

&lt;p&gt;Manual review has three costs. It is slow. It is inconsistent – two reviewers on the same file, or one reviewer at 9am and at 5pm, will not reach the same conclusion every time. And it does not see how the file was built: a reviewer looking at a well-made edit on screen sees the same perfect page the visual tools see. &lt;a href="https://htpbe.tech/pdf-tamper-detection-vs-manual-review" rel="noopener noreferrer"&gt;PDF tamper detection vs manual review&lt;/a&gt; goes into where each one fits.&lt;/p&gt;

&lt;h2&gt;
  
  
  What structural forensics adds
&lt;/h2&gt;

&lt;p&gt;Every PDF carries a record of how it was put together – its dates, its revisions, its signature state and how its pages were produced. Structural PDF analysis reads that record and asks one question: was this file changed after it was created?&lt;/p&gt;

&lt;p&gt;That is the layer HTPBE covers. You submit a PDF and get one of three verdicts – &lt;code&gt;intact&lt;/code&gt;, &lt;code&gt;modified&lt;/code&gt; or &lt;code&gt;inconclusive&lt;/code&gt; – and, when something was found, a list of named markers describing what it was. At outcome level, the findings look like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dates that disagree&lt;/strong&gt; – the file’s date records do not support the history it presents (&lt;code&gt;HTPBE_DATES_DISAGREE&lt;/code&gt; is one of the public marker IDs).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edits after signing&lt;/strong&gt; – content changed after the document was digitally signed (&lt;code&gt;HTPBE_POST_SIGNATURE_EDIT&lt;/code&gt;), or a signature stripped after signing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pages from multiple sources&lt;/strong&gt; – pages assembled from independently produced documents (&lt;code&gt;HTPBE_PAGES_FROM_MULTIPLE_SOURCES&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editing-tool traces&lt;/strong&gt; – fingerprints of a known PDF editor, or the identity of an online editing or conversion service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revision history&lt;/strong&gt; – the document re-saved after its original creation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full list of public markers and what each one means is on &lt;a href="https://htpbe.tech/how" rel="noopener noreferrer"&gt;how HTPBE works&lt;/a&gt;. The &lt;a href="https://htpbe.tech/pdf-tamper-detection" rel="noopener noreferrer"&gt;PDF tamper detection&lt;/a&gt; page covers the approach in more depth, and &lt;a href="https://htpbe.tech/blog/how-to-view-pdf-edit-history" rel="noopener noreferrer"&gt;viewing a PDF’s edit history&lt;/a&gt; explains the revision record itself.&lt;/p&gt;

&lt;p&gt;What structure adds is evidence the other classes do not look at. A clean visual edit is still an edit, and the file often records that it happened even when the page looks untouched.&lt;/p&gt;

&lt;p&gt;If you have one file and want to see this in practice, start with &lt;a href="https://htpbe.tech/use-cases/check-if-pdf-was-edited" rel="noopener noreferrer"&gt;checking whether a PDF was edited&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What no PDF detector proves, including ours
&lt;/h2&gt;

&lt;p&gt;Be clear about the boundary before you build a decision on any of these tools.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;intact&lt;/code&gt; does not mean authentic.&lt;/strong&gt; It means the structural analysis found no evidence the file was changed after it was created. It does not mean the content is true, and it does not mean the document was issued by the party named on it. HTPBE detects modification of the file – not whether what the file says is correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTPBE does not read the content.&lt;/strong&gt; It does not run OCR, check arithmetic, compare names or validate figures. That is the job of the content-checking class above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is not identity or issuer verification.&lt;/strong&gt; It does not confirm who someone is, check a document against an issuer’s records, or perform KYC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;inconclusive&lt;/code&gt; is neither a pass nor proof of fraud.&lt;/strong&gt; Usually it means the file came from a source – ordinary consumer software, a scan and similar – where a structural check cannot confirm integrity either way; occasionally it means the analysis could not finish, in which case treat the file as unchecked rather than clean. Judge it against the origin the document claims: a statement presented as coming from a bank’s system that returns &lt;code&gt;inconclusive&lt;/code&gt; needs a closer look through your other checks, and the safe next step is to request the original from the issuer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No verdict is a decision.&lt;/strong&gt; A &lt;code&gt;modified&lt;/code&gt; result is a reason to look more closely or ask questions, not a reason to reject an applicant automatically. Route it to a person.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to choose: a decision guide
&lt;/h2&gt;

&lt;p&gt;Start from what you are trying to find out, and how many files you handle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One file, one decision.&lt;/strong&gt; Open it in a metadata viewer for a first look. Run a structural check to see whether the file was changed after it was created. Read the content yourself against what you already know – names, dates, amounts. If the stakes are high, contact the issuer through a channel you find independently, not one printed on the document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recurring volume&lt;/strong&gt; – a lettings agent screening tenants every week, an HR team checking candidate documents, a small lender reviewing applications. Manual review alone stops scaling, and its inconsistency starts to matter. Put a structural check on every incoming file so reviewers know which ones to open first – files that come back &lt;code&gt;intact&lt;/code&gt; still get your normal content review, because the check sets priority, it does not approve anything. Keep content checks for the figures that drive the decision and issuer contact for the cases that warrant it. If you are looking for a &lt;a href="https://htpbe.tech/use-cases/detect-fake-documents" rel="noopener noreferrer"&gt;fake document detector&lt;/a&gt; for a specific document type, the use-case pages show what the check covers for each.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integration into your own system.&lt;/strong&gt; If documents arrive through an application form or an upload flow, the check belongs in the pipeline, not in someone’s browser. With the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;HTPBE API&lt;/a&gt; you submit a PDF URL and fetch the verdict and named markers as JSON, so &lt;code&gt;modified&lt;/code&gt; and &lt;code&gt;inconclusive&lt;/code&gt; files land in the review queue automatically, alongside the content checks every file still gets.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Is there a free fake PDF detector?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Several tools are free for a quick look, including our &lt;a href="https://htpbe.tech/pdf-metadata-viewer" rel="noopener noreferrer"&gt;PDF metadata viewer&lt;/a&gt;. For structural analysis, an HTPBE account comes with 5 free credits at signup – one credit per check – so you can run a few files before paying anything. After that, checks are pay-per-check: $5 for 8 credits or $15 for 25. See &lt;a href="https://htpbe.tech/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can a PDF edit checker tell me what was changed?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not the specific words or numbers. A structural check tells you whether the file was modified and, if so, what kind of evidence was found – dates that disagree, an edit after signing, pages from different sources. To see which values on the page are wrong, you need to read the content against a trusted source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does a PDF integrity checker prove a document is genuine?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. An integrity check speaks to the file – whether there is evidence it was changed after it was created – not to the facts in it or who issued it. For high-stakes documents, combine a structural check with content review and contact with the issuer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I run a PDF fraud checker automatically on every document?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, through an API. You submit a PDF URL to the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;HTPBE API&lt;/a&gt; and fetch a verdict with named markers. Subscriptions start at $15 a month. Treat the result as input to your review queue, not as an automatic accept or reject.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go from here
&lt;/h2&gt;

&lt;p&gt;If you are deciding whether to build this layer in-house or use a service, &lt;a href="https://htpbe.tech/blog/build-vs-buy-pdf-tamper-detection" rel="noopener noreferrer"&gt;build vs buy PDF tamper detection&lt;/a&gt; works through the trade-offs. If you have a file to look at now, &lt;a href="https://htpbe.tech/auth/signup" rel="noopener noreferrer"&gt;create an account&lt;/a&gt; and use the 5 welcome credits on it. If documents reach you through your own system, start with the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;API&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What to put around any detector once you have chosen one – source confirmation, callbacks, retention and escalation – is set out in &lt;a href="https://htpbe.tech/blog/pdf-fraud-prevention-best-practices" rel="noopener noreferrer"&gt;PDF fraud prevention: a layered playbook&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>security</category>
      <category>fraud</category>
      <category>webdev</category>
    </item>
    <item>
      <title>When a B2B Customer Won't Give You a Valid VAT Number</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 02 Nov 2026 10:00:52 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/when-a-b2b-customer-wont-give-you-a-valid-vat-number-1ceb</link>
      <guid>https://dev.to/iurii_rogulia/when-a-b2b-customer-wont-give-you-a-valid-vat-number-1ceb</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/customer-wont-provide-valid-vat-number" rel="noopener noreferrer"&gt;vatnode.dev&lt;/a&gt;. The version on vatnode.dev is the canonical source — refer to it for the latest content.&lt;/p&gt;

&lt;p&gt;This is general information about how EU VAT rules operate. It is not tax or legal advice, and it&lt;br&gt;
  isn’t individualized to your facts. If a specific invoice is on the line, talk to a qualified&lt;br&gt;
  adviser in the relevant jurisdiction.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Short answer: none of the three outcomes stops you from invoicing. No number and a confirmed-invalid number both mean the cross-border exemption or reverse-charge treatment isn’t available right now, so you charge VAT as you would to any customer without one – the exact rate and place of supply depend on whether you’re selling goods or services. An unavailable check is different again: it’s not a negative result, so retry it, record the attempt, and either hold the invoice or charge VAT and correct it once the number is confirmed.&lt;/p&gt;

&lt;p&gt;This post covers B2B customers established in another EU member state. A customer in your own country is a domestic sale either way – neither the cross-border exemption nor the standard reverse-charge rules apply to it, though some countries run their own domestic reverse-charge regimes for specific sectors. Check that separately if it’s relevant to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three situations, and they’re not the same problem
&lt;/h2&gt;

&lt;h3&gt;
  
  
  No VAT number given at all
&lt;/h3&gt;

&lt;p&gt;Some customers genuinely don’t have one – they’re below their own country’s VAT registration threshold, or they’re a sole trader who hasn’t registered. Others just haven’t filled in the field yet. Either way, you have nothing to verify, so there’s nothing to zero-rate against.&lt;/p&gt;

&lt;h3&gt;
  
  
  A number was given, but it doesn’t check out
&lt;/h3&gt;

&lt;p&gt;The string exists, but it fails when you &lt;a href="https://vatnode.dev/check" rel="noopener noreferrer"&gt;run a VAT check&lt;/a&gt; against it. Several things can produce ‘invalid’: the number was never registered, the business hasn’t completed the separate intra-EU registration some member states require, or the registration lapsed. One of the more common causes is a number that’s valid domestically but not activated for intra-EU transactions – VIES reports it as invalid even though the business is VAT-registered at home. &lt;a href="https://vatnode.dev/blog/vies-invalid-domestic-only-registration" rel="noopener noreferrer"&gt;VIES invalid for a domestic-only registration&lt;/a&gt; covers how that cause plays out and what to tell the customer. And don’t confuse ‘invalid’ with ‘badly formatted’ – a string that merely fails the pattern check is a different, earlier problem; see &lt;a href="https://vatnode.dev/blog/vat-id-format-vs-valid" rel="noopener noreferrer"&gt;format-valid vs. actually registered&lt;/a&gt; for that distinction.&lt;/p&gt;

&lt;h3&gt;
  
  
  VIES or a member-state service can’t confirm it right now
&lt;/h3&gt;

&lt;p&gt;This one gets mistaken for invalid constantly. An unavailable check means the lookup didn’t complete – not that the number failed. Retry it, and keep a record of the attempt and its timestamp regardless of outcome. &lt;a href="https://vatnode.dev/blog/vies-downtime-guide" rel="noopener noreferrer"&gt;Handling VIES downtime&lt;/a&gt; and the &lt;a href="https://vatnode.dev/docs/errors" rel="noopener noreferrer"&gt;error reference&lt;/a&gt; show what the response looks like in that case, so you can tell it apart from a genuine negative in code rather than by eyeballing a dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  What EU rules actually let you assume
&lt;/h2&gt;

&lt;p&gt;There’s no general EU obligation to collect a VAT number from every B2B customer. The rules describe what you can rely on once you do have one – or once you’ve established you don’t.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;services&lt;/strong&gt;, &lt;a href="https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:02011R0282-20200101" rel="noopener noreferrer"&gt;Implementing Regulation 282/2011 Article 18&lt;/a&gt; sets out two routes. Under Article 18(1)(a), unless you have information suggesting otherwise, you may treat an EU customer as a taxable person once they’ve given you a VAT ID and you’ve obtained confirmation – per Article 31 of Regulation (EU) No 904/2010 – of both its validity and the associated name and address. Under Article 18(1)(b), a customer who says they’ve applied for a number but doesn’t have one yet can still be treated as a taxable person if you have other proof of that and you’ve done reasonable verification. And under Article 18(2), unless you have information to the contrary, you may treat a customer as non-taxable once you can show they haven’t given you a VAT ID at all. Note the word ‘may’ throughout – this is a permission and a presumption you’re allowed to rely on, not a mandatory checklist with one correct outcome.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;goods&lt;/strong&gt;, the relevant condition sits in &lt;a href="https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:02006L0112-20240101" rel="noopener noreferrer"&gt;Directive 2006/112/EC Article 138(1)&lt;/a&gt;, as amended by Directive (EU) 2018/1910 from 1 January 2020: the exemption requires the customer to be identified for VAT in another member state and to have given you that VAT ID, plus a correct recapitulative statement under Article 138(1a). It’s one condition among several – transport evidence is the other half – but if no qualifying number is indicated to you (or only a number from the dispatch country), the exemption doesn’t apply and you must charge VAT.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to charge instead
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Goods.&lt;/strong&gt; No valid VAT ID means the Article 138 exemption isn’t available, but that doesn’t automatically make it a domestic-rate sale. If the customer is a genuine taxable person who just didn’t share a number, VAT is normally due in the country where transport begins, at your own rate. If the customer is actually non-taxable for VAT purposes – a non-taxable legal person, or a business below its own country’s acquisition threshold under Article 3(1) – you’re making an intra-Community distance sale under Articles 14(4) and 33(a): VAT is due where the goods end up once you’re over the same combined €10,000 Article 59c threshold described below, usually accounted through the One Stop Shop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Services.&lt;/strong&gt; Don’t flatten this to ‘charge your own rate.’ The general rule (Article 45) puts the place of supply where you’re established, so for most B2B services without a valid customer VAT ID, you’d apply your own country’s VAT. But most SaaS falls under the specific rule for telecom, broadcasting, and electronically supplied services (Article 58), which puts the place of supply where the customer is, once you’re over the combined EU-wide €10,000 threshold in Article 59c for suppliers established in only one member state. That threshold counts these cross-border service sales together with any intra-EU distance sales of goods you also make. Below that threshold you can still apply your own domestic VAT; above it, VAT for other member states is usually accounted through the One Stop Shop rather than registering separately in each one. A handful of other services have their own place-of-supply rules too. If you’re not sure which rule a service falls under, confirm it before you decide what to charge: the wrong assumption here is a recurring source of mispriced invoices, not just a paperwork gap. When reverse charge applies and how it’s invoiced is covered in the &lt;a href="https://vatnode.dev/guides/vat-reverse-charge" rel="noopener noreferrer"&gt;reverse-charge concept guide&lt;/a&gt; and &lt;a href="https://vatnode.dev/reverse-charge" rel="noopener noreferrer"&gt;by-country invoice wording&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In both cases this isn’t a one-off decision per customer. Know &lt;a href="https://vatnode.dev/blog/when-to-validate-customer-vat-numbers" rel="noopener noreferrer"&gt;when to re-check a customer’s VAT number&lt;/a&gt;, so a number that starts invalid or missing doesn’t stay that way in your records after it’s been fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to tell the customer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No number at all.&lt;/strong&gt; Tell them plainly that without a VAT ID you’ll charge VAT as you would for any customer without one, and that you’ll switch the treatment as soon as they provide a number that checks out. If they’re a sole trader, their VAT number can be built from a personal national identifier in some member states – reluctance to hand it over in a support chat might be a privacy concern, not an attempt to dodge tax. The same applies on your side: a sole trader’s VAT number, name and address are personal data, so keep them in your billing records where you need them, not in support transcripts, debug logs or analytics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invalid number.&lt;/strong&gt; Say exactly what failed – the number didn’t come back confirmed – and ask them to double-check it with whoever handles their VAT registration, since a typo and an unregistered number look identical from your side. Don’t promise to retroactively fix the invoice until you actually have a number that checks out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check unavailable.&lt;/strong&gt; Tell them the check didn’t complete, not that their number is wrong – that distinction matters to the finance team reading your email. Let them know you’ll retry, and that the failed lookup says nothing yet about their registration.&lt;/p&gt;

&lt;p&gt;If it later turns out VAT shouldn’t have been charged, the usual fix runs through you, not the customer’s tax authority: you correct the invoice and refund the VAT, then adjust your own return under your national rules. The cross-border refund procedure under Directive 2008/9/EC covers VAT that was correctly charged and is deductible, not VAT invoiced in error. If a customer pushes back, tell them how you correct invoices, and don’t promise a refund outcome on their side.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to record
&lt;/h2&gt;

&lt;p&gt;Keep the same three things for every outcome: what you asked for, what came back, and when. A definitive invalid result, a definitive ‘no number given,’ and a transient unavailable result all look different in an audit, and conflating them later is how a routine outage turns into a disputed invoice years on. &lt;a href="https://vatnode.dev/blog/what-auditors-look-for-vat-documentation" rel="noopener noreferrer"&gt;What auditors look for in EU VAT documentation&lt;/a&gt; covers the fuller checklist; if you’re set up to run requester-qualified checks, the consultation number VIES issues on those is the strongest form of that evidence – see &lt;a href="https://vatnode.dev/blog/consultation-number-for-finance" rel="noopener noreferrer"&gt;the VIES consultation number for finance teams&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;vatnode’s &lt;code&gt;GET /v1/vat/:vatId&lt;/code&gt; on this &lt;a href="https://vatnode.dev/vat-api" rel="noopener noreferrer"&gt;EU VAT validation API&lt;/a&gt; returns &lt;code&gt;valid&lt;/code&gt; as true or false along with &lt;code&gt;source&lt;/code&gt;, a timestamp, and a check ID on every answered check. An outage is never reported as a silent ‘invalid’: either a national source answers instead, and &lt;code&gt;source&lt;/code&gt; says so, or the call comes back as an error such as &lt;code&gt;VIES_UNAVAILABLE&lt;/code&gt;. A national-source answer isn’t the same as a VIES confirmation, though. It reflects that country’s own register, which may not show whether the number is activated for intra-EU transactions, so check &lt;code&gt;source&lt;/code&gt; before relying on a &lt;code&gt;valid: true&lt;/code&gt; for the exemption or the reverse charge. The three outcomes above are distinguishable in the response itself, not just on a dashboard. The consultation number is included once your account has an EU VAT number set in dashboard Account details and VIES answers live.&lt;/p&gt;

&lt;h2&gt;
  
  
  If the number arrives later
&lt;/h2&gt;

&lt;p&gt;If a customer sends a valid number after you’ve already invoiced them without one, there’s no EU-wide deadline or procedure forcing a fix within a set window. Article 219 treats a document that amends and clearly references the original invoice – a credit note plus a corrected one – as an invoice in its own right, so that’s the usual mechanism. Whether you do it, and how, is a matter of national law and your own process. A number arriving later doesn’t by itself mean the original invoice was wrong. If you applied Article 18(2) correctly, or charged VAT on the information you had and had no information to the contrary, you were relying on a presumption the rules expressly allow. Zero-rating on a number that later turns out to have been invalid is a different, higher-stakes problem – covered in &lt;a href="https://vatnode.dev/blog/reverse-charge-invalid-vat-number-risk" rel="noopener noreferrer"&gt;what happens when you reverse-charge an invalid VAT number&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  A customer has no VAT number at all – what do I charge?
&lt;/h3&gt;

&lt;p&gt;For services, Implementing Regulation 282/2011 Article 18(2) lets you treat them as a non-taxable person when you can show they haven’t communicated a VAT ID, and charge accordingly. For most SaaS that means the place-of-supply rule for electronically supplied services: your own country’s VAT below the EU-wide €10,000 threshold for suppliers established in one member state, the customer’s country’s VAT above it. For goods, the Article 138 exemption needs a VAT ID the customer gave you; without one, it doesn’t apply. VAT is then normally due where transport begins if the customer is a genuine taxable person who just withheld the number. If they’re non-taxable or below their own acquisition threshold, it’s an intra-Community distance sale, taxed at destination above the combined €10,000 threshold. This is general information, not tax advice for a specific transaction.&lt;/p&gt;

&lt;h3&gt;
  
  
  The customer gave a number but VIES says it’s invalid – can I still zero-rate?
&lt;/h3&gt;

&lt;p&gt;No, not on this number. The goods exemption (Article 138), and the Article 18 customer-status presumption that lets you apply reverse charge under Article 196 instead of your own VAT, both depend on a VAT ID that actually checks out. For services the treatment is reverse charge rather than a zero-rated line, because the place of supply moves to the customer’s country. An invalid result means that condition isn’t met at this time, regardless of why – unregistered, not yet activated for intra-EU transactions, or simply wrong. Ask for a correction or fall back to charging VAT; don’t treat an invalid number as a formality to wave through. This is general information, not tax advice for a specific transaction.&lt;/p&gt;

&lt;h3&gt;
  
  
  VIES is down and won’t confirm the number – is that the same as invalid?
&lt;/h3&gt;

&lt;p&gt;No. An unavailable check is a different outcome from invalid. The number may be perfectly valid; the lookup just didn’t complete. Retry, and keep a record of when you tried and what came back. Until the number is confirmed you can’t rely on it for the exemption or the reverse charge, so either hold the invoice until a retry succeeds – within the Article 222 deadline for exempt and reverse-charge supplies, the 15th of the month after the chargeable event – or charge VAT now and correct it once the number is confirmed. If you’ve already been paid for services, Article 65 can make VAT chargeable on receipt, so holding the document alone may not move the tax point. Don’t record the outage as an invalid result.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I have to ask every customer for a VAT number before I can invoice them?
&lt;/h3&gt;

&lt;p&gt;No EU-wide rule requires it. Asking is how you access the exemption or the reverse-charge treatment where it applies; a customer who doesn’t have or won’t give a number is simply treated under the rules for a customer without one. Not providing one doesn’t stop you invoicing them.&lt;/p&gt;

&lt;h3&gt;
  
  
  If a valid number arrives after I’ve already invoiced without one, do I have to redo the invoice?
&lt;/h3&gt;

&lt;p&gt;There’s no EU-wide procedure or deadline for this. A document that amends and clearly references the original invoice is itself treated as an invoice under Article 219, so a credit note plus a corrected one is the usual route – but whether and how to reissue is a matter of national law and your own process, not a single EU rule.&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  Tell the three outcomes apart in code, not by eye
&lt;/h3&gt;

&lt;p&gt;vatnode returns a definitive &lt;code&gt;valid: true&lt;/code&gt;/&lt;code&gt;false&lt;/code&gt; on every answered check, with the source that answered, and never turns an outage into a silent ‘invalid.’ Set your EU VAT number once in dashboard Account details and checks VIES answers live also carry a consultation number. Free plan, 100 requests/month, no card.&lt;/p&gt;

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

</description>
      <category>tax</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>PDF Tamper Detection in Rust: Integration Guide</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Sun, 01 Nov 2026 11:00:46 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/pdf-tamper-detection-in-rust-integration-guide-2e8o</link>
      <guid>https://dev.to/iurii_rogulia/pdf-tamper-detection-in-rust-integration-guide-2e8o</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/pdf-verification-rust-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;This guide covers one narrow job: checking whether a PDF file was structurally modified after it was created, from a Rust service, before your business logic trusts it. It is not KYC, not identity or issuer verification, not OCR and not AI-generated-content detection. The check looks at the file’s structure and metadata, says whether that structure shows evidence of post-creation editing, and leaves the person who sent it entirely out of the answer.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;PDF tamper detection API&lt;/a&gt; is a small REST surface: one &lt;code&gt;POST&lt;/code&gt; to submit a URL, one &lt;code&gt;GET&lt;/code&gt; to read the verdict. There is no official Rust SDK, so this guide builds a client by hand against the REST contract with &lt;code&gt;reqwest&lt;/code&gt;, &lt;code&gt;serde&lt;/code&gt;, &lt;code&gt;thiserror&lt;/code&gt; and &lt;code&gt;tokio&lt;/code&gt;: result types, an error enum that knows what is worth retrying, idempotent retries, an axum handler that routes each verdict, and a presigned-URL helper for private buckets.&lt;/p&gt;

&lt;p&gt;If you want the concepts first, read &lt;a href="https://htpbe.tech/blog/detect-pdf-tampering-programmatically" rel="noopener noreferrer"&gt;How to Detect PDF Tampering Programmatically&lt;/a&gt;. The same integration exists for &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;, &lt;a href="https://htpbe.tech/blog/pdf-verification-java-spring-boot-integration-guide" rel="noopener noreferrer"&gt;Java / Spring Boot&lt;/a&gt;, &lt;a href="https://htpbe.tech/blog/pdf-verification-csharp-dotnet-integration-guide" rel="noopener noreferrer"&gt;C#&lt;/a&gt;, &lt;a href="https://htpbe.tech/blog/pdf-verification-laravel-php-integration-guide" rel="noopener noreferrer"&gt;Laravel / PHP&lt;/a&gt; and &lt;a href="https://htpbe.tech/blog/pdf-verification-ruby-on-rails-integration-guide" rel="noopener noreferrer"&gt;Ruby on Rails&lt;/a&gt;.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Two calls: &lt;code&gt;POST /analyze&lt;/code&gt; takes a PDF URL and returns &lt;code&gt;201&lt;/code&gt; with a check &lt;code&gt;id&lt;/code&gt;; &lt;code&gt;GET /result/{id}&lt;/code&gt; returns a flat JSON object whose &lt;code&gt;status&lt;/code&gt; is &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;Analysis is synchronous. When the &lt;code&gt;POST&lt;/code&gt; returns, the result is ready to read.&lt;/li&gt;
&lt;li&gt;Send an &lt;code&gt;Idempotency-Key&lt;/code&gt; on every &lt;code&gt;POST&lt;/code&gt; and reuse it on every retry of that request. A retry of a completed request returns the same &lt;code&gt;id&lt;/code&gt; and is not charged again.&lt;/li&gt;
&lt;li&gt;Retry &lt;code&gt;409&lt;/code&gt;, &lt;code&gt;429&lt;/code&gt;, &lt;code&gt;500&lt;/code&gt;, &lt;code&gt;502&lt;/code&gt;, &lt;code&gt;504&lt;/code&gt; and transport failures with the same key. Never retry &lt;code&gt;400&lt;/code&gt;, &lt;code&gt;401&lt;/code&gt;, &lt;code&gt;402&lt;/code&gt;, &lt;code&gt;403&lt;/code&gt;, &lt;code&gt;404&lt;/code&gt;, &lt;code&gt;413&lt;/code&gt; or &lt;code&gt;422&lt;/code&gt;; a &lt;code&gt;503&lt;/code&gt; surfaces as a pending check to retry once later.&lt;/li&gt;
&lt;li&gt;Route on &lt;code&gt;usage_caution.recommended_action&lt;/code&gt;. No branch in this guide auto-declines a person: the API marks every verdict &lt;code&gt;safe_for_automated_adverse_decision: false&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A structural verdict is about the file. &lt;code&gt;intact&lt;/code&gt; means no structural modification was detected, not that the document is genuine or its contents are true.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A recent stable Rust toolchain.&lt;/li&gt;
&lt;li&gt;An HTPBE account. Every account has a test key (&lt;code&gt;htpbe_test_...&lt;/code&gt;) on the API keys page of the dashboard; live keys start with &lt;code&gt;htpbe_live_...&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;These dependencies, at the crate versions the listings below target:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="k"&gt;[&lt;/span&gt;&lt;span class="n"&gt;dependencies&lt;/span&gt;&lt;span class="k"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;reqwest&lt;/span&gt; &lt;span class="o"&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="n"&gt;version&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="s"&gt;"0.13"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;features&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="s"&gt;"json"&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="n"&gt;tokio&lt;/span&gt; &lt;span class="o"&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="n"&gt;version&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="s"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;features&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="s"&gt;"macros"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"rt-multi-thread"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"net"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"time"&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="n"&gt;serde&lt;/span&gt; &lt;span class="o"&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="n"&gt;version&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="s"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;features&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="s"&gt;"derive"&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="n"&gt;serde_json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;
&lt;span class="n"&gt;thiserror&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"2"&lt;/span&gt;
&lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="o"&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="n"&gt;version&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="s"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;features&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="s"&gt;"v4"&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="c"&gt;# Step 5 (HTTP handler) and Step 6 (presigned URLs) only&lt;/span&gt;
&lt;span class="n"&gt;axum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"0.8"&lt;/span&gt;
&lt;span class="n"&gt;aws-config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;
&lt;span class="n"&gt;aws-sdk-s3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;json&lt;/code&gt; is an opt-in reqwest feature, so it has to be listed. In reqwest 0.13, &lt;code&gt;query&lt;/code&gt; also became a separate feature that is off by default, so this guide builds query strings by hand rather than calling &lt;code&gt;.query()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The listings assume four modules under &lt;code&gt;src/htpbe/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/htpbe/mod.rs&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;routing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;src/main.rs&lt;/code&gt; declares &lt;code&gt;mod htpbe;&lt;/code&gt; (Step 5). Add &lt;code&gt;mod upload;&lt;/code&gt; if you use the Step 6 helper.&lt;/p&gt;

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

&lt;p&gt;Before any Rust, confirm the key and the flow with curl, using your test key and a fixed test URL (test keys accept only those and never consume credits).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Submit the file:&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;-i&lt;/span&gt; &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 htpbe_test_YOUR_TEST_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Idempotency-Key: 7c2f6b6e-1a3d-4b8a-9c3a-2e6f1d9b4a10"&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/signature-removed.pdf"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is &lt;code&gt;201 Created&lt;/code&gt; with a &lt;code&gt;Location&lt;/code&gt; header pointing at the result URL and a body that contains only the id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"00000000-0000-4000-8000-00000000000c"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Read 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/00000000-0000-4000-8000-00000000000c &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer htpbe_test_YOUR_TEST_KEY"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is one flat object. Trimmed to the fields this guide uses, it looks 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;"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-00000000000c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"filename"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"signature-removed.pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"check_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"modified"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"modification_confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"certain"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"modification_markers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"HTPBE_SIGNATURE_REMOVED"&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;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"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;"usage_caution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"safe_for_automated_adverse_decision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"recommended_action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"route_to_human_review"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Structural evidence of post-creation modification was found. Route this document to a human reviewer before any decision — do not use this verdict as the sole basis for an automatic adverse decision against a person."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;check_date&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt; for test results. Swap the URL for &lt;code&gt;clean.pdf&lt;/code&gt;, &lt;code&gt;dates-mismatch.pdf&lt;/code&gt;, &lt;code&gt;inconclusive.pdf&lt;/code&gt; or &lt;code&gt;scanned-document.pdf&lt;/code&gt; to see the other shapes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Result types with serde
&lt;/h2&gt;

&lt;p&gt;serde skips JSON fields your struct doesn’t declare (so don’t add &lt;code&gt;deny_unknown_fields&lt;/code&gt;): the struct can be partial, and a field added to the API later can’t break decoding. Each enum has a &lt;code&gt;#[serde(other)]&lt;/code&gt; catch-all, so a value added after you ship decodes as &lt;code&gt;Unknown&lt;/code&gt;, and Step 5 sends &lt;code&gt;Unknown&lt;/code&gt; to a person.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/htpbe/types.rs&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;serde&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Serialize&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone,&lt;/span&gt; &lt;span class="nd"&gt;Copy,&lt;/span&gt; &lt;span class="nd"&gt;PartialEq,&lt;/span&gt; &lt;span class="nd"&gt;Eq,&lt;/span&gt; &lt;span class="nd"&gt;Deserialize)]&lt;/span&gt;
&lt;span class="nd"&gt;#[serde(rename_all&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"snake_case"&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&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;Intact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Modified&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Inconclusive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;#[serde(other)]&lt;/span&gt;
    &lt;span class="n"&gt;Unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cd"&gt;/// Present only when `status` is `inconclusive`. It explains why the&lt;/span&gt;
&lt;span class="cd"&gt;/// integrity check could not reach `intact` or `modified`.&lt;/span&gt;
&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone,&lt;/span&gt; &lt;span class="nd"&gt;Copy,&lt;/span&gt; &lt;span class="nd"&gt;PartialEq,&lt;/span&gt; &lt;span class="nd"&gt;Eq,&lt;/span&gt; &lt;span class="nd"&gt;Serialize,&lt;/span&gt; &lt;span class="nd"&gt;Deserialize)]&lt;/span&gt;
&lt;span class="nd"&gt;#[serde(rename_all&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"snake_case"&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;StatusReason&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ConsumerSoftwareOrigin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;OnlineEditorOrigin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ScannedDocument&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;UnverifiableMetadata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;FilledFormOrigin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;FillSignOrigin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;VisibleSignatureOrigin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="cd"&gt;/// Describes the check, not the document: part of the analysis did not finish.&lt;/span&gt;
    &lt;span class="n"&gt;AnalysisIncomplete&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;#[serde(other)]&lt;/span&gt;
    &lt;span class="n"&gt;Unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone,&lt;/span&gt; &lt;span class="nd"&gt;Copy,&lt;/span&gt; &lt;span class="nd"&gt;PartialEq,&lt;/span&gt; &lt;span class="nd"&gt;Eq,&lt;/span&gt; &lt;span class="nd"&gt;Serialize,&lt;/span&gt; &lt;span class="nd"&gt;Deserialize)]&lt;/span&gt;
&lt;span class="nd"&gt;#[serde(rename_all&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"snake_case"&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;Confidence&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Certain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;High&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="cd"&gt;/// The wire value is "none". Renamed so it doesn't read like `Option::None`.&lt;/span&gt;
    &lt;span class="nd"&gt;#[serde(rename&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"none"&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
    &lt;span class="n"&gt;NoneDetected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;#[serde(other)]&lt;/span&gt;
    &lt;span class="n"&gt;Unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone,&lt;/span&gt; &lt;span class="nd"&gt;Copy,&lt;/span&gt; &lt;span class="nd"&gt;PartialEq,&lt;/span&gt; &lt;span class="nd"&gt;Eq,&lt;/span&gt; &lt;span class="nd"&gt;Deserialize)]&lt;/span&gt;
&lt;span class="nd"&gt;#[serde(rename_all&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"snake_case"&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;RecommendedAction&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;RouteToHumanReview&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RequestFromIssuer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;NoAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;#[serde(other)]&lt;/span&gt;
    &lt;span class="n"&gt;Unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone,&lt;/span&gt; &lt;span class="nd"&gt;Deserialize)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;UsageCaution&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/// Always false. The verdict alone must never drive an adverse decision.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;safe_for_automated_adverse_decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;recommended_action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RecommendedAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cd"&gt;/// The flat GET /result/{id} response. Partial on purpose: serde skips&lt;/span&gt;
&lt;span class="cd"&gt;/// any field not declared here, so new API fields never break decoding.&lt;/span&gt;
&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Clone,&lt;/span&gt; &lt;span class="nd"&gt;Deserialize)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;CheckResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="cd"&gt;/// Unix seconds. Null for test-key results and legacy records.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;check_date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;i64&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;pub&lt;/span&gt; &lt;span class="n"&gt;file_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;algorithm_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;current_algorithm_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="cd"&gt;/// Present only when the check ran on an older algorithm version.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;outdated_warning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&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;pub&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;status_reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;StatusReason&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="cd"&gt;/// Null on legacy records.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;modification_confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Confidence&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="cd"&gt;/// Public HTPBE_* ids, strongest first.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;modification_markers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&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;pub&lt;/span&gt; &lt;span class="n"&gt;has_digital_signature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;signature_removed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;modifications_after_signature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;page_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;usage_caution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;UsageCaution&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Markers stay &lt;code&gt;Vec&amp;lt;String&amp;gt;&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;HTPBE_*&lt;/code&gt; ids are append-only, so an enum would break on the first new one. Branch on the ids you care about and render labels from the dictionary on &lt;a href="https://htpbe.tech/how" rel="noopener noreferrer"&gt;how PDF tamper detection works&lt;/a&gt;; the first marker is the strongest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;outdated_warning&lt;/code&gt; is worth logging:&lt;/strong&gt; it means the stored check ran on an older algorithm version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: A typed error enum
&lt;/h2&gt;

&lt;p&gt;The retry loop needs to know whether the same request could succeed if sent again; the handler needs to know what to tell the caller. One enum serves both if it keeps the HTTP status, the &lt;code&gt;code&lt;/code&gt; and &lt;code&gt;Retry-After&lt;/code&gt;. The API’s error body is &lt;code&gt;{"error": "...", "code": "...", "details": "..."}&lt;/code&gt;, but a proxy in front of it can answer with HTML, so parsing is best effort.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/htpbe/error.rs&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;time&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;thiserror::Error)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;HtpbeError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/// A non-2xx response from the API (or from something in front of it).&lt;/span&gt;
    &lt;span class="nd"&gt;#[error(&lt;/span&gt;&lt;span class="s"&gt;"HTPBE API returned {status} ({code}): {message}"&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
    &lt;span class="n"&gt;Api&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="cd"&gt;/// Machine-readable code, e.g. "payment_required". "unknown" when&lt;/span&gt;
        &lt;span class="cd"&gt;/// the body was not the API's JSON error shape.&lt;/span&gt;
        &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="cd"&gt;/// Parsed from Retry-After (seconds) when the response carried it.&lt;/span&gt;
        &lt;span class="n"&gt;retry_after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;

    &lt;span class="cd"&gt;/// Connect failure, timeout, reset connection, unreadable body.&lt;/span&gt;
    &lt;span class="nd"&gt;#[error(&lt;/span&gt;&lt;span class="s"&gt;"transport error: {0}"&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
    &lt;span class="nf"&gt;Transport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;#[from]&lt;/span&gt; &lt;span class="nn"&gt;reqwest&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="cd"&gt;/// A 2xx response whose body did not match the documented shape.&lt;/span&gt;
    &lt;span class="nd"&gt;#[error(&lt;/span&gt;&lt;span class="s"&gt;"unexpected response body: {0}"&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
    &lt;span class="nf"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;#[from]&lt;/span&gt; &lt;span class="nn"&gt;serde_json&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="nd"&gt;#[error(&lt;/span&gt;&lt;span class="s"&gt;"configuration error: {0}"&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
    &lt;span class="nf"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;'static&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;HtpbeError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/// Whether sending the SAME request again (same Idempotency-Key) could succeed.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;is_retryable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// 409 idempotency_key_in_progress: the original is still running.&lt;/span&gt;
            &lt;span class="c1"&gt;// 429 server_at_capacity: server-wide concurrency, not your quota.&lt;/span&gt;
            &lt;span class="c1"&gt;// 500/502/504: transient on the server side or in front of it.&lt;/span&gt;
            &lt;span class="c1"&gt;// 503 is NOT retried here: it surfaces to the caller (CheckPending).&lt;/span&gt;
            &lt;span class="nn"&gt;HtpbeError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Api&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;status&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;=&amp;gt;&lt;/span&gt; &lt;span class="nd"&gt;matches!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;409&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;504&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="c1"&gt;// Timeouts and dropped connections are exactly what the&lt;/span&gt;
            &lt;span class="c1"&gt;// Idempotency-Key exists for. A builder error is a bug, not a blip.&lt;/span&gt;
            &lt;span class="nn"&gt;HtpbeError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Transport&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="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="nf"&gt;.is_builder&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="c1"&gt;// A malformed 2xx body will be malformed again.&lt;/span&gt;
            &lt;span class="nn"&gt;HtpbeError&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="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nn"&gt;HtpbeError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&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;match&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nn"&gt;HtpbeError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Api&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;code&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;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="nf"&gt;.as_str&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
            &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;None&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;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;retry_after&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Duration&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;match&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nn"&gt;HtpbeError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Api&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;retry_after&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;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;retry_after&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;None&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="cd"&gt;/// 409 idempotency_key_in_progress: the original request with this key&lt;/span&gt;
    &lt;span class="cd"&gt;/// is still running. The retry loop waits on it separately.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;is_in_progress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;matches!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;HtpbeError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Api&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;409&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="cd"&gt;/// No credit source left on the account. Not retryable: it fails the&lt;/span&gt;
    &lt;span class="cd"&gt;/// same way until credits are added, so send it to whoever owns billing.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;is_out_of_credits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.code&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"payment_required"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;code&lt;/code&gt; stays a &lt;code&gt;String&lt;/code&gt; for the same reason as the markers. The codes worth knowing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;&lt;code&gt;code&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Retry?&lt;/th&gt;
&lt;th&gt;What it means for you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;invalid_request&lt;/code&gt;, &lt;code&gt;invalid_url_format&lt;/code&gt;, &lt;code&gt;download_failed&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Bad body or URL, or the server could not fetch the file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;missing_api_key&lt;/code&gt;, &lt;code&gt;invalid_api_key&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Configuration problem; alert, don’t loop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;402&lt;/td&gt;
&lt;td&gt;&lt;code&gt;payment_required&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No credits left on the account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;inactive_client&lt;/code&gt;, &lt;code&gt;test_url_required&lt;/code&gt;, &lt;code&gt;account_restricted&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Key deactivated, test key with a real URL, or account restriction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;td&gt;&lt;code&gt;not_found&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Unknown id, or an id owned by another account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;409&lt;/td&gt;
&lt;td&gt;&lt;code&gt;idempotency_key_in_progress&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;The original request with this key is still running&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;413&lt;/td&gt;
&lt;td&gt;&lt;code&gt;file_too_large&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Over the 10&amp;nbsp;MB limit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;422&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;invalid_pdf&lt;/code&gt;, &lt;code&gt;idempotency_key_fingerprint_mismatch&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Unreadable PDF, or one key reused for two different URLs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;&lt;code&gt;server_at_capacity&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Server-wide concurrency limit; honour &lt;code&gt;Retry-After&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;&lt;code&gt;internal_error&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Server-side error; retry with the same key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;503&lt;/td&gt;
&lt;td&gt;&lt;code&gt;internal_error&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Later&lt;/td&gt;
&lt;td&gt;No result stored, no &lt;code&gt;Retry-After&lt;/code&gt;; contact support if it repeats&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The client retries &lt;code&gt;500&lt;/code&gt; (and a &lt;code&gt;502&lt;/code&gt; or &lt;code&gt;504&lt;/code&gt; from a proxy) within its attempt cap. A &lt;code&gt;503&lt;/code&gt; is left to the caller: the handler in Step 5 records it as check pending, to be retried once later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: The client
&lt;/h2&gt;

&lt;p&gt;Build one &lt;code&gt;reqwest::Client&lt;/code&gt; at startup and share it: it pools connections and is cheap to clone.&lt;/p&gt;

&lt;p&gt;Three design points drive the rest of the code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timeouts sized to the server’s limits.&lt;/strong&gt; The server allows 30 seconds to download your file and 20 to analyse it before the &lt;code&gt;POST&lt;/code&gt; returns, so the client allows 90 seconds per request, plus a short connect timeout. Anything tighter cuts off slow but valid checks and triggers pointless retries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One &lt;code&gt;Idempotency-Key&lt;/code&gt; per logical request, owned by the caller.&lt;/strong&gt; &lt;code&gt;check_pdf&lt;/code&gt; takes the key as an argument and resends it on every retry, so a retry of a completed request returns the same &lt;code&gt;id&lt;/code&gt; with no second charge. The caller owns it because the logical request can outlive one call: a pending case retried later, while its document URL is still valid, must send the same key and the same URL, or it risks spending credit twice on one document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;POST&lt;/code&gt; and &lt;code&gt;GET&lt;/code&gt; are retried separately.&lt;/strong&gt; Once you have an &lt;code&gt;id&lt;/code&gt;, a failed read is retried by reading again, never by re-submitting.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/htpbe/client.rs&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;future&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;time&lt;/span&gt;&lt;span class="p"&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;SystemTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UNIX_EPOCH&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;reqwest&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;header&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;RETRY_AFTER&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;reqwest&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;serde&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Serialize&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;error&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;HtpbeError&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;types&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;CheckResult&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;DEFAULT_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&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="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;MAX_RETRY_AFTER_SECS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u64&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="cd"&gt;/// How long to keep waiting on a 409 (original request still running).&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;MAX_IN_PROGRESS_WAIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Clone)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;HtpbeClient&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;reqwest&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;base_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&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;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Serialize)]&lt;/span&gt;
&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;AnalyzeRequest&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;'a&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;'a&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;#[serde(skip_serializing_if&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Option::is_none"&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
    &lt;span class="n"&gt;original_filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;'a&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Deserialize)]&lt;/span&gt;
&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;AnalyzeResponse&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="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Deserialize)]&lt;/span&gt;
&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;ErrorBody&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="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;HtpbeClient&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="nb"&gt;Into&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&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;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HtpbeError&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;let&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="nf"&gt;.into&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;api_key&lt;/span&gt;&lt;span class="nf"&gt;.is_empty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;HtpbeError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"HTPBE API key is empty"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;reqwest&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;.connect_timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="c1"&gt;// Above the server's 30 s download + 20 s analysis limits,&lt;/span&gt;
            &lt;span class="c1"&gt;// so a slow but valid check is never cut off client-side.&lt;/span&gt;
            &lt;span class="nf"&gt;.timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="nf"&gt;.build&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="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DEFAULT_BASE_URL&lt;/span&gt;&lt;span class="nf"&gt;.to_owned&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="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/// Point the client at a mock server in tests.&lt;/span&gt;
    &lt;span class="nd"&gt;#[allow(dead_code)]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;with_base_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="nb"&gt;Into&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&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;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.base_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="nf"&gt;.trim_end_matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.to_owned&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/// One logical check: submit (retried under the caller's key), then read.&lt;/span&gt;
    &lt;span class="cd"&gt;/// Persist `idempotency_key` with the case and reuse it if you retry later.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;check_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;pdf_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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;original_filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CheckResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HtpbeError&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;let&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.analyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;original_filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.result&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;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/// POST /analyze. Pass the same `idempotency_key` on every retry of&lt;/span&gt;
    &lt;span class="cd"&gt;/// one logical request, and a new key for every new document.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;pdf_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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;original_filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HtpbeError&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;let&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;AnalyzeRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pdf_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;original_filename&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="k"&gt;let&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;&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="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.with_retry&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;
                &lt;span class="py"&gt;.http&lt;/span&gt;
                &lt;span class="nf"&gt;.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{}/analyze"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.base_url&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                &lt;span class="nf"&gt;.bearer_auth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.api_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;.header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Idempotency-Key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotency_key&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;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;.send&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;check_status&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="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;let&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;resp&lt;/span&gt;&lt;span class="nf"&gt;.bytes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AnalyzeResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;serde_json&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_slice&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;bytes&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="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="py"&gt;.id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="k"&gt;.await&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/// GET /result/{id}. A plain read, so retrying it is always safe.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&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="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CheckResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HtpbeError&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;self&lt;/span&gt;&lt;span class="nf"&gt;.with_retry&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;
                &lt;span class="py"&gt;.http&lt;/span&gt;
                &lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{}/result/{}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.base_url&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="nf"&gt;.bearer_auth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.api_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;.send&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;check_status&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="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;let&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;resp&lt;/span&gt;&lt;span class="nf"&gt;.bytes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;serde_json&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_slice&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;bytes&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="k"&gt;.await&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="n"&gt;with_retry&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;F&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Fut&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;HtpbeError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;where&lt;/span&gt;
        &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;FnMut&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Fut&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Fut&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;HtpbeError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u32&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;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;waited_in_progress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ZERO&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;loop&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="nf"&gt;op&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="c1"&gt;// The original request is still running: wait on it without&lt;/span&gt;
                &lt;span class="c1"&gt;// spending a normal attempt, up to MAX_IN_PROGRESS_WAIT.&lt;/span&gt;
                &lt;span class="nf"&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;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="nf"&gt;.is_in_progress&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;waited_in_progress&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;MAX_IN_PROGRESS_WAIT&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;let&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;err&lt;/span&gt;
                        &lt;span class="nf"&gt;.retry_after&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                        &lt;span class="nf"&gt;.unwrap_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                        &lt;span class="nf"&gt;.min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MAX_IN_PROGRESS_WAIT&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;waited_in_progress&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                    &lt;span class="n"&gt;waited_in_progress&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                    &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;time&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;sleep&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="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="c1"&gt;// 409 is excluded here, so the in-progress budget is a hard stop.&lt;/span&gt;
                &lt;span class="nf"&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;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="nf"&gt;.is_in_progress&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;err&lt;/span&gt;&lt;span class="nf"&gt;.is_retryable&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;retries&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;&amp;lt;&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.max_attempts&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;retries&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                    &lt;span class="k"&gt;let&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;err&lt;/span&gt;&lt;span class="nf"&gt;.retry_after&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap_or_else&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="nf"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retries&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
                    &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;time&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;sleep&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="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="nf"&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;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&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="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="cd"&gt;/// Turns a non-2xx response into HtpbeError::Api. The body is parsed on a&lt;/span&gt;
&lt;span class="cd"&gt;/// best-effort basis: a proxy in front of the API can answer with HTML.&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;check_status&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;Response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HtpbeError&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;let&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;resp&lt;/span&gt;&lt;span class="nf"&gt;.status&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="nf"&gt;.is_success&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Ok&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;let&lt;/span&gt; &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;
        &lt;span class="nf"&gt;.headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RETRY_AFTER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.and_then&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;v&lt;/span&gt;&lt;span class="nf"&gt;.to_str&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.ok&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="nf"&gt;.and_then&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="nf"&gt;.trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.parse&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u64&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;.ok&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="n"&gt;secs&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secs&lt;/span&gt;&lt;span class="nf"&gt;.clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MAX_RETRY_AFTER_SECS&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;

    &lt;span class="k"&gt;let&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;resp&lt;/span&gt;&lt;span class="nf"&gt;.bytes&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;.unwrap_or_default&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="nn"&gt;serde_json&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;from_slice&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;ErrorBody&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="py"&gt;.code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="py"&gt;.error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="py"&gt;.details&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"unknown"&lt;/span&gt;&lt;span class="nf"&gt;.to_owned&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="nf"&gt;.canonical_reason&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"unexpected status"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.to_owned&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;HtpbeError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Api&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="nf"&gt;.as_u16&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&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="p"&gt;}&lt;/span&gt;

&lt;span class="cd"&gt;/// 1 s, 2 s, 4 s ... capped at 30 s, plus up to one second of jitter.&lt;/span&gt;
&lt;span class="cd"&gt;/// The jitter comes from the clock's sub-second nanos: enough to keep a&lt;/span&gt;
&lt;span class="cd"&gt;/// batch of workers from retrying in lockstep, without an RNG crate.&lt;/span&gt;
&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1u64&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="nf"&gt;.saturating_sub&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;.min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="nf"&gt;.min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;nanos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;SystemTime&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.duration_since&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UNIX_EPOCH&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="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="nf"&gt;.subsec_nanos&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="nf"&gt;.unwrap_or&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="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_millis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nanos&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;1_000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;Retry-After&lt;/code&gt;&lt;/strong&gt; is read as integer seconds and clamped to 1–60. The API sends it on &lt;code&gt;429 server_at_capacity&lt;/code&gt; and on &lt;code&gt;409 idempotency_key_in_progress&lt;/code&gt;; otherwise the loop uses exponential backoff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;409&lt;/code&gt; has its own budget.&lt;/strong&gt; A &lt;code&gt;409&lt;/code&gt; means the original is still running, so the loop keeps waiting on it for up to 90 seconds instead of spending its normal attempts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test keys never see &lt;code&gt;429&lt;/code&gt;&lt;/strong&gt;: capacity limits apply to live keys only. Keep the retry path anyway, so the code behaves the same once a live key goes in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Four attempts is a starting point.&lt;/strong&gt; Exhausting them doesn’t mean the document failed; it means you don’t know yet, and the handler in the next step treats it that way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Routing the verdict (axum handler)
&lt;/h2&gt;

&lt;p&gt;The API ships its own recommendation in every result: &lt;code&gt;usage_caution.recommended_action&lt;/code&gt; is &lt;code&gt;route_to_human_review&lt;/code&gt; for &lt;code&gt;modified&lt;/code&gt;, &lt;code&gt;request_from_issuer&lt;/code&gt; for &lt;code&gt;inconclusive&lt;/code&gt; and &lt;code&gt;no_action&lt;/code&gt; for &lt;code&gt;intact&lt;/code&gt;. The router follows it, with one special case and one fail-safe rule.&lt;/p&gt;

&lt;p&gt;The special case is &lt;code&gt;status_reason: analysis_incomplete&lt;/code&gt;, which describes the check rather than the document, so the router asks for a resubmission. A resubmission is a new logical request, so it gets a &lt;strong&gt;new&lt;/strong&gt; &lt;code&gt;Idempotency-Key&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fail-safe rule: anything the router doesn’t recognise, or any combination that doesn’t add up, goes to a human. &lt;code&gt;Proceed&lt;/code&gt; requires both &lt;code&gt;status: intact&lt;/code&gt; and &lt;code&gt;recommended_action: no_action&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/htpbe/routing.rs&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;types&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;CheckResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RecommendedAction&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;StatusReason&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="cd"&gt;/// Where a checked document goes next.&lt;/span&gt;
&lt;span class="cd"&gt;///&lt;/span&gt;
&lt;span class="cd"&gt;/// IMPORTANT: no variant here declines an application, flags a person or&lt;/span&gt;
&lt;span class="cd"&gt;/// takes any adverse action. The API marks every verdict&lt;/span&gt;
&lt;span class="cd"&gt;/// `safe_for_automated_adverse_decision: false`. A structural verdict is a&lt;/span&gt;
&lt;span class="cd"&gt;/// statement about the FILE, never about the person who sent it.&lt;/span&gt;
&lt;span class="nd"&gt;#[derive(Debug)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;Route&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/// intact + no_action: no structural modification was detected.&lt;/span&gt;
    &lt;span class="cd"&gt;/// Not a statement that the document is authentic or its contents true.&lt;/span&gt;
    &lt;span class="n"&gt;Proceed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="cd"&gt;/// modified + route_to_human_review, and every unknown or inconsistent case.&lt;/span&gt;
    &lt;span class="n"&gt;HumanReview&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;markers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Confidence&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="cd"&gt;/// inconclusive + request_from_issuer: the limit of what the file's&lt;/span&gt;
    &lt;span class="cd"&gt;/// structure can show. Not a suspicion of fraud.&lt;/span&gt;
    &lt;span class="n"&gt;RequestFromIssuer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;StatusReason&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="cd"&gt;/// status_reason analysis_incomplete: run the check again with a NEW&lt;/span&gt;
    &lt;span class="cd"&gt;/// Idempotency-Key. If it repeats, the caller downgrades to RequestFromIssuer.&lt;/span&gt;
    &lt;span class="n"&gt;Resubmit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;route_check&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="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;CheckResult&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Route&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// The incomplete case first: it describes the check, not the document.&lt;/span&gt;
    &lt;span class="c1"&gt;// (An incomplete analysis that still found a modification comes back as&lt;/span&gt;
    &lt;span class="c1"&gt;// `modified` with no status_reason, and the match below routes it.)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="py"&gt;.status_reason&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;StatusReason&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;AnalysisIncomplete&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="nn"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Resubmit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="py"&gt;.usage_caution.recommended_action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="py"&gt;.status&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="nn"&gt;RecommendedAction&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;NoAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Intact&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Proceed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;RecommendedAction&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;RequestFromIssuer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Inconclusive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;RequestFromIssuer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="py"&gt;.status_reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="c1"&gt;// route_to_human_review, plus anything unknown or inconsistent.&lt;/span&gt;
        &lt;span class="c1"&gt;// Fail safe: an unrecognised answer goes to a person, never to Proceed.&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;HumanReview&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;markers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="py"&gt;.modification_markers&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="py"&gt;.modification_confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The handler receives a case from your own system, with everything it needs stored on the case: a reachable document URL (Step 6), the idempotency key minted when the case was recorded, and the resubmission key if one was ever needed.&lt;/p&gt;

&lt;p&gt;The error branch matters as much as the routing. If the API can’t be reached or returns an error, the document has &lt;strong&gt;not&lt;/strong&gt; been checked. The case stays ‘check pending’. It doesn’t proceed, and it doesn’t count against anyone. Because its keys are stored with it, a retry within the URL’s lifetime replays the same logical request; after a failed attempt whose URL has expired, restage first (Step 6).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/main.rs&lt;/span&gt;
&lt;span class="k"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;htpbe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;axum&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="nn"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;http&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;routing&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;serde&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Serialize&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;htpbe&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;client&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;HtpbeClient&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;htpbe&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;error&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;HtpbeError&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;htpbe&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;routing&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;route_check&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;htpbe&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;types&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;Confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StatusReason&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Uuid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Clone)]&lt;/span&gt;
&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;AppState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;htpbe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;HtpbeClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Deserialize)]&lt;/span&gt;
&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;SubmitDocument&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;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;original_filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;case_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="cd"&gt;/// Minted (UUID v4) when the case was recorded and stored with it, next to&lt;/span&gt;
    &lt;span class="cd"&gt;/// the exact document_url. A pending case is retried with the SAME pair.&lt;/span&gt;
    &lt;span class="n"&gt;check_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="cd"&gt;/// Set once a first run came back analysis_incomplete. Stored on the case.&lt;/span&gt;
    &lt;span class="n"&gt;resubmit_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Serialize)]&lt;/span&gt;
&lt;span class="nd"&gt;#[serde(tag&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"route"&lt;/span&gt;&lt;span class="nd"&gt;,&lt;/span&gt; &lt;span class="nd"&gt;rename_all&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"snake_case"&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;CaseOutcome&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Proceed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;check_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;HumanReview&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;check_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;markers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Confidence&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;RequestFromIssuer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;check_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;StatusReason&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="cd"&gt;/// The file itself could not be analysed (too large, unreadable,&lt;/span&gt;
    &lt;span class="cd"&gt;/// password-protected). Ask the submitter for a readable copy.&lt;/span&gt;
    &lt;span class="n"&gt;FileNotCheckable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;error_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="cd"&gt;/// The check did not happen. Not a pass, not a fail: the case waits.&lt;/span&gt;
    &lt;span class="n"&gt;CheckPending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cd"&gt;/// Stand-in for your case store: write the resubmission key to the case&lt;/span&gt;
&lt;span class="cd"&gt;/// record. Replace with a real database write.&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;save_resubmit_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;case_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;io&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Demo only: never log real idempotency keys.&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;case_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cd"&gt;/// Runs the check and, for analysis_incomplete, exactly one fresh run.&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;run_check&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="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;HtpbeClient&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="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;SubmitDocument&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;HtpbeError&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;let&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;req&lt;/span&gt;&lt;span class="py"&gt;.document_url&lt;/span&gt;&lt;span class="nf"&gt;.as_str&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="py"&gt;.original_filename&lt;/span&gt;&lt;span class="nf"&gt;.as_deref&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;resubmit_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="py"&gt;.resubmit_key&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// A stored resubmit key means the first run already came back&lt;/span&gt;
        &lt;span class="c1"&gt;// incomplete: replay the resubmission, don't repeat the first check.&lt;/span&gt;
        &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nb"&gt;None&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="nf"&gt;.check_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&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;req&lt;/span&gt;&lt;span class="py"&gt;.check_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="nf"&gt;route_check&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;first&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nn"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Resubmit&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="c1"&gt;// A resubmission is a new logical request, so it gets a NEW&lt;/span&gt;
                    &lt;span class="c1"&gt;// Idempotency-Key, persisted on the case BEFORE it is sent.&lt;/span&gt;
                    &lt;span class="c1"&gt;// If the write fails, nothing is sent and the case stays pending.&lt;/span&gt;
                    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Uuid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new_v4&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                    &lt;span class="nf"&gt;save_resubmit_key&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;req&lt;/span&gt;&lt;span class="py"&gt;.case_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;key&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;.map_err&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nn"&gt;HtpbeError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"could not persist the resubmit key"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                    &lt;span class="n"&gt;key&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="n"&gt;other&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="py"&gt;.id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;other&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;let&lt;/span&gt; &lt;span class="n"&gt;second&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="nf"&gt;.check_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&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;resubmit_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="nf"&gt;route_check&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;second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Incomplete twice: unexamined, so never clean.&lt;/span&gt;
        &lt;span class="nn"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Resubmit&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;RequestFromIssuer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;StatusReason&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;AnalysisIncomplete&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;other&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;second&lt;/span&gt;&lt;span class="py"&gt;.id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;route&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="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;submit_document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;State&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AppState&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;Json&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;Json&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SubmitDocument&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Json&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CaseOutcome&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;checked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_check&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;state&lt;/span&gt;&lt;span class="py"&gt;.htpbe&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;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&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;outcome&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;checked&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;check_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Proceed&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;CaseOutcome&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Proceed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;check_id&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;check_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;HumanReview&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;markers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nn"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nn"&gt;CaseOutcome&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;HumanReview&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;check_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;markers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;check_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;RequestFromIssuer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;CaseOutcome&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;RequestFromIssuer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;check_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c1"&gt;// run_check never returns Resubmit; if it ever did, the file is unexamined.&lt;/span&gt;
        &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Resubmit&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ACCEPTED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;CaseOutcome&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;CheckPending&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;

        &lt;span class="nf"&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;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="nf"&gt;.code&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;Some&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="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"file_too_large"&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="s"&gt;"invalid_pdf"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nn"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;UNPROCESSABLE_ENTITY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nn"&gt;CaseOutcome&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;FileNotCheckable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;error_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="nf"&gt;.to_owned&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;_&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="c1"&gt;// Transport failure, retries exhausted, 401/402/403, 503, or a caller&lt;/span&gt;
                &lt;span class="c1"&gt;// bug such as idempotency_key_fingerprint_mismatch. The document&lt;/span&gt;
                &lt;span class="c1"&gt;// was not checked: never Proceed, never adverse.&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="nf"&gt;.is_out_of_credits&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nd"&gt;eprintln!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"htpbe: no credits left, alert billing"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nd"&gt;eprintln!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"htpbe check failed: {err}"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ACCEPTED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;CaseOutcome&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;CheckPending&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&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;outcome&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[tokio::main]&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;error&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;var&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="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AppState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;htpbe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;HtpbeClient&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&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;let&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/documents"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;submit_document&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="nf"&gt;.with_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;listener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;net&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;TcpListener&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"0.0.0.0:8080"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nn"&gt;axum&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is axum 0.8: a &lt;code&gt;tokio::net::TcpListener&lt;/code&gt; handed to &lt;code&gt;axum::serve&lt;/code&gt;, with &lt;code&gt;Json&lt;/code&gt; last because it consumes the body. Cloning &lt;code&gt;HtpbeClient&lt;/code&gt; only clones a handle to the same pool. When run inline, the worst case (retries, a wait on a &lt;code&gt;409&lt;/code&gt;, one resubmission) can hold a request for several minutes, which is why a queue suits higher volume; see &lt;a href="https://htpbe.tech/blog/batch-pdf-verification-queue-async" rel="noopener noreferrer"&gt;batch PDF checks with a queue&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What each route means for the people reviewing cases
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Proceed&lt;/strong&gt; means the file showed no structural sign of being edited after it was created. Your other checks still apply, and it says nothing about whether the figures in the document are true.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human review&lt;/strong&gt; means the file’s structure shows evidence of post-creation editing, and the markers say what kind. The reviewer decides what that means for this case, typically by asking for the original or contacting the issuer. Results the router doesn’t recognise also land here, often with an empty marker list, so check the list before drawing any conclusion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request from issuer&lt;/strong&gt; means the file came from a source where the structure can’t confirm integrity either way, such as an office suite, an online editor or a scan. That is a limit of the evidence, not a suspicion of the applicant. It also covers a file whose analysis came back incomplete twice; in that case the file was not fully examined, which says nothing about its source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check pending&lt;/strong&gt; means the check did not run, and the case waits until it does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the full reasoning behind each verdict and each &lt;code&gt;status_reason&lt;/code&gt;, see &lt;a href="https://htpbe.tech/blog/handling-pdf-verification-verdicts-in-code" rel="noopener noreferrer"&gt;handling verdicts in code&lt;/a&gt; and &lt;a href="https://htpbe.tech/blog/what-inconclusive-means-pdf-verification-results" rel="noopener noreferrer"&gt;what ‘inconclusive’ means&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Giving the API a reachable URL
&lt;/h2&gt;

&lt;p&gt;The API downloads the PDF from the URL you send, so the file must be reachable without authentication and no larger than 10&amp;nbsp;MB. For a private bucket, use a short-lived presigned &lt;code&gt;GET&lt;/code&gt; URL, and pass &lt;code&gt;original_filename&lt;/code&gt; so the stored result shows a readable name.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/upload.rs&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;time&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;aws_sdk_s3&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;presigning&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;PresigningConfig&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;aws_sdk_s3&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;primitives&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ByteStream&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;aws_sdk_s3&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;S3Client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Uuid&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;BoxError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;error&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Send&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Sync&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cd"&gt;/// Covers one in-process check plus its retries, NOT the pending-case&lt;/span&gt;
&lt;span class="cd"&gt;/// window: a case retried after the URL expired is restaged instead.&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;PRESIGN_TTL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&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="cd"&gt;/// Persist all three fields with the case. Every check of this case runs&lt;/span&gt;
&lt;span class="cd"&gt;/// from the stored URL and key while the URL is valid.&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;StagedCheck&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;object_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&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;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;check_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cd"&gt;/// Uploads once, signs once, mints the Idempotency-Key once.&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;stage_upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s3&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;S3Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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;pdf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u8&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;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;StagedCheck&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BoxError&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;let&lt;/span&gt; &lt;span class="n"&gt;object_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"incoming/{}.pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;Uuid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new_v4&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="nf"&gt;.put_object&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.key&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;object_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.content_type&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="nf"&gt;.body&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;ByteStream&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="nf"&gt;.send&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;object_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cd"&gt;/// Only for a check whose original attempt FAILED and whose URL has since&lt;/span&gt;
&lt;span class="cd"&gt;/// expired: a new URL is a new logical request, so it gets a new key too.&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;restage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s3&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;S3Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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;object_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;StagedCheck&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BoxError&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;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;object_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s3&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;S3Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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;object_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;StagedCheck&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BoxError&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;let&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;s3&lt;/span&gt;
        &lt;span class="nf"&gt;.get_object&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.bucket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.key&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;object_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.presigned&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;PresigningConfig&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;expires_in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PRESIGN_TTL&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;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StagedCheck&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;object_key&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="n"&gt;presigned&lt;/span&gt;&lt;span class="nf"&gt;.uri&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.to_owned&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;check_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;Uuid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new_v4&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check itself runs from the stored pair: &lt;code&gt;htpbe.check_pdf(&amp;amp;staged.document_url, Some(name), &amp;amp;staged.check_key)&lt;/code&gt;. The Idempotency-Key is tied to the full URL, query string included, so a re-signed URL under the same key gets &lt;code&gt;422 idempotency_key_fingerprint_mismatch&lt;/code&gt;. That’s why the pair is signed once and stored, never rebuilt on retry.&lt;/p&gt;

&lt;p&gt;Replaying a request that completed with a verdict returns the original check before the file is downloaded, so an expired URL doesn’t break a replay. If the original attempt failed and the URL has since expired, the retry comes back &lt;code&gt;400 download_failed&lt;/code&gt;: call &lt;code&gt;restage&lt;/code&gt; for a new URL and a new key, store the new pair (and clear any stored resubmit key), and check again. That’s safe because a failed attempt is not charged.&lt;/p&gt;

&lt;p&gt;The expiry is short on purpose. A presigned URL is a bearer link: anyone holding it can fetch the document until it expires, and a short expiry costs nothing more than a &lt;code&gt;restage&lt;/code&gt; when a failed case is retried later. A URL signed with temporary credentials also stops working when those credentials expire.&lt;/p&gt;

&lt;p&gt;Build the S3 client once at startup with &lt;code&gt;aws_config::load_defaults(BehaviorVersion::latest()).await&lt;/code&gt; and &lt;code&gt;aws_sdk_s3::Client::new(&amp;amp;config)&lt;/code&gt;. The same code works against Cloudflare R2 or any other S3-compatible store once you set the endpoint URL on the SDK config.&lt;/p&gt;

&lt;p&gt;One security note: if a URL ever comes from untrusted input, validate it first and reject &lt;code&gt;localhost&lt;/code&gt;, link-local addresses such as &lt;code&gt;169.254.169.254&lt;/code&gt; (cloud metadata) and the RFC 1918 private ranges. Better still, download the file yourself and send the API your own presigned link.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batch processing, test mode and credits
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Synchronous by design.&lt;/strong&gt; There’s nothing to poll and no webhook to register. For a backlog, run checks with a modest concurrency bound (a &lt;code&gt;tokio::sync::Semaphore&lt;/code&gt; or a small worker pool) and one shared &lt;code&gt;HtpbeClient&lt;/code&gt;. In a job queue, mint the key when the job is created and store it with the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[derive(Clone)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;CheckJob&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;pdf_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="cd"&gt;/// Minted at enqueue time and persisted, so a worker that picks the&lt;/span&gt;
    &lt;span class="cd"&gt;/// job up again replays the same logical request.&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&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;Workers then call &lt;code&gt;client.check_pdf(&amp;amp;job.pdf_url, None, &amp;amp;job.idempotency_key)&lt;/code&gt;. The retry loop already handles &lt;code&gt;429&lt;/code&gt;, but a small concurrency bound means you rarely reach it. For queue-based designs, see the &lt;a href="https://htpbe.tech/blog/pdf-verification-webhook-event-driven-integration" rel="noopener noreferrer"&gt;event-driven integration guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Past checks.&lt;/strong&gt; &lt;code&gt;GET /checks&lt;/code&gt; returns a paginated list of your checks, filterable by &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;from_date&lt;/code&gt;, &lt;code&gt;to_date&lt;/code&gt;, &lt;code&gt;creator&lt;/code&gt;, &lt;code&gt;producer&lt;/code&gt; and &lt;code&gt;tool&lt;/code&gt;, paged with &lt;code&gt;limit&lt;/code&gt; (1 to 500) and &lt;code&gt;offset&lt;/code&gt;. With reqwest’s &lt;code&gt;query&lt;/code&gt; feature off, build the string yourself, for example &lt;code&gt;format!("{base}/checks?status=modified&amp;amp;limit=100&amp;amp;offset=0")&lt;/code&gt;, with the same bearer header.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test mode.&lt;/strong&gt; Test keys accept only the fixed URLs under &lt;code&gt;https://api.htpbe.tech/v1/test/&lt;/code&gt; (anything else returns &lt;code&gt;403 test_url_required&lt;/code&gt;), return deterministic results and cost nothing. Five of them exercise three of the four branches of &lt;code&gt;route_check&lt;/code&gt;: &lt;code&gt;clean.pdf&lt;/code&gt;, &lt;code&gt;signature-removed.pdf&lt;/code&gt;, &lt;code&gt;dates-mismatch.pdf&lt;/code&gt;, &lt;code&gt;inconclusive.pdf&lt;/code&gt; and &lt;code&gt;scanned-document.pdf&lt;/code&gt;. No test URL produces &lt;code&gt;analysis_incomplete&lt;/code&gt;, so cover &lt;code&gt;Resubmit&lt;/code&gt; with a mock server via &lt;code&gt;with_base_url&lt;/code&gt;. Test keys also ignore &lt;code&gt;Idempotency-Key&lt;/code&gt;, so &lt;code&gt;409&lt;/code&gt;, replays and &lt;code&gt;idempotency_key_fingerprint_mismatch&lt;/code&gt; only appear with a live key. &lt;code&gt;trigger-402.pdf&lt;/code&gt; exercises the out-of-credits path. Test results never appear in &lt;code&gt;GET /checks&lt;/code&gt;. The full list is in the &lt;a href="https://github.com/htpbe/docs/blob/main/testing.md" rel="noopener noreferrer"&gt;testing reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Credits.&lt;/strong&gt; API calls and web uploads share one credit pool. With no credit source left, &lt;code&gt;/analyze&lt;/code&gt; returns &lt;code&gt;402 payment_required&lt;/code&gt; until credits are added, so route it to billing rather than retrying. Plans, credit packs and welcome-credit terms are on &lt;a href="https://htpbe.tech/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;Structural analysis has limits, and a service making automated routing decisions should encode them rather than discover them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Content that was false from the start.&lt;/strong&gt; The check finds evidence that a file was changed after it was created. A document whose figures were wrong when it was first produced carries no such evidence, because nothing was changed afterwards. This is also why a file produced by consumer software comes back &lt;code&gt;inconclusive&lt;/code&gt; with &lt;code&gt;consumer_software_origin&lt;/code&gt; rather than &lt;code&gt;intact&lt;/code&gt;: the structure can’t vouch for a file anyone could have produced from scratch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skilled, deliberate fabrication.&lt;/strong&gt; A well-resourced forger can sometimes produce a file that shows no structural sign of editing. It’s rare and takes effort, but &lt;code&gt;intact&lt;/code&gt; means no modification was detected, not that the document is genuine. Confirmation with the issuer remains the stronger check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encrypted or password-protected PDFs.&lt;/strong&gt; The file has to be readable to be analysed; these come back as &lt;code&gt;422 invalid_pdf&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The content layer.&lt;/strong&gt; The check doesn’t read the document’s text or figures, so it can’t tell you whether an amount, a name or a date printed on the page is correct.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s why structural tamper detection is one layer, not the whole stack. Pair the verdict with domain checks: confirmation with the issuer, account or employer lookups, and your KYC or OCR provider where those apply. More in &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 API surface is deliberately small: one &lt;code&gt;POST&lt;/code&gt;, one &lt;code&gt;GET&lt;/code&gt;, three verdicts and an error contract. The decisions that matter are on your side.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What reviewers see.&lt;/strong&gt; Give reviewers the markers rendered as labels from &lt;a href="https://htpbe.tech/how" rel="noopener noreferrer"&gt;/how&lt;/a&gt;, the &lt;code&gt;status_reason&lt;/code&gt; for inconclusive results and the &lt;code&gt;usage_caution.message&lt;/code&gt;, so the reasoning travels with the case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Policy per document type.&lt;/strong&gt; The router above follows the API’s recommendation. If your workflow needs a different path for a given document type, add it as a policy layer on top, and keep the rule that no structural verdict alone triggers an adverse action against a person.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To start, get a key from the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;API page&lt;/a&gt;, run the Step 1 curl with your test key at no cost, then point the client at a real file with a live key. New accounts get 5 welcome credits on signup, then pay-per-check credits or a subscription; see &lt;a href="https://htpbe.tech/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; for the terms.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>tutorial</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Grant and Relief-Fund Fraud: Altered Financial PDFs</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Sat, 31 Oct 2026 11:00:47 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/grant-and-relief-fund-fraud-altered-financial-pdfs-4no</link>
      <guid>https://dev.to/iurii_rogulia/grant-and-relief-fund-fraud-altered-financial-pdfs-4no</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/grant-subsidy-relief-fund-document-fraud" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt;. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A grant, subsidy or relief program asks applicants for much the same paperwork a lender does: bank statements, payroll records, tax returns, a profit and loss statement – and later, invoices showing what the money paid for. The difference is who reads it. A lender has an underwriting team paid to read that paperwork with suspicion. A funding program usually has a program officer, an eligibility checklist and a disbursement deadline.&lt;/p&gt;

&lt;p&gt;Grant and relief-fund document fraud rarely looks dramatic. It is a real bank statement with a changed balance, a payroll register with an extra name, an invoice with a higher total. The page reads correctly because a real document was edited, not invented from nothing. Reviewers read what a document says; programs rarely check whether the file was changed after the issuing system produced it. A structural PDF check answers that one question – was this file edited after it was created? It does not tell you whether the applicant qualifies, whether the figures are true, or who sent the file.&lt;/p&gt;

&lt;p&gt;When money moves fast on self-certified paperwork, the gap gets expensive. Paycheck Protection Program (PPP) fraud is a well-documented example, and the official record on it is summarised below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three checkpoints where applicant PDFs decide the money
&lt;/h2&gt;

&lt;p&gt;A funder holds applicant-submitted PDFs at three distinct moments, and each one releases or commits money.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Eligibility intake.&lt;/strong&gt; The applicant proves they exist as a going concern and meet the program’s thresholds: revenue, headcount, losses, location. Bank statements, tax returns and payroll summaries carry this step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-disbursement.&lt;/strong&gt; Before the first payment, programs often ask for updated statements, a bank account letter for the payee details, or a signed budget. A changed account number here redirects the award itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reimbursement and post-award review.&lt;/strong&gt; After the award, grantees claim against their budget with invoices, receipts and payroll records for staff the grant funds. This is where most of the documents arrive, spread over months or years.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In most programs, document scrutiny is concentrated at the first checkpoint. Most of the documents arrive at the third.&lt;/p&gt;

&lt;h2&gt;
  
  
  The documents that carry the decision
&lt;/h2&gt;

&lt;p&gt;Grant money is decided by the same handful of document types that decide loans. Each has its own altered-PDF pattern, described here by outcome rather than method:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bank statements&lt;/strong&gt; – a statement with a changed closing balance or a removed overdraft, used to show need or capacity. See &lt;a href="https://htpbe.tech/use-cases/fake-bank-statement-detection" rel="noopener noreferrer"&gt;bank statement tamper detection&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payroll records and pay stubs&lt;/strong&gt; – a payroll summary or pay stub showing staff or wages the business does not have, used for headcount-based awards and payroll reimbursement. See &lt;a href="https://htpbe.tech/use-cases/fake-pay-stub-detection" rel="noopener noreferrer"&gt;pay stub tamper detection&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tax returns&lt;/strong&gt; – a return with a revised gross receipts line, used to prove revenue loss against a prior year. See &lt;a href="https://htpbe.tech/use-cases/fake-tax-document-detection" rel="noopener noreferrer"&gt;tax document tamper detection&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Financial statements and profit and loss reports&lt;/strong&gt; – a P&amp;amp;L with a deeper loss than the books show.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reimbursement invoices and receipts&lt;/strong&gt; – an invoice with a higher total or a receipt re-dated into the grant period. See &lt;a href="https://htpbe.tech/use-cases/fake-invoice-detection" rel="noopener noreferrer"&gt;invoice tamper detection&lt;/a&gt; and &lt;a href="https://htpbe.tech/use-cases/expense-reimbursement" rel="noopener noreferrer"&gt;expense reimbursement checks&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of these share one property that matters for a funder: a well-made edit looks like the original on screen, so a careful reader often has nothing to catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What PPP and relief-fund history showed
&lt;/h2&gt;

&lt;p&gt;The SBA Office of Inspector General’s &lt;a href="https://legacy.sba.gov/document/report-23-09-covid-19-pandemic-eidl-ppp-loan-fraud-landscape" rel="noopener noreferrer"&gt;June 2023 fraud landscape report (Report 23-09)&lt;/a&gt; is a detailed primary record. It describes how emergency measures allowed the SBA to ‘reduce or eliminate key internal controls and rely on self-certification of eligibility to expedite aid’, and estimates that the SBA disbursed more than $200 billion in potentially fraudulent COVID-19 Economic Injury Disaster Loans (EIDLs) and PPP loans – at least 17% of disbursed funds, split as more than $136 billion in EIDLs and $64 billion in PPP funds. The SBA disputed that estimate in its response, published in the same report, and put the body of loans likely to be fraudulent at approximately $36 billion. Either figure is large; the gap between them is a reminder that ‘potentially fraudulent’ is not ‘proven’.&lt;/p&gt;

&lt;p&gt;Two details in the report matter for grant administrators. The first is that EIDL advances ‘were essentially grants that did not need to be repaid’, and applicants self-certified their headcount, a number ‘not vetted by SBA’. The second is that documents kept deciding money after disbursement: in one case the report describes, ‘the fraudulent payroll records were then submitted to SBA to obtain 100 percent loan forgiveness.’&lt;/p&gt;

&lt;p&gt;Two limits apply. The report does not break losses down by how the documents were produced, and many of the schemes it describes ran on stolen identities, fabricated employer numbers and businesses with no operations – problems a file check does not touch. And where a scheme accepts a self-declaration instead of a document, there is no file to inspect at all. In a separate example, the UK’s Bounce Back Loan Scheme had ‘limited verification, and no credit checks on borrowers’, according to the &lt;a href="https://www.nao.org.uk/press-releases/the-bounce-back-loan-scheme-an-update/" rel="noopener noreferrer"&gt;National Audit Office’s December 2021 update&lt;/a&gt;; the department’s March 2021 estimate that 11% of loans, worth £4.9 billion, were fraudulent was described as ‘highly uncertain’; a later October 2021 revision put it at 7.5% of loans, a figure the NAO said it could not assure.&lt;/p&gt;

&lt;p&gt;For how lenders handled PPP loan application fraud and what changed in their document review afterwards, see our post on &lt;a href="https://htpbe.tech/blog/sba-business-loan-stip-fraud" rel="noopener noreferrer"&gt;SBA loan stip-document fraud&lt;/a&gt;. For the wider public-sector picture, see &lt;a href="https://htpbe.tech/blog/government-document-fraud-pdf-verification" rel="noopener noreferrer"&gt;government document fraud and PDF checks&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why application-time review misses post-award claims
&lt;/h2&gt;

&lt;p&gt;Intake review gets the experienced staff: a few documents per applicant, read once, against clear criteria. Post-award claims arrive monthly or quarterly for the life of the grant, many small documents per grantee. Reviewers check them against budget lines – is this cost allowed, does it fall in the period, does the arithmetic match the claim – which is a question about the content, not the file. Audits are usually sampled. And a grantee who applied honestly can, under cash pressure two years later, edit one receipt total.&lt;/p&gt;

&lt;p&gt;So reimbursement documents are where editing is cheapest and review thinnest: one changed figure on one invoice, among hundreds in the queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a modification check tells a program officer
&lt;/h2&gt;

&lt;p&gt;HTPBE runs a structural PDF tamper detection check on each file and returns one of three verdicts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;intact&lt;/code&gt; – no structural modification detected.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;modified&lt;/code&gt; – the file carries structural evidence that it was changed after it was created.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;inconclusive&lt;/code&gt; – the check cannot give a full answer for this file, so it says so instead of guessing. Treat it as unanswered rather than cleared, and confirm the document with its source wherever the decision depends on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A &lt;code&gt;modified&lt;/code&gt; result names what was found with public marker IDs such as &lt;code&gt;HTPBE_MULTIPLE_REVISION_LAYERS&lt;/code&gt; (the document was saved again with changes after creation), &lt;code&gt;HTPBE_EDITING_TOOL_FINGERPRINT&lt;/code&gt; (a known PDF editor left its traces) or &lt;code&gt;HTPBE_DATES_DISAGREE&lt;/code&gt; (the modification date differs from the declared creation date). Each check also gets an ID you can store with the application as a record of the check.&lt;/p&gt;

&lt;p&gt;A trimmed example of a result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"506a6b1b-1360-48a2-b389-abb346f85d04"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"filename"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"reimbursement-invoice-q3.pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"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_MULTIPLE_REVISION_LAYERS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HTPBE_EDITING_TOOL_FINGERPRINT"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"usage_caution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"safe_for_automated_adverse_decision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"recommended_action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"route_to_human_review"&lt;/span&gt;&lt;span class="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 &lt;code&gt;usage_caution&lt;/code&gt; field is always present, and &lt;code&gt;safe_for_automated_adverse_decision&lt;/code&gt; is always &lt;code&gt;false&lt;/code&gt;. That reflects how the verdict should be used: intake, then the check, then flagged files routed to a person who decides what the edit means and whether to confirm the document with its source. Source confirmation – the bank, a tax transcript, the payroll provider – stays the reference. The check helps sequence it: files returned as &lt;code&gt;modified&lt;/code&gt; go to the front of the confirmation queue. For an &lt;code&gt;intact&lt;/code&gt; file the API returns &lt;code&gt;no_action&lt;/code&gt;: the file check raised no flag. That is not the same as saying source confirmation is unnecessary – an &lt;code&gt;intact&lt;/code&gt; file simply sits lower in the queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the check cannot tell you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not identity.&lt;/strong&gt; It does not establish who submitted the file or whether the business exists. That is a KYC and registry question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not eligibility.&lt;/strong&gt; It does not decide whether an applicant qualifies under program rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not whether the numbers are true.&lt;/strong&gt; HTPBE does not read balances, totals or headcounts, and does not check arithmetic. It examines the file’s structure, not its content – which is exactly why an edit that leaves the page looking perfect can still show up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not issuer confirmation.&lt;/strong&gt; &lt;code&gt;intact&lt;/code&gt; means no structural modification detected. It does not mean the document is authentic or that the bank issued it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not fabrication from scratch.&lt;/strong&gt; A document built with false content from the start, rather than edited, has no later edit for a modification check to find. Whatever verdict such a file gets – &lt;code&gt;intact&lt;/code&gt; included – it says nothing about whether the content is true; source confirmation covers that case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not scans.&lt;/strong&gt; A scanned or print-to-PDF document cannot return &lt;code&gt;intact&lt;/code&gt;; confirm it with the issuer instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wiring it into intake
&lt;/h2&gt;

&lt;p&gt;Platform teams that own the upload step can call the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;HTPBE API&lt;/a&gt; with one POST per file. The file URL must be downloadable without authentication, so applicant documents in private storage need a short-lived signed link:&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://storage.example.org/signed/applicant-123/bank-statement.pdf"}'&lt;/span&gt;

&lt;span class="c"&gt;# returns {"id": "..."}; fetch the verdict and marker IDs with:&lt;/span&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 &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;Store the check ID with the application record. Send &lt;code&gt;modified&lt;/code&gt; files to your existing review queue rather than to an automatic decision; for &lt;code&gt;inconclusive&lt;/code&gt; files, which the check cannot answer, confirm with the source wherever the decision depends on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can a PDF check tell whether an applicant qualifies for a grant?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. A structural check answers whether the file was edited after it was created. Eligibility depends on the program’s rules and on facts the file may or may not state truthfully – that stays with the program officer and with source confirmation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How is this different from a lender’s stip review?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The check is the same; the timing differs. A lender concentrates its review before funding, while a grant program keeps receiving documents after the award, through reimbursement claims and close-out reports – so the check belongs on post-award documents too, not just at intake.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens with scanned documents?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Scanned and print-to-PDF documents cannot return &lt;code&gt;intact&lt;/code&gt;, whatever happened to the original. If a program needs to rely on a scanned document, confirm it with the issuer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does a &lt;code&gt;modified&lt;/code&gt; result mean the applicant committed fraud?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. It records the fact of an edit, not the intent behind it. Redacting an account number, merging pages or re-exporting a file are edits too. A &lt;code&gt;modified&lt;/code&gt; result is a reason to route the file to a person, never grounds on its own to deny an application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it on your own applicant files
&lt;/h2&gt;

&lt;p&gt;The fastest way to see what the check returns on your documents is to &lt;a href="https://htpbe.tech/auth/signup" rel="noopener noreferrer"&gt;sign up&lt;/a&gt; and run a few through the web app – new accounts get five welcome credits. After that, as of October 2026, checks cost $5 for 8 or $15 for 25 as one-time credit packs, or plans start at $15 a month for 30 checks; see &lt;a href="https://htpbe.tech/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; for current tiers. Whatever the result, treat it as one input to review: none of the three verdicts on its own should be the sole basis for denying an applicant.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>fintech</category>
      <category>fraud</category>
      <category>webdev</category>
    </item>
    <item>
      <title>PHP2Shell: Two Critical PHP RCE Flaws and What to Do in the First Hour</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Fri, 30 Oct 2026 11:01:17 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/php2shell-two-critical-php-rce-flaws-and-what-to-do-in-the-first-hour-3cb6</link>
      <guid>https://dev.to/iurii_rogulia/php2shell-two-critical-php-rce-flaws-and-what-to-do-in-the-first-hour-3cb6</guid>
      <description>&lt;p&gt;On October 21, 2026, the PHP project released 8.5.12, 8.4.27, 8.3.36 and 8.2.35 to fix two critical remote code execution flaws, CVE-2026-109618 and CVE-2026-109619. Amazon Threat Intelligence calls the pair PHP2Shell. Both are rated CVSS v4.0 9.2, and no login is required to reach them.&lt;/p&gt;

&lt;p&gt;If anything you run serves web requests through PHP, the first job is to find out which version is actually answering those requests. This article covers what the flaws are, who is exposed, and the order I would work in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Two Flaws Are
&lt;/h2&gt;

&lt;p&gt;Both are memory-safety bugs in PHP itself. Neither is in a framework, a plugin or your application code. The details below come from the PHP project’s advisories, &lt;a href="https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w" rel="noopener noreferrer"&gt;GHSA-w8hw-w9fx-j79w&lt;/a&gt; and &lt;a href="https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp" rel="noopener noreferrer"&gt;GHSA-6wmf-4rv5-p3rp&lt;/a&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CVE-2026-109618, RCE via OOM corruption.&lt;/strong&gt; When PHP runs out of memory it exits the current context with &lt;code&gt;longjmp()&lt;/code&gt;. In some places request data is reachable before it is fully initialised, so the early exit leaves invalid memory behind and request shutdown then acts on it. Multipart parsing can push PHP into that state while staying within the configured limits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CVE-2026-109619, RCE via stale global state.&lt;/strong&gt; A persistent worker can keep global state that points at memory owned by a previous request. After that memory is freed, a later request on the same worker may use the stale value before PHP has initialised the request. The confirmed paths are the PHP 8.5 &lt;code&gt;fatal_error_backtraces&lt;/code&gt; setting and session upload progress.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The advisories give the affected ranges as everything below the fixed versions on 8.2, 8.3, 8.4 and 8.5.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who Is Exposed
&lt;/h2&gt;

&lt;p&gt;The PHP project says all installations serving web requests are at risk: PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed. The risk is highest for mod_php, where an exploit was demonstrated that fires before any application code runs.&lt;/p&gt;

&lt;p&gt;For PHP-FPM, the OOM flaw is considered susceptible from source analysis, but the advisory says no complete exploit has been demonstrated. The stale-state flaw was validated against FPM through session upload progress, which is enabled by default.&lt;/p&gt;

&lt;p&gt;PHP 8.1 and older no longer get updates from the PHP project. It published minimal source backports on separate branches for 7.4, 8.0 and 8.1, and some distributions and long-term-support vendors ship their own fixes. PHP 8.2 reaches end of life on December 31, 2026, according to the &lt;a href="https://www.php.net/supported-versions.php" rel="noopener noreferrer"&gt;PHP project&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Some distributions backport the fix into an old upstream version instead of upgrading. Ubuntu 22.04 ships PHP 8.1.2 and is fixed at &lt;code&gt;8.1.2-1ubuntu2.28&lt;/code&gt;, so comparing against the list above tells you nothing there. Debian moved to the upstream fixed versions instead (8.2.35 on bookworm, 8.4.27 on trixie). Check your distribution’s security tracker for the package version: &lt;a href="https://security-tracker.debian.org/tracker/CVE-2026-109618" rel="noopener noreferrer"&gt;Debian&lt;/a&gt;, &lt;a href="https://ubuntu.com/security/CVE-2026-109618" rel="noopener noreferrer"&gt;Ubuntu&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is It Being Exploited
&lt;/h2&gt;

&lt;p&gt;Amazon Threat Intelligence &lt;a href="https://aws.amazon.com/blogs/security/amazon-threat-intelligence-identifies-php2shell-exploitation-prior-to-public-disclosure-in-madpot/" rel="noopener noreferrer"&gt;reports&lt;/a&gt; an exploitation attempt on its MadPot honeypot network on October 17, four days before disclosure. On October 21, between about 02:00 and 08:00 UTC, it saw more attempts from other infrastructure with varying payloads, which it reads as multiple actors. Once the patch shipped, new proof-of-concept exploits hit its sensors within hours, and Amazon says PHP2Shell is now seeing broad exploitation attempts in the wild. Amazon also says it is unclear whether any of these attempts would have worked against real systems.&lt;/p&gt;

&lt;p&gt;So the safe assumption is that scanning is already under way, and that the question is whether your version is fixed, not whether anyone is looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Hour
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find the PHP that serves requests, not the one in your shell.&lt;/strong&gt; &lt;code&gt;php -v&lt;/code&gt; reports the command-line binary. Your web server can use a different build, a different container or a different FPM pool. Check the version of the running web runtime, the container image tag, and your deploy and CI configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update.&lt;/strong&gt; Move to 8.5.12, 8.4.27, 8.3.36 or 8.2.35, or to your distribution’s patched package. The PHP project says updating is the only complete fix for both flaws. On containers, rebuild the image from a patched base and recreate the containers, since restarting the old one changes nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you cannot update yet, apply the interim measures and treat them as partial.&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;For the OOM flaw: set &lt;code&gt;memory_limit=-1&lt;/code&gt; so it cannot be overridden (&lt;code&gt;php_admin_value&lt;/code&gt; in an FPM pool or Apache config; the price is that a single request has no memory ceiling, so you need an external limit such as a container or cgroup limit that kills only the affected worker), keep &lt;code&gt;post_max_size&lt;/code&gt; at 8M (32M if you need uploads) with &lt;code&gt;upload_max_filesize&lt;/code&gt; below it, and limit request size, field count, multipart parts and nesting at the proxy. Do not use &lt;code&gt;RLIMIT_AS&lt;/code&gt; or &lt;code&gt;RLIMIT_DATA&lt;/code&gt;, because they trigger the vulnerable path.&lt;/li&gt;
&lt;li&gt;For the stale-state flaw: set &lt;code&gt;session.upload_progress.enabled=0&lt;/code&gt; in &lt;code&gt;php.ini&lt;/code&gt;, the FPM pool or the server config, then restart every worker. &lt;code&gt;ini_set()&lt;/code&gt; and &lt;code&gt;.user.ini&lt;/code&gt; do not work for this. On PHP 8.5, also set &lt;code&gt;fatal_error_backtraces=0&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look back, starting October 17.&lt;/strong&gt; Amazon suggests searching web server logs for POST requests containing &lt;code&gt;PHP_SESSION_UPLOAD_PROGRESS&lt;/code&gt; in form uploads, and checking servers for unexpected process execution or file changes. Start from October 17, not from the disclosure date.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Separately, the PHP project’s advisory page lists a related issue on session upload progress, CVE-2026-115560, dated October 23. It is not part of these two, and it needs a specific &lt;code&gt;session.serialize_handler&lt;/code&gt; change at runtime. If you do not use upload progress, keeping it off removes that exposure too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Letting an Agent Do the Inventory
&lt;/h2&gt;

&lt;p&gt;The tedious part is the inventory: PHP on hosts, in container images, in CI, in deploy files, and telling the CLI version from the web one. I published a free Agent Skill that walks Claude Code, Codex or Cursor through this: &lt;a href="https://iurii.rogulia.fi/skills/php2shell-response" rel="noopener noreferrer"&gt;PHP RCE Response&lt;/a&gt;. It finds the runtimes, classifies each by SAPI and the settings the advisories name, plans the update and checks the result. It is read-only by default: it diagnoses and recommends, and you decide what to change. Where the advisories do not state a fact, it marks it as unverified instead of guessing.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.php.net/archive/2026.php" rel="noopener noreferrer"&gt;PHP security advisory, October 21, 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w" rel="noopener noreferrer"&gt;GHSA-w8hw-w9fx-j79w, CVE-2026-109618&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp" rel="noopener noreferrer"&gt;GHSA-6wmf-4rv5-p3rp, CVE-2026-109619&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/blogs/security/amazon-threat-intelligence-identifies-php2shell-exploitation-prior-to-public-disclosure-in-madpot/" rel="noopener noreferrer"&gt;Amazon Threat Intelligence on PHP2Shell exploitation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>vulnerabilities</category>
      <category>rce</category>
    </item>
    <item>
      <title>Auto Insurance Claims Fraud: Altered Estimates and Reports</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Fri, 30 Oct 2026 11:00:51 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/auto-insurance-claims-fraud-altered-estimates-and-reports-3lf0</link>
      <guid>https://dev.to/iurii_rogulia/auto-insurance-claims-fraud-altered-estimates-and-reports-3lf0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/auto-insurance-claims-fraud-repair-estimate-pdf" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt;. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A motor claim rarely arrives as one document. It arrives as a stack of PDFs: the body shop’s estimate, a supplement two weeks later, a crash report the claimant downloaded, a tow and storage invoice, sometimes a total-loss valuation and a counter-offer. Most of them passed through someone else’s inbox before they reached yours, and for many of them the file is the only evidence you hold.&lt;/p&gt;

&lt;p&gt;This article covers one narrow layer of automobile insurance fraud detection: what the structure of each of those PDFs can tell a claims team, and what it can’t.&lt;/p&gt;

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

&lt;p&gt;Ask one question of every PDF in the claim file: &lt;strong&gt;was this file changed after it was issued?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;HTPBE answers that question by reading the file’s structure and history, not its content. The result is one of three verdicts – &lt;code&gt;intact&lt;/code&gt;, &lt;code&gt;modified&lt;/code&gt; or &lt;code&gt;inconclusive&lt;/code&gt; – plus named markers for what was found. A marker might report, for example, that the file was saved again after it was created (&lt;code&gt;HTPBE_MULTIPLE_REVISION_LAYERS&lt;/code&gt;) or that its modification date does not match its creation date (&lt;code&gt;HTPBE_DATES_DISAGREE&lt;/code&gt;).&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;whether the figures on the document are correct;&lt;/li&gt;
&lt;li&gt;whether the police, the shop or the tow company actually issued it;&lt;/li&gt;
&lt;li&gt;whether the accident happened the way the claim says;&lt;/li&gt;
&lt;li&gt;photos of the damage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are separate questions, and they need separate controls.&lt;/p&gt;

&lt;h2&gt;
  
  
  The motor-claim document set
&lt;/h2&gt;

&lt;p&gt;Motor accounts for the majority of detected claims fraud by number, at least in the UK figures. The &lt;a href="https://www.abi.org.uk/news/news-articles/2025/11/fraudulent-insurance-claims-continue-to-top-1-billion/" rel="noopener noreferrer"&gt;Association of British Insurers&lt;/a&gt; reported that UK insurers detected 51,700 fraudulent motor claims worth £576m in 2024 – 53% of all detected fraudulent general insurance claims by number. Across general insurance, detected fraud came to £1.16bn on 98,400 claims, and exaggerated loss was the most common type, at £466m. These are frauds insurers caught; the share they did not catch is unknown by definition.&lt;/p&gt;

&lt;p&gt;Each document in a motor claim has a different author and a different route to the insurer. The route matters as much as the document type: a file that came straight from its issuer is a different object from one the claimant forwarded.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Document&lt;/th&gt;
&lt;th&gt;Produced by&lt;/th&gt;
&lt;th&gt;Usually reaches you via&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Repair estimate and supplements&lt;/td&gt;
&lt;td&gt;Body shop’s estimating system&lt;/td&gt;
&lt;td&gt;Shop, claimant, broker or public adjuster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Police or crash report&lt;/td&gt;
&lt;td&gt;Police agency, often sold through a records portal&lt;/td&gt;
&lt;td&gt;Claimant, who downloads or scans a copy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tow and storage invoice&lt;/td&gt;
&lt;td&gt;Tow operator or storage yard&lt;/td&gt;
&lt;td&gt;Claimant or shop, often by email&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total-loss valuation&lt;/td&gt;
&lt;td&gt;Valuation provider; the claimant may send a rival one&lt;/td&gt;
&lt;td&gt;Your own vendor, or the claimant in a dispute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Final repair invoice&lt;/td&gt;
&lt;td&gt;Body shop&lt;/td&gt;
&lt;td&gt;Shop or claimant at settlement&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every hand-off is a point where a PDF can be edited and saved again. Most of those edits are harmless; the job is to find the few that are not, without slowing down the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repair estimates and supplements: revisions are normal
&lt;/h2&gt;

&lt;p&gt;Estimates change for legitimate reasons. A teardown finds hidden damage and the shop issues a supplement. A parts price changes and the estimate is re-issued. Three or four estimate documents on one claim is ordinary, and each one is normally its own file.&lt;/p&gt;

&lt;p&gt;That is a different thing from one file being changed after it was produced, which is what a &lt;code&gt;modified&lt;/code&gt; verdict reports – for example &lt;code&gt;HTPBE_MULTIPLE_REVISION_LAYERS&lt;/code&gt;, meaning the file was saved again after it was created, or &lt;code&gt;HTPBE_ONLINE_EDITOR_ORIGIN&lt;/code&gt;, meaning it carries the identity of an online editing or conversion service. A supplement does not explain a changed file, and a changed file is not proof of fraud either. The next step is to compare the whole document with the copy the shop sends you directly. If the two match, the edit did not change what the claim relies on. If they differ, the file goes to your special investigations unit (SIU).&lt;/p&gt;

&lt;p&gt;The check matters most where the claimant, rather than the shop, is the courier. An estimate that arrives through your estimating platform or straight from the shop has fewer hands on it than one forwarded as an email attachment. The &lt;a href="https://htpbe.tech/use-cases/insurance-claims-fraud-detection/fake-repair-estimate-detection" rel="noopener noreferrer"&gt;repair estimate tamper-detection page&lt;/a&gt; covers this document type in detail, and &lt;a href="https://htpbe.tech/blog/insurance-claims-fraud-altered-pdfs" rel="noopener noreferrer"&gt;what adjusters miss in altered claim PDFs&lt;/a&gt; walks through how altered estimates, medical bills and receipts reach claims processing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Police and crash reports: a copy, not the record
&lt;/h2&gt;

&lt;p&gt;In some US states, people involved in a crash can buy and download the police report themselves. New York’s DMV, for example, lets you &lt;a href="https://dmv.ny.gov/records/order-and-access-motor-vehicle-crash-accident-reports" rel="noopener noreferrer"&gt;search, purchase and immediately download certified crash reports online&lt;/a&gt;. So the copy in your claim file has passed through the claimant’s hands.&lt;/p&gt;

&lt;p&gt;File forensics can answer one question about that copy: did this PDF change after it was produced? It cannot tell you whether the content matches the agency’s record, whether an officer wrote it, or whether the narrative is accurate. If the report decides liability or the size of the payout, order the copy yourself from the same agency or portal, where you are entitled to request it, and compare.&lt;/p&gt;

&lt;p&gt;Many crash reports also arrive as phone scans or photographed pages, and those often come back &lt;code&gt;inconclusive&lt;/code&gt;: a scanned copy can never be confirmed intact, though some still show signs of editing and come back &lt;code&gt;modified&lt;/code&gt;. An &lt;code&gt;inconclusive&lt;/code&gt; result is not a sign of fraud or a reason to treat the claimant with suspicion; the answer has to come from the source.&lt;/p&gt;

&lt;h2&gt;
  
  
  The timeline across the claim file
&lt;/h2&gt;

&lt;p&gt;Most PDFs carry their own creation and modification dates, and a motor claim has a fixed reference point: the date of loss. Lined up together, the dates of the estimate, the supplements, the tow invoice and the crash report should make sense against that date and against each other. A file last modified long after it should have been final is a question for the handler. Some dates move for ordinary reasons: a crash report bought from a portal may be generated at download time, so its creation date can fall well after the date of loss, and legitimate archiving and handling move dates too. Treat a timeline gap as a reason to look closer, not as a finding. For how a claims platform compares these dates automatically and routes each verdict to the right queue, see &lt;a href="https://htpbe.tech/blog/insurance-claims-pdf-fraud" rel="noopener noreferrer"&gt;the claims-routing integration article&lt;/a&gt;.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Content that was false from the start.&lt;/strong&gt; A document whose figures were wrong when it was first produced carries no evidence of a later edit, because nothing was changed afterwards. And a well-resourced, deliberate forger can sometimes produce a file that shows no structural sign of editing. It’s rare, but &lt;code&gt;intact&lt;/code&gt; means no modification was detected, not that the document is genuine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong figures in an issued document.&lt;/strong&gt; HTPBE does not read totals, labour rates, parts prices or line items. An estimate inflated inside the shop’s own system before the PDF was issued has no later edit to detect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staged accidents and claim-level fraud.&lt;/strong&gt; Staged collisions, phantom passengers and pre-existing damage are claim facts, not file facts. Claim scoring and investigation cover them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Photos and video.&lt;/strong&gt; HTPBE checks PDF files. It does not assess photographs of the vehicle or the scene.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Issuer and police records.&lt;/strong&gt; HTPBE does not contact the shop, the tow company or the police, and it does not confirm that a document exists in their records.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intent.&lt;/strong&gt; &lt;code&gt;modified&lt;/code&gt; means the file was changed. It does not say who changed it or why. That is for the handler or SIU to establish.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it fits in motor claims intake
&lt;/h2&gt;

&lt;p&gt;Run the check once per PDF, when the document is attached to the claim, before anyone relies on it. Then route by verdict:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;intact&lt;/code&gt;&lt;/strong&gt; – normal handling. The file shows no evidence of a post-creation edit; everything else about the claim still gets its usual review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;inconclusive&lt;/code&gt;&lt;/strong&gt; – not a fraud signal and no reason to treat the claimant with suspicion. If the document affects liability or the payout, get it from its source before relying on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;modified&lt;/code&gt;&lt;/strong&gt; – hold for review, with the markers attached. Compare the file with the issuer’s copy first; if they differ, send it to SIU. Declining the claim is not the next step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A &lt;code&gt;modified&lt;/code&gt; verdict is not a finding of fraud and should not on its own be the reason to decline or reduce a claim. Give the claimant or the issuer a chance to explain the change or supply the original.&lt;/p&gt;

&lt;p&gt;Most documents go through untouched. For motor insurance fraud detection teams, the change is that the few edited files reach SIU with a reason attached, before payment rather than after. The &lt;a href="https://htpbe.tech/use-cases/insurance-claims-fraud-detection" rel="noopener noreferrer"&gt;insurance claims fraud detection overview&lt;/a&gt; covers the other claim document types, and &lt;a href="https://htpbe.tech/blog/insurance-fraud-detection-methods" rel="noopener noreferrer"&gt;how insurance fraud detection methods fit together&lt;/a&gt; explains where this layer sits next to claim scoring.&lt;/p&gt;

&lt;p&gt;To try it on your own claim files, &lt;a href="https://htpbe.tech/auth/signup" rel="noopener noreferrer"&gt;create an account&lt;/a&gt;: it comes with 5 welcome credits, and checks after that are pay-per-check (see &lt;a href="https://htpbe.tech/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can HTPBE tell whether a repair estimate is inflated?
&lt;/h3&gt;

&lt;p&gt;No. HTPBE does not read the figures, line items or labour rates on an estimate, so it cannot say whether a total is too high. It answers a different question – whether the PDF was changed after it was issued. If an estimate comes back modified, compare the whole document with the copy the body shop sends you directly. If the two match, the edit did not change what the claim relies on; if they differ, the file goes to review.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does an inconclusive result on a police report mean the report is fake?
&lt;/h3&gt;

&lt;p&gt;No. Scanned and photographed copies can never be confirmed intact, so unless they show signs of editing they come back inconclusive. That is a common result for this kind of file, not a sign of fraud and not grounds for denying a claim. If the report matters to liability, order the copy from the agency or state portal yourself, where you are entitled to request it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does an intact verdict mean the motor claim is genuine?
&lt;/h3&gt;

&lt;p&gt;No. Intact means the file shows no evidence of being changed after it was created. A document whose content was false from the start, an estimate inflated inside the shop’s own system, or a staged accident can all sit behind an intact PDF. The verdict covers the file, not the claim, the figures or the events it describes.&lt;/p&gt;

&lt;h2&gt;
  
  
  For claims platforms
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;HTPBE API&lt;/a&gt; takes one POST to &lt;code&gt;https://api.htpbe.tech/v1/analyze&lt;/code&gt; with a publicly accessible URL of the PDF and returns a check ID. A GET on &lt;code&gt;https://api.htpbe.tech/v1/result/{id}&lt;/code&gt; returns the verdict and public marker IDs. Call it from the document-upload step of your claims system, store the check ID on the claim record, and route on the verdict. A test key returns synthetic scenarios for each verdict, so you can build and test the routing before any real claim document goes through it.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>fintech</category>
      <category>fraud</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Verifying vatnode Webhook Signatures (HMAC-SHA256)</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Fri, 30 Oct 2026 10:00:50 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/verifying-vatnode-webhook-signatures-hmac-sha256-d3f</link>
      <guid>https://dev.to/iurii_rogulia/verifying-vatnode-webhook-signatures-hmac-sha256-d3f</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/verify-vatnode-webhook-signatures" rel="noopener noreferrer"&gt;vatnode.dev&lt;/a&gt;. The version on vatnode.dev is the canonical source — refer to it for the latest content.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A webhook signature proves the request was sent by someone holding your webhook’s signing key – vatnode, unless that key has leaked – and that the body wasn’t altered in transit. Getting verification right is less about the HMAC itself – that part is five lines – and more about three things people get wrong: reading the wrong bytes, skipping the timestamp check, and returning the wrong status code when verification fails. This post covers all three, with a verifier you can drop in as-is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What’s actually in the signature
&lt;/h2&gt;

&lt;p&gt;Every webhook delivery carries an &lt;code&gt;X-Vatnode-Signature&lt;/code&gt; header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;X-Vatnode-Signature: t=1760000000,v1=3f2a9c...64 hex chars
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;t&lt;/code&gt; — the unix timestamp (seconds) at the moment vatnode signed and sent this specific attempt. It changes on every retry; the body bytes don’t.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;v1&lt;/code&gt; — a lowercase hex HMAC-SHA256, 64 characters. Only one &lt;code&gt;v1&lt;/code&gt; value is ever sent; there’s no multi-secret or multi-version list to loop over.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The signed message is &lt;code&gt;${t}.${rawBody}&lt;/code&gt;, where &lt;code&gt;rawBody&lt;/code&gt; is the exact compact JSON string vatnode wrote to the wire – &lt;code&gt;JSON.stringify(payload)&lt;/code&gt;, no pretty-printing. The HMAC key is the full signing secret returned once in the &lt;code&gt;201&lt;/code&gt; response of &lt;code&gt;POST /v1/webhooks&lt;/code&gt;, &lt;code&gt;vat_wh_&lt;/code&gt; prefix included, used as a plain UTF-8 string – not decoded from hex or base64. Keep it in your secret store or environment, never in code, and never log it or the raw &lt;code&gt;X-Vatnode-Signature&lt;/code&gt; header; log rejections without either. The framework examples below refuse to start without it: a missing key would otherwise make the verifier reject every real delivery, with nothing in the logs to say why.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;X-Vatnode-Event&lt;/code&gt; carries the event type as a convenience header, but it isn’t covered by the signature. Route on the verified body’s &lt;code&gt;event&lt;/code&gt; field, not the header – the header is for logging and quick filtering, not for a decision that depends on the payload being authentic.&lt;/p&gt;

&lt;p&gt;The verified body is a JSON envelope with &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;version&lt;/code&gt;, &lt;code&gt;event&lt;/code&gt;, &lt;code&gt;data&lt;/code&gt;, and &lt;code&gt;timestamp&lt;/code&gt; fields. &lt;code&gt;id&lt;/code&gt; is stable across every retry of the same event, and identical across every webhook endpoint on your account that received it – dedupe on &lt;code&gt;id&lt;/code&gt;, not on the HTTP request. Possible &lt;code&gt;event&lt;/code&gt; values are &lt;code&gt;VAT_BECAME_VALID&lt;/code&gt;, &lt;code&gt;VAT_BECAME_INVALID&lt;/code&gt;, &lt;code&gt;COMPANY_NAME_CHANGED&lt;/code&gt;, &lt;code&gt;COMPANY_ADDRESS_CHANGED&lt;/code&gt;, &lt;code&gt;VIES_UNAVAILABLE&lt;/code&gt;, and &lt;code&gt;webhook.test&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;VAT_BECAME_INVALID&lt;/code&gt; lands for a customer you’ve already invoiced, that’s a signal to re-check before your next invoice – not something to resolve automatically from this event alone. See &lt;a href="https://vatnode.dev/blog/vat-validation-subscription-renewals" rel="noopener noreferrer"&gt;VAT validation and subscription renewals&lt;/a&gt; for that workflow.&lt;/p&gt;

&lt;p&gt;Here’s the full verifier both the Express and Next.js examples below import. TypeScript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timingSafeEqual&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TOLERANCE_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&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;verifyVatnodeSignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;rawBody&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="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;signingKey&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;nowSeconds&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;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&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;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;header&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;signingKey&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;v1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
  &lt;span class="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;part&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&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;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &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="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;part&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;i&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="nf"&gt;trim&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;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;t&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&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;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;v1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;v1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{64}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v1&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nowSeconds&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;TOLERANCE_SECONDS&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signingKey&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&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;received&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&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;v1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&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;received&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="nx"&gt;expected&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;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expected&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;Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;TOLERANCE_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_vatnode_signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signing_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="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&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;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;signing_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;parts&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="n"&gt;part&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sep&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&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="n"&gt;v1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parts&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;t&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="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;parts&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1&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="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fullmatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[0-9]{1,12}&lt;/span&gt;&lt;span class="sh"&gt;"&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="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fullmatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[0-9a-f]{64}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&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;now&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;int&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;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TOLERANCE_SECONDS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

    &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signing_key&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&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="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;raw_body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&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;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass it the request body as bytes, exactly as received: &lt;code&gt;request.get_data()&lt;/code&gt; in Flask, &lt;code&gt;request.body&lt;/code&gt; in Django, &lt;code&gt;await request.body()&lt;/code&gt; in FastAPI or Starlette.&lt;/p&gt;

&lt;p&gt;Both use a constant-time comparison (&lt;code&gt;timingSafeEqual&lt;/code&gt;, &lt;code&gt;hmac.compare_digest&lt;/code&gt;) rather than &lt;code&gt;===&lt;/code&gt; or &lt;code&gt;==&lt;/code&gt; on the hex strings – a plain string comparison leaks timing information about how many leading bytes matched, which is exactly the kind of detail an HMAC is supposed to deny an attacker.&lt;/p&gt;

&lt;h2&gt;
  
  
  The raw-body trap
&lt;/h2&gt;

&lt;p&gt;The most common way to get a correct-looking implementation that still fails in production is verifying against the wrong bytes. A signature is a function of exact bytes, not of the data those bytes represent. If something in your stack parses the body into an object before your handler gets it, and you reconstruct a string from that object to verify, you risk hashing different bytes than the ones vatnode actually signed – a different serializer can add spacing &lt;code&gt;JSON.stringify&lt;/code&gt; doesn’t, format a number differently, or otherwise produce output that means the same thing but isn’t the same bytes. The HMAC won’t match, and you’ll see a steady trickle of false rejections that look like an intermittent bug but aren’t.&lt;/p&gt;

&lt;p&gt;The fix: capture the raw body before any parsing happens, and hash that. In Express, that means registering the webhook route with &lt;code&gt;express.raw()&lt;/code&gt; ahead of any global &lt;code&gt;express.json()&lt;/code&gt; middleware:&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="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&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;verifyVatnodeSignature&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./verify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VATNODE_WEBHOOK_SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VATNODE_WEBHOOK_SECRET&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;VATNODE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;VATNODE_WEBHOOK_SECRET is not set&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;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// Register the raw-body route before any app.use(express.json()).&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;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/vatnode&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&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;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;rawBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verifyVatnodeSignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&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="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;X-Vatnode-Signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;VATNODE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ok&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;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="kd"&gt;as &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="kr"&gt;string&lt;/span&gt;
    &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// record event.id and enqueue your work in one step (see dedupe below)&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;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&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;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&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="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next.js route handlers give you the raw body by default – &lt;code&gt;request.text()&lt;/code&gt; returns the exact string that arrived, with no parsing step in between:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;verifyVatnodeSignature&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./verify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VATNODE_WEBHOOK_SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VATNODE_WEBHOOK_SECRET&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;VATNODE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;VATNODE_WEBHOOK_SECRET is not set&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&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;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawBody&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&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;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verifyVatnodeSignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-vatnode-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;VATNODE_WEBHOOK_SECRET&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;as &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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&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;Whatever the framework, verify the bytes you received, never a re-serialized copy. If something upstream parses the body before your code sees it, you need the original string preserved somewhere, not just the parsed object.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timestamp tolerance and replay protection
&lt;/h2&gt;

&lt;p&gt;vatnode does not enforce any tolerance on &lt;code&gt;t&lt;/code&gt; itself – a signature stays mathematically valid forever unless your verifier rejects it based on age. That check turns a signature from ‘proves the sender holds the key’ into ‘proves the sender held the key recently’, and it’s entirely your responsibility.&lt;/p&gt;

&lt;p&gt;Compare &lt;code&gt;t&lt;/code&gt; against your own clock and reject anything too far off – the documented tolerance is 300 seconds (5 minutes). Use the absolute difference so the bound is symmetric: a delivery is accepted only within 5 minutes either side of your clock, and clock skew in either direction fails visibly instead of quietly widening the window. (&lt;code&gt;t&lt;/code&gt; is part of the signed message, so nobody can move it without breaking the HMAC.)&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nowSeconds&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;TOLERANCE_SECONDS&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each delivery attempt – including retries of the same event – is re-signed with a fresh &lt;code&gt;t&lt;/code&gt; at send time; the body bytes stay identical across attempts, only the timestamp and signature change. That’s why dedupe has to key on the envelope’s &lt;code&gt;id&lt;/code&gt;, not on &lt;code&gt;t&lt;/code&gt; or on the signature value: a retried event is a legitimately different signature for the same logical event. Together, the tolerance check and &lt;code&gt;id&lt;/code&gt;-based dedupe are what make a captured delivery unusable if it’s replayed later.&lt;/p&gt;

&lt;p&gt;Keep seen &lt;code&gt;id&lt;/code&gt;s for at least as long as your tolerance window (longer is fine). Record the &lt;code&gt;id&lt;/code&gt; in the same transaction as the work it triggers, or when you enqueue that work: if you mark it seen first and then crash and return a 5xx, the retry is deduped away and the event is lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 401 trap: how a verifier bug silently drops events
&lt;/h2&gt;

&lt;p&gt;This is the failure mode that costs the most and gets noticed last. vatnode’s delivery model treats any 4xx response other than 429 as a &lt;strong&gt;permanent&lt;/strong&gt; failure – not retried, full stop. Only 5xx, 429, and network/timeout errors get retried (up to 5 attempts, exponential backoff of 1s/2s/4s/8s between them). There’s no redelivery or replay endpoint on the API, so a verifier that incorrectly returns 401 or 400 on a signature that was actually valid doesn’t get a second chance – that event is gone.&lt;/p&gt;

&lt;p&gt;Returning a 4xx for a genuinely invalid signature is correct – it tells vatnode not to waste retries on a request that will never verify. The trap is a verifier bug that returns the same 4xx on signatures that are actually valid: a wrong secret loaded from an env var, a proxy that re-encodes the body, a clock far enough out of sync to exceed your tolerance window. From vatnode’s side that looks identical to a deliberate rejection, so it’s never retried and nothing flags it as a bug on your end.&lt;/p&gt;

&lt;p&gt;The same goes for redirects: a 3xx response is never followed and counts as a permanent failure, so a trailing-slash or HTTP-to-HTTPS redirect in front of your endpoint loses events the same way.&lt;/p&gt;

&lt;p&gt;It compounds: enough consecutive definitive failures (5), or 3 days of continuous failure, and vatnode auto-disables the webhook – at that point you’re missing everything until you notice and re-enable it with a passing test.&lt;/p&gt;

&lt;p&gt;Only return a 2xx after verification succeeds, and investigate every rejection in production rather than just logging it. A verifier that wrongly rejects valid traffic is exactly the bug a test event is supposed to catch before it touches anything real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing your verifier before it goes live
&lt;/h2&gt;

&lt;p&gt;Every new webhook starts in a &lt;code&gt;disabled&lt;/code&gt; state and only becomes &lt;code&gt;active&lt;/code&gt; once a test delivery succeeds – a &lt;code&gt;2xx&lt;/code&gt; response is what flips it. If you created the webhook from the dashboard, it sends that test automatically right after creation. If you created it through the API, call &lt;code&gt;POST /v1/webhooks/{id}/test&lt;/code&gt; yourself to trigger one.&lt;/p&gt;

&lt;p&gt;The test event (&lt;code&gt;event: "webhook.test"&lt;/code&gt;) is signed exactly like a real one – same header format, same HMAC construction, same raw-body requirement. That makes it a real end-to-end check of your verifier, not a stub: if your verifier has a bug that rejects valid signatures, the test delivery fails the same way a real one would, and the webhook stays &lt;code&gt;disabled&lt;/code&gt; instead of silently losing production events. Catching that during setup, against a webhook nothing depends on yet, is a lot cheaper than catching it after &lt;code&gt;VAT_BECAME_INVALID&lt;/code&gt; stopped arriving for a week.&lt;/p&gt;

&lt;p&gt;Webhooks and VAT monitoring both need a paid plan – the free plan runs on 100 requests/month with no card required, but has no monitoring subscriptions and no webhook slots at all. See &lt;a href="https://vatnode.dev/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; for what each paid plan includes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rotating the signing key
&lt;/h2&gt;

&lt;p&gt;There’s no rotation endpoint – a webhook’s signing key is fixed for that webhook’s entire lifetime. Rotating means creating a new webhook rather than updating an existing one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a second webhook pointing at the same URL with &lt;code&gt;POST /v1/webhooks&lt;/code&gt; and store the new &lt;code&gt;secret&lt;/code&gt; from the response. There’s no uniqueness constraint on URL, so two webhooks can target the same endpoint at once – you’ll need a free slot under your plan’s webhook limit.&lt;/li&gt;
&lt;li&gt;Deploy your verifier accepting either the old or the new key, and keep deduping on the envelope’s &lt;code&gt;id&lt;/code&gt; – during the overlap, the same logical event can arrive twice, once signed with each key.&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;POST /v1/webhooks/{id}/test&lt;/code&gt; for the new webhook. It stays &lt;code&gt;disabled&lt;/code&gt; until that test passes, so do this only after step 2 is live.&lt;/li&gt;
&lt;li&gt;Once the new webhook is &lt;code&gt;active&lt;/code&gt;, delete the old one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Deliveries already queued for retry at the moment you delete the old webhook were signed with the key snapshot taken at fan-out time, so a few old-key deliveries can still land afterward. Retries normally finish within a few minutes, but a queued delivery can be delayed further if delivery is backed up. That’s expected: keep the old key in your accepted set for a generous window – an hour is plenty – unless the key leaked.&lt;/p&gt;

&lt;p&gt;If you’re rotating because the key leaked, skip the overlap: delete the old webhook and remove the old key from your verifier immediately. The few retries still in flight will be rejected, which is the price of not accepting forged requests.&lt;/p&gt;

&lt;p&gt;Managing more than one webhook – which rotation requires, even briefly – needs the API; the dashboard’s webhook UI manages a single webhook. The management endpoints (&lt;code&gt;/v1/webhooks*&lt;/code&gt;) accept either a dashboard session or a live API key; a test key gets &lt;code&gt;403 TEST_KEY_NOT_ALLOWED&lt;/code&gt;, because webhooks deliver real HTTP callbacks to a real endpoint, not fixtures.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Why does my webhook endpoint need the raw request body, not the parsed JSON?
&lt;/h3&gt;

&lt;p&gt;Because the signature covers exact bytes, not parsed data. vatnode signs &lt;code&gt;${t}.${rawBody}&lt;/code&gt;, where &lt;code&gt;rawBody&lt;/code&gt; is the compact JSON string it sent on the wire. If you reconstruct that string yourself – especially with a different serializer, such as re-encoding in a language whose default JSON writer inserts different spacing – you can produce different bytes than the ones that were actually signed, and the signature will never match even though the payload means the same thing. Verify against the exact body you received, not a round-tripped copy.&lt;/p&gt;

&lt;h3&gt;
  
  
  What timestamp tolerance should I use when verifying a vatnode webhook signature?
&lt;/h3&gt;

&lt;p&gt;vatnode does not enforce a tolerance itself – that check is entirely up to your verifier. Reject the request when the absolute difference between the current time and the header’s &lt;code&gt;t&lt;/code&gt; exceeds 300 seconds (5 minutes). Use absolute difference, not just whether &lt;code&gt;t&lt;/code&gt; is in the past, so the window is bounded in both directions and clock skew can’t stretch it.&lt;/p&gt;

&lt;h3&gt;
  
  
  If my verifier wrongly rejects a valid signature, will vatnode retry the event?
&lt;/h3&gt;

&lt;p&gt;No – a 4xx response other than 429 is treated as a permanent failure and is never retried; only 5xx, 429, and network or timeout errors are. There is no redelivery or replay endpoint, so a verifier bug that returns 401 on a valid signature loses that event for good. Enough consecutive failures, or three days of continuous failure, disables the webhook entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I rotate a webhook’s signing key without missing events?
&lt;/h3&gt;

&lt;p&gt;There is no rotation endpoint – a signing key is fixed to its webhook for that webhook’s lifetime. To rotate, create a second webhook pointing at the same URL (there is no uniqueness constraint on URL) and get its new key into your verifier. Accept either key for an overlap window of about an hour, dedupe on the event &lt;code&gt;id&lt;/code&gt;, then delete the old webhook. Already-queued retries were signed with the key snapshot at fan-out, so a few old-key deliveries can still arrive for a while after deletion – that’s expected, not a bug. If the key leaked, skip the overlap and drop the old key at once.&lt;/p&gt;

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

&lt;p&gt;Full endpoint and payload reference lives in &lt;a href="https://vatnode.dev/docs/vat-monitoring" rel="noopener noreferrer"&gt;VAT monitoring&lt;/a&gt;; the end-to-end setup guide for monitoring alerts and webhook delivery is at &lt;a href="https://vatnode.dev/guides/vat-monitoring-webhooks" rel="noopener noreferrer"&gt;VAT monitoring and webhooks&lt;/a&gt;. Before you wire real business logic to an event, make sure idempotency and retries on the rest of your VAT stack hold up too – see &lt;a href="https://vatnode.dev/blog/vat-api-idempotency-retries" rel="noopener noreferrer"&gt;idempotency and retries when calling a VAT validation API&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  Create your first webhook
&lt;/h3&gt;

&lt;p&gt;Every webhook starts disabled until a signed test delivery passes – wire up the verifier above first, pick a paid plan to unlock webhook slots, then create the webhook from your &lt;a href="https://vatnode.dev/dashboard/vat-monitoring" rel="noopener noreferrer"&gt;dashboard&lt;/a&gt; and send the test.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://vatnode.dev/pricing" rel="noopener noreferrer"&gt;See plans&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

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