<?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.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>PDF Integrity Report: February 2026</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Sat, 23 May 2026 10:00:26 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/pdf-integrity-report-february-2026-3c8m</link>
      <guid>https://dev.to/iurii_rogulia/pdf-integrity-report-february-2026-3c8m</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/pdf-integrity-report-february-2026" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt;. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every month we look at aggregate, anonymized data from checks processed through the HTPBE? web interface and publish what we find. No file contents, no personally identifiable information — only the structural and metadata signals our algorithm uses to detect modifications.&lt;/p&gt;

&lt;p&gt;February 2026: &lt;strong&gt;418 PDFs&lt;/strong&gt; analyzed through the website, 28 calendar days, steady daily volume.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Top Line
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total PDFs analyzed&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;418&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flagged as modified&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;169 (40.4%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clean&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;249 (59.6%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total data volume&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;210.3 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total pages analyzed&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,902&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two in five PDFs submitted through the website in February showed signs of post-creation modification. That is a higher rate than cross-industry averages suggest — but it reflects the selection bias of fraud detection workflows: people check documents when they have a reason to be concerned.&lt;/p&gt;




&lt;h2&gt;
  
  
  Modification Confidence Distribution
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Confidence level&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;None (no modification detected)&lt;/td&gt;
&lt;td&gt;211&lt;/td&gt;
&lt;td&gt;50.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High (strong structural evidence)&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;5.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100% (cryptographic proof or definitive markers)&lt;/td&gt;
&lt;td&gt;145&lt;/td&gt;
&lt;td&gt;34.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cannot determine (consumer software origin)&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;td&gt;9.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;More than a third of all uploaded PDFs carried 100% modification confidence — meaning the evidence was unambiguous, not probabilistic. These documents carry stacked forensic signals — a date mismatch, incremental update artifacts, and tool-signature inconsistencies.&lt;/p&gt;

&lt;p&gt;Files with high-confidence (but not 100%) findings deserve attention: 24 files, 5.7% of the total. These documents show strong structural evidence — suspicious fields, questionable timestamps — but no single finding rises to the level of cryptographic proof. In a compliance workflow, these warrant manual review.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Modifications Are Detected
&lt;/h2&gt;

&lt;p&gt;Among the 169 flagged files, the algorithm identified the following signals:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Detection signal(s)&lt;/th&gt;
&lt;th&gt;Files&lt;/th&gt;
&lt;th&gt;% of modified&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Modification date differs (only)&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;td&gt;34.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Incremental updates + modification date differs&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;18.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Incremental updates (only)&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;8.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Incremental updates + suspicious update pattern&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;8.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No explicit signal (rule-based verdict)&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;8.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All three: incremental + suspicious + date&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;4.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invalid date sequence + anomalies + date differs&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;3.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool signature mismatch combinations&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;4.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The single most common detection signal — appearing in &lt;strong&gt;62% of flagged files&lt;/strong&gt; — is a discrepancy between the embedded creation and modification timestamps. A document edited in an external tool will often have its modification date updated while the original creation date remains as set by the authoring software. This divergence, when combined with other signals, becomes a strong forensic indicator.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Incremental updates&lt;/strong&gt; were detected in 97 files (23.2% of all February checks). This is the PDF mechanism that allows appending content — annotations, form data, revised pages — without rewriting the file. Among those 97 files, the average update chain length was &lt;strong&gt;2.6 revisions&lt;/strong&gt;. Crucially, 59 of those 97 files (60.8%) were also classified as modified. The remaining 40% showed incremental updates consistent with legitimate workflows: annotations, digital signatures, or form completion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Critical modification markers across all flagged files:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Different creation and modification dates — &lt;strong&gt;113 files&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Multiple cross-reference tables (incremental updates) — &lt;strong&gt;40 files&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Known PDF editing tool detected — &lt;strong&gt;15 files&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;




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

&lt;p&gt;PDF metadata reveals which software created and last touched a document. February showed a clearly Microsoft-centric picture, with significant freelance-platform presence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Top producers&lt;/strong&gt; (the application that last wrote the file):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Producer&lt;/th&gt;
&lt;th&gt;Files&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://htpbe.tech/statistics/" rel="noopener noreferrer"&gt;Microsoft: Print To PDF&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;5.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://htpbe.tech/statistics/" rel="noopener noreferrer"&gt;PDFium&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;4.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://htpbe.tech/statistics/" rel="noopener noreferrer"&gt;mPDF 8.2.5&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;4.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://htpbe.tech/statistics/" rel="noopener noreferrer"&gt;Upwork&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;3.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://htpbe.tech/statistics/" rel="noopener noreferrer"&gt;Microsoft® Word for Microsoft 365&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;2.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://htpbe.tech/statistics/" rel="noopener noreferrer"&gt;iLovePDF&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;2.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://htpbe.tech/statistics/" rel="noopener noreferrer"&gt;Style Report&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;2.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://htpbe.tech/statistics/" rel="noopener noreferrer"&gt;OpenPDF 1.3.26&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;2.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://htpbe.tech/statistics/" rel="noopener noreferrer"&gt;PDFsharp 1.50&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;2.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Top creators&lt;/strong&gt; (the original authoring application):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Creator&lt;/th&gt;
&lt;th&gt;Files&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://htpbe.tech/statistics/" rel="noopener noreferrer"&gt;PDFium&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;5.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://htpbe.tech/statistics/" rel="noopener noreferrer"&gt;Upwork&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;3.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://htpbe.tech/statistics/" rel="noopener noreferrer"&gt;Microsoft® Word 2016&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;3.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://htpbe.tech/statistics/" rel="noopener noreferrer"&gt;Microsoft® Word for Microsoft 365&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;3.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://htpbe.tech/statistics/" rel="noopener noreferrer"&gt;Style Report&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;2.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://htpbe.tech/statistics/" rel="noopener noreferrer"&gt;PDFsharp 1.50&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;2.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://htpbe.tech/statistics/" rel="noopener noreferrer"&gt;PScript5.dll Version 5.2.2&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;2.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://htpbe.tech/statistics/" rel="noopener noreferrer"&gt;Chromium&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;2.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://htpbe.tech/statistics/" rel="noopener noreferrer"&gt;Microsoft Word&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://htpbe.tech/statistics/" rel="noopener noreferrer"&gt;Microsoft® Word 2019&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;1.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Several patterns worth noting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Microsoft Word fragments into multiple entries.&lt;/strong&gt; Word 2016, Word 2019, Word for Microsoft 365, and the generic “Microsoft Word” string together account for 41 files — the single largest authoring platform if consolidated. Organizations upgrading their Office installations leave version-heterogeneous document archives, and all of those versions end up in fraud detection queues.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;iLovePDF in the producer field&lt;/strong&gt; signals documents that were processed through an online PDF manipulation service after their original creation. When a file lists iLovePDF as producer but names Microsoft Word or Chromium as creator, the document went through an intermediate editing step that the creator field does not acknowledge. Eleven files carried this pattern in February.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Upwork appears in both creator and producer&lt;/strong&gt; (16 files each). The Upwork platform generates its own PDFs — contracts, payment statements, work history reports — and they are being submitted for authenticity analysis by counterparties before acting on them. This reflects a real-world use case: recipients checking freelance platform documents before releasing funds or signing agreements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;mPDF 8.2.5&lt;/strong&gt; (18 files as producer) is a PHP PDF library used by web applications to generate invoices, receipts, and reports programmatically. These are application-generated documents, not user-authored files — which makes any structural inconsistency more notable, since they should be templated and uniform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PDFium&lt;/strong&gt; appearing in both creator and producer (20 and 21 files respectively) reflects Chrome-based PDF generation — printouts from web applications, saved browser pages, Google Docs exports.&lt;/p&gt;




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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;PDF Version&lt;/th&gt;
&lt;th&gt;Files&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1.7&lt;/td&gt;
&lt;td&gt;154&lt;/td&gt;
&lt;td&gt;36.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.4&lt;/td&gt;
&lt;td&gt;113&lt;/td&gt;
&lt;td&gt;27.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.5&lt;/td&gt;
&lt;td&gt;66&lt;/td&gt;
&lt;td&gt;15.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.6&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;8.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.3&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;8.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.0&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.2&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invalid/missing&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;1.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;PDF 1.7 leads at 36.8%, with 1.4 a strong second at 27%. Together they account for nearly two thirds of the sample. PDF 2.0 — the ISO 32000-2 standard from 2017 — appears in just 3 files (0.7%), reflecting how slowly the ecosystem adopts new specifications.&lt;/p&gt;

&lt;p&gt;Seven files had an invalid or unparseable version string. A well-formed PDF should always declare its version in the file header; losing this field is a sign of either corruption or aggressive editing that stripped the header.&lt;/p&gt;




&lt;h2&gt;
  
  
  Digital Signatures: Present but Not Protective
&lt;/h2&gt;

&lt;p&gt;11 PDFs carried embedded digital signatures (2.6% of the total). Of those, &lt;strong&gt;3 had been modified after the signature was applied&lt;/strong&gt; — a 27.3% post-signature modification rate among signed documents.&lt;/p&gt;

&lt;p&gt;The mechanism most commonly exploited here is incremental updates. The PDF specification permits content to be appended after a signature is applied, provided the additions are limited to explicitly permitted operations. Some editors exploit the ambiguity of what constitutes a “permitted” change to introduce substantive content modifications — revised figures, changed dates, altered party names — while preserving a signature that remains cryptographically valid within its original scope.&lt;/p&gt;

&lt;p&gt;The result: a document that displays a valid signature indicator in a viewer, but whose content has changed since signing. The signature covers what it covered when it was applied; it does not cover what was added afterward.&lt;/p&gt;

&lt;p&gt;In practice, most organizations treat the presence of a signature field as sufficient fraud detection. Active signature validation — which would surface these post-signature modifications — is rarely performed outside of legal and financial workflows with formal fraud detection requirements.&lt;/p&gt;




&lt;h2&gt;
  
  
  Document Profile
&lt;/h2&gt;

&lt;p&gt;The average PDF checked through the website in February:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Average size:&lt;/strong&gt; 0.50 MB&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Largest file:&lt;/strong&gt; 9.70 MB&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Average page count:&lt;/strong&gt; 4 pages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total pages analyzed:&lt;/strong&gt; 1,902&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The half-megabyte average is consistent with the document types typically submitted for fraud detection: invoices, contracts, bank statements, certificates. Short documents with specific numerical or legal content — where a changed figure or date has real financial or legal consequence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Metadata completeness&lt;/strong&gt; averaged &lt;strong&gt;76 out of 100&lt;/strong&gt;. The score measures how many of the eight standard PDF metadata fields (title, author, creator, producer, creation date, modification date, subject, keywords) are populated. Missing creation dates affected &lt;strong&gt;53 files (12.7%)&lt;/strong&gt; — removing one of the cleaner forensic signals and increasing reliance on structural analysis.&lt;/p&gt;




&lt;h2&gt;
  
  
  Daily Volume
&lt;/h2&gt;

&lt;p&gt;Usage was steady throughout February, without dramatic spikes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Feb 06: 28    Feb 14: 11    Feb 22:  1
Feb 07: 10    Feb 15: 20    Feb 23: 24
Feb 08:  1    Feb 16: 25    Feb 24: 12
Feb 09: 11    Feb 17: 28    Feb 25: 47
Feb 10: 25    Feb 18: 11    Feb 26: 20
Feb 11: 16    Feb 19: 20    Feb 27: 24
Feb 12: 32    Feb 20: 14    Feb 28:  4
Feb 13: 20    Feb 21: 14
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The peak day was &lt;strong&gt;February 25 with 47 checks&lt;/strong&gt; — roughly 1.7× the monthly daily average of 27.5. No batch processing, no anomalous spikes. The distribution reflects organic usage: higher on weekdays, quieter on weekends, with the first week of the month running slightly lighter than the rest.&lt;/p&gt;




&lt;h2&gt;
  
  
  Other Signals
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;JavaScript in PDFs:&lt;/strong&gt; zero across all 418 files. No embedded JavaScript was detected in February. This is consistent with the document types: invoices, contracts, and certificates do not use interactive scripting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embedded files:&lt;/strong&gt; 4 (less than 1%). PDFs can contain binary attachments. Four documents carried embedded content. Not unusual, but worth flagging in any workflow where file attachments introduce compliance risk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Suspicious tool patterns:&lt;/strong&gt; 50 files (12.0%). This flag indicates that the creator–producer metadata combination is internally inconsistent in ways that suggest an unacknowledged intermediate processing step. The file claims a creation toolchain that does not match its structural fingerprint.&lt;/p&gt;




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

&lt;p&gt;February 2026 by the numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;40.4% of submitted PDFs&lt;/strong&gt; showed modification signals — significantly above the commonly cited 25–30% industry baseline, consistent with the self-selection of fraud detection workflows&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Modification date discrepancy&lt;/strong&gt; is the leading forensic indicator, present in 62% of flagged files&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Microsoft Office ecosystem&lt;/strong&gt; (Word across multiple versions, Print to PDF) is the primary authoring environment in this sample&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iLovePDF and online editors&lt;/strong&gt; leave traceable producer-field evidence in files that subsequently pass through fraud detection&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upwork documents&lt;/strong&gt; are a recurring fraud detection target — freelance contracts and payment records being checked by counterparties&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Digital signatures do not guarantee post-signature integrity&lt;/strong&gt; — 27% of signed files in this sample were modified after signing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDF 2.0 adoption remains below 1%&lt;/strong&gt; despite being available for nearly a decade&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 40.4% modification rate is the most important number from February. It means that when someone uploads a PDF to check its authenticity, there is more than a one-in-three chance the document will come back flagged. That is not a marginal outcome — it is why fraud detection workflows exist.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Data covers all checks submitted through the HTPBE? web interface in February 2026 (UTC). File contents are not stored or analyzed; only structural metadata signals are retained. All figures are aggregate and anonymized.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>fraud</category>
      <category>webdev</category>
    </item>
    <item>
      <title>EU VAT Rate Types Explained: Standard, Reduced, Super-Reduced, Parking, and Territorial Rates</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Sat, 23 May 2026 09:00:27 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/eu-vat-rate-types-explained-standard-reduced-super-reduced-parking-and-territorial-rates-62m</link>
      <guid>https://dev.to/iurii_rogulia/eu-vat-rate-types-explained-standard-reduced-super-reduced-parking-and-territorial-rates-62m</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/eu-vat-rate-types" 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;EU VAT is not a single number per country. Each member state can have up to four different rate types, and several countries have autonomous regions or overseas territories where completely different rules apply — or where VAT does not exist at all. This guide covers every rate type, when it applies, why it exists, and which territories you need to watch out for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four EU VAT rate types
&lt;/h2&gt;

&lt;p&gt;The EU VAT Directive (2006/112/EC) defines four possible rate categories. No country uses all four — most use two or three. Here is what each means, when it applies, and why it exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  Standard rate
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;Universal · 17%–27%&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The default rate applied to all goods and services that do not fall into a special category. Every EU member state must have a standard rate of at least 15% (in practice the minimum today is 17% in Luxembourg, the maximum 27% in Hungary). If you are unsure which rate applies, use the standard rate.&lt;/p&gt;

&lt;p&gt;Typical examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Software licences&lt;/li&gt;
&lt;li&gt;Consulting services&lt;/li&gt;
&lt;li&gt;Electronics&lt;/li&gt;
&lt;li&gt;Clothing&lt;/li&gt;
&lt;li&gt;Most B2B services&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Legal basis: Art. 96–97, VAT Directive 2006/112/EC&lt;/p&gt;

&lt;h3&gt;
  
  
  Reduced rate
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;Common · 5%–18%&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A lower rate — at least 5% — that EU countries may apply to a defined list of goods and services set out in Annex III of the VAT Directive. Countries can have up to two different reduced rates. The list covers categories considered socially or economically important: food, medicines, books, passenger transport, hotel accommodation, cultural events, and more.&lt;/p&gt;

&lt;p&gt;Typical examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Food and non-alcoholic drinks&lt;/li&gt;
&lt;li&gt;Books, newspapers, e-books&lt;/li&gt;
&lt;li&gt;Medicines and medical devices&lt;/li&gt;
&lt;li&gt;Passenger transport&lt;/li&gt;
&lt;li&gt;Hotel accommodation&lt;/li&gt;
&lt;li&gt;Renovation of residential property&lt;/li&gt;
&lt;li&gt;Cultural and sports services&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Legal basis: Art. 98–99 and Annex III, VAT Directive 2006/112/EC&lt;/p&gt;

&lt;h3&gt;
  
  
  Super-reduced rate
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;Limited countries · Below 5%&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;An exceptionally low rate, below 5%, that only a handful of EU states are permitted to keep as a historical exception. These rates predate the EU's 1993 VAT harmonisation and are grandfathered in — new member states cannot introduce them. They typically apply to the most essential goods: certain foods, newspapers, medicines, or tickets to cultural events.&lt;/p&gt;

&lt;p&gt;Typical examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;France: 2.1% — certain medicines reimbursed by social security; press publications&lt;/li&gt;
&lt;li&gt;Spain: 4% — basic foodstuffs (bread, milk, cheese, eggs); books and newspapers; medicines&lt;/li&gt;
&lt;li&gt;Italy: 4% — basic foodstuffs; books; certain agricultural products&lt;/li&gt;
&lt;li&gt;Cyprus: 3% — certain foodstuffs; books and newspapers&lt;/li&gt;
&lt;li&gt;Luxembourg: 3% — foodstuffs; certain printed matter; children's shoes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Legal basis: Art. 110 (standstill clause), VAT Directive 2006/112/EC&lt;/p&gt;

&lt;h3&gt;
  
  
  Parking rate
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;Transitional · ≥ 12%&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A parking rate is a transitional measure introduced when the EU harmonised VAT in 1993. Before harmonisation, many countries applied reduced rates to goods and services that the Sixth VAT Directive (77/388/EEC) did not include in Annex III — meaning those items would have needed to jump straight to the full standard rate. Rather than forcing an abrupt increase, the EU allowed states to "park" those items at an intermediate rate of at least 12% until they could be phased out or reassigned. Decades later, several countries still use it.&lt;/p&gt;

&lt;p&gt;Typical examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Belgium 12%: certain fuels for heating and agricultural use; cut flowers; restaurant services&lt;/li&gt;
&lt;li&gt;Greece 13%: selected agricultural goods; some printed publications&lt;/li&gt;
&lt;li&gt;Luxembourg 14%: wine; certain print materials; firewood&lt;/li&gt;
&lt;li&gt;Malta 12%: certain printed matter; certain confectionery items&lt;/li&gt;
&lt;li&gt;Portugal 13%: wine; certain animal feed; some agricultural inputs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Legal basis: Art. 103 (parking rate) and Art. 109–110, VAT Directive 2006/112/EC&lt;/p&gt;

&lt;h2&gt;
  
  
  The parking rate in depth
&lt;/h2&gt;

&lt;p&gt;Of the four rate types, the parking rate is the least understood and the most likely to confuse developers encountering it for the first time. Here is the full picture.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where does it come from?
&lt;/h3&gt;

&lt;p&gt;When the EU adopted the Sixth VAT Directive in 1977 and then completed the single market in 1993, it defined a fixed list (Annex III) of goods and services that could qualify for a reduced rate. Many member states had existing reduced rates on items not on that list — cutting flowers, wine, certain fuels, printed advertising material, among others.&lt;/p&gt;

&lt;p&gt;Rather than forcing an immediate jump to the full standard rate (which could have been politically and commercially disruptive), the directive allowed states to &lt;em&gt;park&lt;/em&gt; those items at an intermediate rate of at least 12% while they gradually aligned their rules. The word &lt;strong&gt;"parking"&lt;/strong&gt; is literal — the rate is parked there, in a transitional state, until the country moves it up to the standard rate or the EU adds the item to Annex III.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does it still exist in 2026?
&lt;/h3&gt;

&lt;p&gt;The transition was never completed. Five EU countries still use the parking rate: Belgium, Greece, Luxembourg, Malta, and Portugal. Each has found that the political will to remove the rate entirely is weaker than the pressure from the industries benefiting from it. The EU has not imposed a deadline for abolition.&lt;/p&gt;

&lt;p&gt;From a legal standpoint, the parking rate is allowed as a derogation under Article 103 of the VAT Directive. The minimum is 12%; there is no maximum other than the standard rate. Luxembourg uses 14%, Portugal and Greece 13%, Belgium and Malta 12%.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to handle it as a developer
&lt;/h3&gt;

&lt;p&gt;Most SaaS and e-commerce products never need to worry about parking rates — they apply to physical goods in very specific categories. But if you are building a general-purpose VAT calculation engine or a tax classification tool, you need to be aware that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A country's "reduced rates" array in the vatnode API or eu-vat-rates-data package does NOT include the parking rate — it has its own parkingRate field.&lt;/li&gt;
&lt;li&gt;Parking rates are listed separately in the EC TEDB database under the rate type REDUCED/PARKING_RATE.&lt;/li&gt;
&lt;li&gt;The parking rate is never the rate that applies by default to unclassified goods — that is always the standard rate.&lt;/li&gt;
&lt;li&gt;If you display a country's rates to users, show the parking rate only if you support the specific goods categories it covers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Territorial exceptions — where rates differ
&lt;/h2&gt;

&lt;p&gt;Several EU member states have overseas territories, autonomous regions, or special enclaves where the normal VAT rules do not apply. There are two distinct situations:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Outside the EU VAT area&lt;/strong&gt; — Transactions with these territories are treated as imports or exports. EU VAT does not apply — the territory has its own local tax system (or none at all).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inside the EU VAT area — lower rates&lt;/strong&gt; — The territory is in the EU VAT area and follows standard VAT rules (invoicing, registration, recovery), but the rates themselves are lower than the mainland. Article 105 of the VAT Directive permits this for ultra-peripheral regions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Spain
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Canary Islands
&lt;/h4&gt;

&lt;p&gt;&lt;em&gt;Outside EU VAT area&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Tax system: IGIC — Impuesto General Indirecto Canario&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Standard: 7%&lt;/li&gt;
&lt;li&gt;Reduced: 3%&lt;/li&gt;
&lt;li&gt;Super-reduced: 0%&lt;/li&gt;
&lt;li&gt;Increased: 9.5%, 15%&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Canary Islands are geographically located off the African coast and are excluded from the EU VAT area under Annex I of the VAT Directive. Goods shipped from mainland Spain or from another EU country to the Canary Islands are treated as exports — no EU VAT is charged, but IGIC applies on arrival. The rate is dramatically lower than the mainland standard of 21%, making the islands a popular destination for duty-free shopping.&lt;/p&gt;

&lt;p&gt;EU VAT does not apply — do not charge or show VAT on invoices for this territory.&lt;/p&gt;

&lt;h4&gt;
  
  
  Ceuta &amp;amp; Melilla
&lt;/h4&gt;

&lt;p&gt;&lt;em&gt;Outside EU VAT area&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Tax system: IPSI — Impuesto sobre la Producción, los Servicios y la Importación&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Range: 0.5%–10%&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These Spanish enclaves on the northern coast of Morocco are neither in the EU customs territory nor the EU VAT area. They levy IPSI at very low rates depending on the type of good or service. Transactions with Ceuta and Melilla are treated as imports/exports for VAT purposes.&lt;/p&gt;

&lt;p&gt;EU VAT does not apply — do not charge or show VAT on invoices for this territory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Portugal
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Azores
&lt;/h4&gt;

&lt;p&gt;&lt;em&gt;Inside EU VAT area — lower rates&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Tax system: Portuguese VAT (IVA) at reduced regional rates&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Standard: 16%&lt;/li&gt;
&lt;li&gt;Intermediate: 9%&lt;/li&gt;
&lt;li&gt;Reduced: 4%&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Azores are an autonomous region within the EU VAT area. Article 105 of the VAT Directive allows the Portuguese legislature to apply lower VAT rates to autonomous ultra-peripheral regions. The Azores standard rate of 16% compares to 23% on the mainland — a reduction of 7 percentage points. The intermediate rate of 9% corresponds to the mainland's 13%, and the reduced rate of 4% to the mainland's 6%. Businesses operating in or shipping to the Azores must apply Azorean rates.&lt;/p&gt;

&lt;p&gt;EU VAT invoicing rules apply — include VAT number and rate on invoices.&lt;/p&gt;

&lt;h4&gt;
  
  
  Madeira
&lt;/h4&gt;

&lt;p&gt;&lt;em&gt;Inside EU VAT area — lower rates&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Tax system: Portuguese VAT (IVA) at reduced regional rates&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Standard: 22%&lt;/li&gt;
&lt;li&gt;Intermediate: 12%&lt;/li&gt;
&lt;li&gt;Reduced: 5%&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Madeira is another Portuguese ultra-peripheral region with the same legal basis as the Azores but with slightly different rates — standard 22% vs 16% in the Azores. The difference reflects regional policy choices. Like the Azores, Madeira is fully in the EU VAT area, so regular VAT rules on invoicing, registration, and input tax recovery apply.&lt;/p&gt;

&lt;p&gt;EU VAT invoicing rules apply — include VAT number and rate on invoices.&lt;/p&gt;

&lt;h3&gt;
  
  
  France
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Guadeloupe, Martinique, Réunion
&lt;/h4&gt;

&lt;p&gt;&lt;em&gt;Inside EU VAT area — lower rates&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Tax system: French VAT (TVA) at DOM rates&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Standard: 8.5%&lt;/li&gt;
&lt;li&gt;Reduced: 2.1%&lt;/li&gt;
&lt;li&gt;Intermediate: 3.74%&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These French overseas departments (DOM — Départements d'Outre-Mer) are part of the EU VAT area as ultra-peripheral regions. Article 105 of the VAT Directive permits France to apply lower rates. The standard rate of 8.5% is less than half of the French mainland rate of 20%. Supplies from mainland France to DOM territories are treated as intra-EU supplies; the DOM rate applies at the destination.&lt;/p&gt;

&lt;p&gt;EU VAT invoicing rules apply — include VAT number and rate on invoices.&lt;/p&gt;

&lt;h4&gt;
  
  
  French Guiana &amp;amp; Mayotte
&lt;/h4&gt;

&lt;p&gt;&lt;em&gt;Outside EU VAT area&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Tax system: No VAT&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;VAT: Not applicable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;French Guiana (on the South American mainland) and Mayotte (an island in the Indian Ocean) are explicitly excluded from the EU VAT area under Annex I of the VAT Directive. No VAT is levied on goods or services in these territories. Transactions are treated as exports from the EU perspective.&lt;/p&gt;

&lt;p&gt;EU VAT does not apply — do not charge or show VAT on invoices for this territory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Greece
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Selected Aegean islands
&lt;/h4&gt;

&lt;p&gt;&lt;em&gt;Inside EU VAT area — 30% reduction&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Tax system: Greek VAT (ΦΠΑ) at island rates&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Standard: 17% (vs 24%)&lt;/li&gt;
&lt;li&gt;Reduced: 9% (vs 13%)&lt;/li&gt;
&lt;li&gt;Super-reduced: 3% (vs 4%)&lt;/li&gt;
&lt;li&gt;Parking: 9% (vs 13%)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Certain islands in the North Aegean and Dodecanese groups — including Lesvos, Chios, Samos, Ikaria, Rhodes, and Kos — benefit from a 30% reduction on all Greek VAT rates. The measure was originally introduced to support the economies of islands close to the Turkish border. It has been renewed repeatedly and remains in force. The reduced rates apply to supplies made within the island; goods shipped from the mainland are generally subject to mainland rates.&lt;/p&gt;

&lt;p&gt;EU VAT invoicing rules apply — include VAT number and rate on invoices.&lt;/p&gt;

&lt;h3&gt;
  
  
  Germany
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Büsingen am Hochrhein &amp;amp; Helgoland
&lt;/h4&gt;

&lt;p&gt;&lt;em&gt;Outside EU VAT area&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Tax system: No German VAT&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;VAT: Not applicable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Büsingen is a German exclave surrounded entirely by Switzerland and is excluded from both the EU customs territory and the EU VAT area. Swiss VAT rules apply in practice. Helgoland is a small island in the North Sea that was kept outside the EU VAT area for historical reasons. Supplies to both territories are treated as exports.&lt;/p&gt;

&lt;p&gt;EU VAT does not apply — do not charge or show VAT on invoices for this territory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Italy
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Livigno &amp;amp; Campione d'Italia
&lt;/h4&gt;

&lt;p&gt;&lt;em&gt;Outside EU VAT area&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Tax system: No VAT&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;VAT: Not applicable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Livigno is a high-altitude ski resort near the Swiss border that has historically enjoyed duty-free status. Campione d'Italia is an Italian exclave inside Switzerland. Both are excluded from the EU VAT area. Goods supplied to Livigno or Campione d'Italia are zero-rated as exports from an Italian VAT perspective.&lt;/p&gt;

&lt;p&gt;EU VAT does not apply — do not charge or show VAT on invoices for this territory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Finland
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Åland Islands
&lt;/h4&gt;

&lt;p&gt;&lt;em&gt;Special fiscal territory&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Tax system: Finnish VAT — but special tax boundary with mainland&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Standard: 25.5% (same as mainland)&lt;/li&gt;
&lt;li&gt;Reduced: 10%, 13.5% (same as mainland)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Åland Islands are in the EU VAT area and apply standard Finnish VAT rates. However, because of their special political autonomy status (a demilitarised, self-governing region under the 1921 League of Nations decision), the EU treats the Finland–Åland border as a fiscal boundary. This means that travel between Åland and mainland Finland counts as an "import/export" for VAT purposes, which is why ferries between Helsinki and Stockholm stopping at Mariehamn can sell duty-free goods. The VAT rates themselves are identical to the Finnish mainland.&lt;/p&gt;

&lt;p&gt;EU VAT invoicing rules apply — include VAT number and rate on invoices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Developer notes: what this means for your API calls
&lt;/h2&gt;

&lt;p&gt;When validating a VAT number or determining rates for a transaction, the country code alone is not always sufficient. Here is what to keep in mind:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Canary Islands, Ceuta, Melilla, Büsingen, Helgoland, Livigno, French Guiana, Mayotte&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;These territories have no EU VAT registration. A business operating exclusively in these areas will not have an EU VAT number (or will have one that reflects the parent country but should not be charged EU VAT for local supplies). The standard country code for the parent state is returned by VIES, but billing software must check the delivery address before applying rates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Portugal: PT-AC (Azores) and PT-MA (Madeira)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;These are valid EU VAT registrations. The Portuguese NIF is the same format across mainland and islands. If your system collects a delivery address, apply Azorean or Madeiran rates when the postal code falls within those regions. The vatnode /rates endpoint returns mainland rates for PT — you need to handle regional logic in your application.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Greece: island rate zone&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is no separate country code or VAT prefix for Greek island rates. The determination is based on the delivery address (postal code / island name). The standard GR country code is used for all Greek VAT numbers regardless of where the business is located.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parking rate classification&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The vatnode API and eu-vat-rates-data package return parkingRate as a dedicated field, separate from reducedRates. Do not merge them. When classifying goods, parking rates apply only to historically grandfathered product categories — they are never a default fallback.&lt;/p&gt;

&lt;p&gt;The vatnode rates endpoint returns all four rate types in a single call. You can also browse current rates interactively in the &lt;a href="https://vatnode.dev/vat-rates" rel="noopener noreferrer"&gt;EU VAT rates tool&lt;/a&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.vatnode.dev/v1/rates/BE

&lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"countryCode"&lt;/span&gt;: &lt;span class="s2"&gt;"BE"&lt;/span&gt;,
  &lt;span class="s2"&gt;"countryName"&lt;/span&gt;: &lt;span class="s2"&gt;"Belgium"&lt;/span&gt;,
  &lt;span class="s2"&gt;"vatName"&lt;/span&gt;: &lt;span class="s2"&gt;"Belasting over de toegevoegde waarde"&lt;/span&gt;,
  &lt;span class="s2"&gt;"vatAbbr"&lt;/span&gt;: &lt;span class="s2"&gt;"BTW"&lt;/span&gt;,
  &lt;span class="s2"&gt;"currency"&lt;/span&gt;: &lt;span class="s2"&gt;"EUR"&lt;/span&gt;,
  &lt;span class="s2"&gt;"standardRate"&lt;/span&gt;: 21,
  &lt;span class="s2"&gt;"reducedRates"&lt;/span&gt;: &lt;span class="o"&gt;[&lt;/span&gt;6, 12],   // note: parkingRate is separate
  &lt;span class="s2"&gt;"superReducedRate"&lt;/span&gt;: null,
  &lt;span class="s2"&gt;"parkingRate"&lt;/span&gt;: 12,          // Belgium&lt;span class="s1"&gt;'s parking rate
  "countryVatUpdatedAt": "2026-04-02"
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h3&gt;
  
  
  What is a parking VAT rate?
&lt;/h3&gt;

&lt;p&gt;The parking rate is a transitional VAT rate that some EU member states kept after the 1993 VAT harmonisation. It applies to goods and services that were at a reduced rate before the EU's Sixth VAT Directive but no longer qualify for a reduced rate under harmonised rules. The parking rate must be at least 12% and is currently used by Belgium, Greece, Luxembourg, Malta, and Portugal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is VAT applied in the Canary Islands?
&lt;/h3&gt;

&lt;p&gt;No. The Canary Islands are part of the EU customs territory but are excluded from the EU VAT area. They use their own indirect tax called IGIC (Impuesto General Indirecto Canario) with a standard rate of 7%, which is much lower than the Spanish mainland VAT of 21%.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do the Azores and Madeira have different VAT rates from mainland Portugal?
&lt;/h3&gt;

&lt;p&gt;Yes. The Azores and Madeira are autonomous regions within the EU VAT area, but EU law permits them to apply lower rates. The Azores have a standard rate of 16% (vs 23% on the mainland) and Madeira 22%. Reduced rates are also lower in both regions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which Greek islands have reduced VAT rates?
&lt;/h3&gt;

&lt;p&gt;Certain Aegean islands close to Turkey — including Lesvos, Chios, Samos, and the Dodecanese — benefit from a 30% reduction on all Greek VAT rates. This gives them a standard rate of 17% instead of 24%, and proportionally lower reduced and parking rates.&lt;/p&gt;

</description>
      <category>tax</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Next.js SaaS Checklist: Launch Production-Ready in 8 Weeks</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Sat, 23 May 2026 07:00:25 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/nextjs-saas-checklist-launch-production-ready-in-8-weeks-79m</link>
      <guid>https://dev.to/iurii_rogulia/nextjs-saas-checklist-launch-production-ready-in-8-weeks-79m</guid>
      <description>&lt;p&gt;I've built several SaaS products. Each time I run through the same checklist. Not because I'm following a template — but because I've paid for skipping items with production incidents, angry customers, and weekends spent fixing what should have been done at the start.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://iurii.rogulia.fi/projects/vatnode-vat-validation" rel="noopener noreferrer"&gt;vatnode.dev&lt;/a&gt; — EU VAT validation SaaS, running in production with 95% Redis cache hit rate. &lt;a href="https://iurii.rogulia.fi/projects/htpbe-pdf-analysis" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt; — PDF forensics SaaS, 5-layer analysis in under 9 seconds. &lt;a href="https://iurii.rogulia.fi/projects/pi-pi-b2b-ecommerce" rel="noopener noreferrer"&gt;pi-pi.ee&lt;/a&gt; — B2B e-commerce across 32 EU markets. All of them went from zero to production in 6–8 weeks. Here's exactly what that takes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack I Start With
&lt;/h2&gt;

&lt;p&gt;Before the checklist, the decision I never revisit: the default stack.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 15&lt;/strong&gt; (App Router) for the web application — Server Components, Server Actions, API routes in one framework&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hono 4&lt;/strong&gt; when I need a dedicated API server (separate deployment, higher performance needs, BullMQ workers)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL&lt;/strong&gt; — the only database I trust for production SaaS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drizzle ORM&lt;/strong&gt; — type-safe, no magic, migrations I understand&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better Auth&lt;/strong&gt; — auth library that handles sessions, OAuth, magic links properly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripe&lt;/strong&gt; — payments, full stop&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resend&lt;/strong&gt; — transactional email&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis (Upstash or self-hosted)&lt;/strong&gt; — rate limiting, queues, caching&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BullMQ&lt;/strong&gt; — background job queue on top of Redis&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vercel&lt;/strong&gt; — hosting, with caveats I'll get to&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentry&lt;/strong&gt; — error tracking&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every project deviation from this stack has cost me time. I now treat any deviation as a decision that requires justification, not exploration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auth Checklist
&lt;/h2&gt;

&lt;p&gt;
  slug="mvp-development"&lt;br&gt;
  text="I build the full SaaS foundation — auth, billing, database, background jobs, monitoring — so you can focus on the product, not the plumbing."&lt;br&gt;
/&amp;gt;&lt;/p&gt;

&lt;p&gt;Auth is where most SaaS projects spend too much time if they roll their own, and too little time if they just copy a tutorial.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;strong&gt;Email + password with bcrypt&lt;/strong&gt; — minimum 12 rounds, store only the hash&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Magic links&lt;/strong&gt; — better UX for B2B tools where users don't want another password&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;OAuth: Google and GitHub&lt;/strong&gt; — covers 80% of developer and startup users&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Session management&lt;/strong&gt; — use database sessions, not JWT-only (you need the ability to revoke)&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Rate limiting on auth endpoints&lt;/strong&gt; — login, register, password reset, magic link send&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Email verification&lt;/strong&gt; — required before first login, not optional&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Password reset flow&lt;/strong&gt; — expiring tokens, single-use&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Account deletion&lt;/strong&gt; — GDPR requirement, not an afterthought&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I use &lt;a href="https://www.better-auth.com/" rel="noopener noreferrer"&gt;Better Auth&lt;/a&gt; on all current projects. It handles sessions, OAuth providers, email verification, and password reset in one library. NextAuth.js is fine, but Better Auth has better TypeScript ergonomics and the plugin model is cleaner.&lt;/p&gt;

&lt;p&gt;Here's the core Better Auth setup I start every project with:&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/auth.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;betterAuth&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;better-auth&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;drizzleAdapter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;better-auth/adapters/drizzle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/packages/db&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;magicLink&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;better-auth/plugins&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;betterAuth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;database&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;drizzleAdapter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;emailAndPassword&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;enabled&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;requireEmailVerification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;socialProviders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;google&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GOOGLE_CLIENT_ID&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;clientSecret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GOOGLE_CLIENT_SECRET&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="na"&gt;github&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GITHUB_CLIENT_ID&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;clientSecret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GITHUB_CLIENT_SECRET&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="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;magicLink&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;sendMagicLink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Resend integration — see Email section below&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendMagicLinkEmail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="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;rateLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;enabled&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;window&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 60 second window&lt;/span&gt;
    &lt;span class="na"&gt;max&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;// 10 requests per window per IP&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;$Infer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better Auth generates the database schema automatically. Run &lt;code&gt;npx better-auth generate&lt;/code&gt; and it outputs the Drizzle migration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to choose NextAuth.js instead:&lt;/strong&gt; if you're on an older Next.js Pages Router project or the team already knows NextAuth.js deeply. For new projects starting today, Better Auth is the better choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Billing Checklist
&lt;/h2&gt;

&lt;p&gt;Stripe is the only option I consider for EU SaaS. The European payment method support, VAT handling, and customer portal are not features you want to rebuild yourself.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;strong&gt;Stripe Customer created on signup&lt;/strong&gt; — immediately, even before the first payment&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Subscription or one-time payments&lt;/strong&gt; — decide upfront, the data model differs significantly&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Webhook handler with idempotency&lt;/strong&gt; — Stripe retries for 72 hours; duplicates are guaranteed without this&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Redis deduplication layer&lt;/strong&gt; — fast check before hitting the database&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Self-service portal&lt;/strong&gt; — &lt;code&gt;stripe.billingPortal.sessions.create()&lt;/code&gt; handles plan changes, cancellations, invoice history&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Free trial logic&lt;/strong&gt; — &lt;code&gt;trial_period_days&lt;/code&gt; in the subscription, enforce feature gating on trial expiry&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Upgrade/downgrade flow&lt;/strong&gt; — use &lt;code&gt;stripe.subscriptions.update()&lt;/code&gt; with &lt;code&gt;proration_behavior: 'create_prorations'&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Invoice generation and delivery&lt;/strong&gt; — Stripe sends invoice PDFs automatically; for custom invoices, see the PDF generation work I described in the &lt;a href="https://iurii.rogulia.fi/blog/ecommerce-order-automation" rel="noopener noreferrer"&gt;order automation pipeline&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Failed payment recovery (dunning)&lt;/strong&gt; — three-email sequence, don't immediately revoke access&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Webhook events to handle&lt;/strong&gt;: &lt;code&gt;payment_intent.succeeded&lt;/code&gt;, &lt;code&gt;customer.subscription.updated&lt;/code&gt;, &lt;code&gt;customer.subscription.deleted&lt;/code&gt;, &lt;code&gt;invoice.payment_failed&lt;/code&gt;, &lt;code&gt;invoice.payment_succeeded&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The idempotency pattern I use — covering both Redis fast-check and PostgreSQL durable record — is documented in detail in &lt;a href="https://iurii.rogulia.fi/blog/stripe-webhooks-production" rel="noopener noreferrer"&gt;Stripe Webhooks Done Right&lt;/a&gt;. I won't repeat it here, but it's non-negotiable: ship it or ship duplicate orders.&lt;/p&gt;

&lt;h2&gt;
  
  
  Database Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;strong&gt;PostgreSQL&lt;/strong&gt; — I've used MySQL and SQLite on old projects; I don't anymore&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Drizzle ORM&lt;/strong&gt; — type-safe queries, plain SQL migrations, no ORM magic&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;&lt;code&gt;created_at&lt;/code&gt; and &lt;code&gt;updated_at&lt;/code&gt; on every table&lt;/strong&gt; — non-negotiable for debugging production issues&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Soft deletes&lt;/strong&gt; — &lt;code&gt;deleted_at timestamp&lt;/code&gt; column instead of hard &lt;code&gt;DELETE&lt;/code&gt;; makes recovery and audit possible&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Migration strategy&lt;/strong&gt; — Drizzle migrations committed to the repo, run on deploy&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Backup policy&lt;/strong&gt; — daily automated backups, tested restore at least once before launch&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Connection pooling&lt;/strong&gt; — PgBouncer or Neon's built-in pooling; raw PostgreSQL connections don't survive serverless&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the table structure I start every project with:&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;// packages/db/schema/base.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;uuid&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;drizzle-orm/pg-core&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;drizzle-orm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Reusable column set — spread into every table definition&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;baseColumns&lt;/span&gt; &lt;span class="o"&gt;=&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="nf"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;primaryKey&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`gen_random_uuid()`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;created_at&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;withTimezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;defaultNow&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;updatedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;updated_at&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;withTimezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defaultNow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;$onUpdate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
  &lt;span class="na"&gt;deletedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deleted_at&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;withTimezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// packages/db/schema/users.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pgTable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;drizzle-orm/pg-core&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;baseColumns&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./base&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;users&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pgTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users&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="nx"&gt;baseColumns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;email&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;emailVerified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email_verified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&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;stripeCustomerId&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe_customer_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;plan&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;plan&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;free&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;planActivatedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;plan_activated_at&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;withTimezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Drizzle vs Prisma:&lt;/strong&gt; I made the switch after Prisma's migration behavior caused a production incident (it tried to recreate an indexed column instead of adding a new one). Drizzle generates raw SQL migrations you can read and verify before running. That's the property I care about most in production. Prisma is friendlier for beginners; Drizzle is what I trust with real data.&lt;/p&gt;

&lt;h2&gt;
  
  
  API Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;strong&gt;Rate limiting per IP&lt;/strong&gt; — protect against unauthenticated abuse&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Rate limiting per API key&lt;/strong&gt; — per-plan limits for authenticated users&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Error responses with machine-readable codes&lt;/strong&gt; — not just HTTP status codes, but &lt;code&gt;{ "error": { "code": "VAT_NUMBER_INVALID", "message": "..." } }&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Cursor-based pagination&lt;/strong&gt; — offset pagination breaks on large datasets and concurrent writes&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;API versioning strategy&lt;/strong&gt; — even if v1 is the only version, establish the URL pattern now (&lt;code&gt;/api/v1/&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;OpenAPI documentation&lt;/strong&gt; — Hono has built-in OpenAPI support; use it&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Request validation with Zod&lt;/strong&gt; — validate inputs before touching the database&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Standard rate limit headers&lt;/strong&gt; — &lt;code&gt;X-RateLimit-Limit&lt;/code&gt;, &lt;code&gt;X-RateLimit-Remaining&lt;/code&gt;, &lt;code&gt;X-RateLimit-Reset&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For vatnode's public API, I use a two-tier rate limit: 30 requests per minute per API key (enforced per plan in the database), and a hard 60 requests per minute per IP regardless of auth status. The Redis sliding window implementation is worth a dedicated article — and I wrote one: &lt;a href="https://iurii.rogulia.fi/blog/redis-rate-limiting-api" rel="noopener noreferrer"&gt;Redis Rate Limiting for APIs&lt;/a&gt;. Building a public-facing API is also part of my &lt;a href="https://iurii.rogulia.fi/services/api-integrations" rel="noopener noreferrer"&gt;API integrations work&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A consistent error response format matters more than most developers realize. When a client's code breaks at 2 AM, machine-readable error codes are what makes automated retry logic possible:&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/api-error.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApiError&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&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="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;statusCode&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="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;details&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// lib/api-response.ts&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;errorResponse&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;ApiError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="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="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;message&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="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;details&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;details&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;details&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Usage:&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;ApiError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SUBSCRIPTION_REQUIRED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;This endpoint requires an active subscription.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;402&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Email Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;strong&gt;Resend or Mailgun for delivery&lt;/strong&gt; — never send transactional email from your own SMTP&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Welcome / onboarding sequence&lt;/strong&gt; — triggered on signup, not a bulk newsletter&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Email verification&lt;/strong&gt; — required before first meaningful action&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Payment confirmation&lt;/strong&gt; — immediately after &lt;code&gt;invoice.payment_succeeded&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Failed payment recovery&lt;/strong&gt; — day 1, day 3, day 7; vary the subject and body&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Subscription cancellation confirmation&lt;/strong&gt; — acknowledge it, include the end date and data export instructions&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Branded HTML templates&lt;/strong&gt; — React Email for component-based email templates&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Plain text fallback&lt;/strong&gt; — some clients don't render HTML; always include it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I use &lt;a href="https://resend.com" rel="noopener noreferrer"&gt;Resend&lt;/a&gt; with &lt;a href="https://react.email" rel="noopener noreferrer"&gt;React Email&lt;/a&gt; on all current projects. The developer experience is significantly better than Mailgun templates, and the deliverability is comparable. Here's the email client setup:&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/email.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Resend&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;resend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MagicLinkEmail&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/emails/magic-link&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PaymentConfirmationEmail&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/emails/payment-confirmation&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;resend&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;Resend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RESEND_API_KEY&lt;/span&gt;&lt;span class="o"&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;sendMagicLinkEmail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;resend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emails&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="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;noreply@yourdomain.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Your sign-in link&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;react&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;MagicLinkEmail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendPaymentConfirmation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;invoiceUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;email&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;invoiceUrl&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;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;formatted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en-US&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;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Stripe amounts are in cents&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;resend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emails&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="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;billing@yourdomain.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Payment confirmed — &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;formatted&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;react&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentConfirmationEmail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;invoiceUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;formatted&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;
  
  
  Monitoring and Observability Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;strong&gt;Sentry&lt;/strong&gt; — error tracking with source maps; configure &lt;code&gt;SENTRY_DSN&lt;/code&gt; in environment&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Uptime monitoring&lt;/strong&gt; — BetterUptime or UptimeRobot on all public endpoints; alert threshold 1 minute&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Structured logging&lt;/strong&gt; — JSON logs with correlation IDs so you can trace a request across services&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Performance monitoring&lt;/strong&gt; — Vercel Analytics or self-hosted Plausible for the frontend; Sentry performance for the API&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Health check endpoint&lt;/strong&gt; — &lt;code&gt;GET /api/health&lt;/code&gt; that checks DB connectivity and returns 200 or 503&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Alert channels&lt;/strong&gt; — Telegram bot for critical errors, email for daily summaries&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Correlation IDs are the monitoring item that developers consistently skip and consistently regret. When a user reports an error, "I got a 500 error at around 3 PM" is not debuggable without a correlation ID tying the frontend request to the backend logs.&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;// middleware.ts (Next.js)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;v4&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;uuidv4&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;uuid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;correlationId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-correlation-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;uuidv4&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="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// Pass through to Server Components and API routes&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;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-correlation-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;correlationId&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;response&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;Then in every Server Action or API route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;correlationId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-correlation-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;info&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;order.created&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;orderId&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Security Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;strong&gt;HTTPS enforced&lt;/strong&gt; — redirect HTTP to HTTPS at the infrastructure level, not in application code&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Content Security Policy headers&lt;/strong&gt; — use &lt;code&gt;next.config.ts&lt;/code&gt; headers configuration, start with &lt;code&gt;report-only&lt;/code&gt; mode&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;SQL injection protection&lt;/strong&gt; — Drizzle ORM parameterizes all queries; never interpolate user input into raw SQL&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;XSS protection&lt;/strong&gt; — React escapes by default; audit any &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt; usage&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;CSRF protection&lt;/strong&gt; — Better Auth handles this for session-based auth; verify for any custom endpoints&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Secrets management&lt;/strong&gt; — environment variables only, never committed to the repo; use Vercel env vars or Doppler&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Dependency audit&lt;/strong&gt; — &lt;code&gt;npm audit&lt;/code&gt; in CI on every PR&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Rate limiting on all mutating endpoints&lt;/strong&gt; — not just auth; account update, file upload, anything that changes state&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Input validation on the server&lt;/strong&gt; — Zod schemas on all API inputs; never trust the client
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.ts — security headers&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;NextConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next&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;nextConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;headers&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="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/(.*)&lt;/span&gt;&lt;span class="dl"&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="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Frame-Options&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="s2"&gt;DENY&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;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Content-Type-Options&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="s2"&gt;nosniff&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;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Referrer-Policy&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="s2"&gt;strict-origin-when-cross-origin&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;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Permissions-Policy&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="s2"&gt;camera=(), microphone=(), geolocation=()&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;// Start with report-only, then enforce once you're confident&lt;/span&gt;
          &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Security-Policy-Report-Only&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="p"&gt;[&lt;/span&gt;
              &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default-src 'self'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;script-src 'self' 'unsafe-inline'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Tighten this after audit&lt;/span&gt;
              &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;style-src 'self' 'unsafe-inline'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;img-src 'self' data: https:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;connect-src 'self' https://api.stripe.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="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="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  SEO and Analytics Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;strong&gt;Server-side rendering for all landing pages&lt;/strong&gt; — Next.js App Router does this by default&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;&lt;code&gt;sitemap.xml&lt;/code&gt; generated dynamically&lt;/strong&gt; — &lt;code&gt;app/sitemap.ts&lt;/code&gt; with all public routes&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;&lt;code&gt;robots.txt&lt;/code&gt;&lt;/strong&gt; — block &lt;code&gt;/api/*&lt;/code&gt;, &lt;code&gt;/dashboard/*&lt;/code&gt;, allow everything else&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Open Graph tags&lt;/strong&gt; — title, description, image on every public page&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;GA4 server-side tracking&lt;/strong&gt; — client-side tracking loses 30–60% of conversions to adblockers and iOS&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Structured data (JSON-LD)&lt;/strong&gt; — at minimum &lt;code&gt;WebSite&lt;/code&gt; and &lt;code&gt;Organization&lt;/code&gt; schemas on the landing page&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Canonical URLs&lt;/strong&gt; — especially if you have locale-prefixed routes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the GA4 server-side tracking implementation I use in production, including the Measurement Protocol setup and deduplication with Meta CAPI, that's a full topic on its own — I cover it in the &lt;a href="https://iurii.rogulia.fi/blog/server-side-tracking-ga4-meta" rel="noopener noreferrer"&gt;Server-Side Tracking article&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Build vs. What to Buy
&lt;/h2&gt;

&lt;p&gt;This is where projects waste the most time. Building auth, email delivery, or payment processing from scratch is not a competitive advantage. It's technical debt with no upside.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Auth&lt;/td&gt;
&lt;td&gt;Better Auth library&lt;/td&gt;
&lt;td&gt;Rolling your own means managing sessions, hashing, OAuth, CSRF — months of work and still wrong&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email delivery&lt;/td&gt;
&lt;td&gt;Resend or Mailgun&lt;/td&gt;
&lt;td&gt;SPF/DKIM/DMARC setup, deliverability reputation, bounce handling — use a service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File storage&lt;/td&gt;
&lt;td&gt;S3 or Cloudflare R2&lt;/td&gt;
&lt;td&gt;R2 has no egress fees, compatible S3 API; use it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payments&lt;/td&gt;
&lt;td&gt;Stripe only&lt;/td&gt;
&lt;td&gt;European payment methods, VAT handling, dispute management — nothing else comes close&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search&lt;/td&gt;
&lt;td&gt;Algolia or Typesense&lt;/td&gt;
&lt;td&gt;Only if you need full-text search; most SaaS don't need it at launch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email templates&lt;/td&gt;
&lt;td&gt;React Email&lt;/td&gt;
&lt;td&gt;Component-based email that renders in all clients&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background jobs&lt;/td&gt;
&lt;td&gt;BullMQ&lt;/td&gt;
&lt;td&gt;Battle-tested, Redis-backed, excellent TypeScript support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monitoring&lt;/td&gt;
&lt;td&gt;Sentry + UptimeRobot&lt;/td&gt;
&lt;td&gt;Both have generous free tiers; set up on day one&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The places I do build custom:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Business logic specific to the domain (EU VAT rules, PDF forensics analysis, order pipeline orchestration)&lt;/li&gt;
&lt;li&gt;API integrations not covered by existing libraries (PostNord shipping API, Netvisor accounting API)&lt;/li&gt;
&lt;li&gt;Rate limiting with product-specific tiers (covered in the Redis article)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Vercel: What Works and What Doesn't
&lt;/h2&gt;

&lt;p&gt;I deploy Next.js apps to Vercel by default. The DX is excellent — preview deployments, edge functions, automatic SSL. But there are real constraints to plan for:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Works well:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Server Components and Server Actions with standard rendering&lt;/li&gt;
&lt;li&gt;API routes that complete in under 25 seconds&lt;/li&gt;
&lt;li&gt;Edge middleware for auth and redirects&lt;/li&gt;
&lt;li&gt;Static assets and image optimization&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Requires workarounds:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Long-running background jobs — use BullMQ on a separate VPS (I run a Vultr instance for workers)&lt;/li&gt;
&lt;li&gt;Puppeteer for PDF generation — bundle size hits function limits; consider a dedicated service or &lt;code&gt;@sparticuz/chromium&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Large file processing — Vercel's request body limit is 4.5 MB; use presigned S3 URLs for direct browser-to-S3 uploads instead&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For vatnode, the Next.js frontend and API routes run on Vercel; the BullMQ workers and long-running processes run on a separate Node.js service deployed to Vultr — as described in detail in the &lt;a href="https://iurii.rogulia.fi/blog/self-hosting-caddy-docker-vps" rel="noopener noreferrer"&gt;self-hosting on a VPS&lt;/a&gt; article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest Time Estimates
&lt;/h2&gt;

&lt;p&gt;These are real numbers from real projects, not optimistic planning estimates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Week 1–2: Foundation&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Auth (Better Auth setup, email verification, OAuth): 3–4 days&lt;/li&gt;
&lt;li&gt;Database schema (users, subscriptions, core tables): 2 days&lt;/li&gt;
&lt;li&gt;Stripe integration (customer creation, subscription, webhook handler with idempotency): 3–4 days&lt;/li&gt;
&lt;li&gt;Base UI (layout, nav, dashboard shell, auth pages): 2 days&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Week 3–6: Core Features&lt;/strong&gt;&lt;br&gt;
This is the variable part. For vatnode, the core feature (VAT validation with Redis caching, rate limiting, VIES integration) took 2 weeks. For HTPBE?, the 5-layer PDF forensics engine took 3 weeks to get right across 7 iterations of the algorithm. Your product's complexity determines this range.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Week 7–8: Production Readiness&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Error monitoring (Sentry integration, alert setup): 1 day&lt;/li&gt;
&lt;li&gt;Performance audit (Core Web Vitals, database query optimization): 2 days&lt;/li&gt;
&lt;li&gt;Security review (CSP headers, dependency audit, secrets audit): 1 day&lt;/li&gt;
&lt;li&gt;Email sequences (welcome, payment confirmation, failed payment recovery): 2 days&lt;/li&gt;
&lt;li&gt;Documentation (internal runbook, API documentation): 1 day&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Total to a production-ready MVP: 6–8 weeks.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not 2 weeks like some frameworks promise. Not 6 months like agencies quote. 6–8 weeks for a solid foundation plus a working core feature set, if you're a senior developer who's done it before.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Complete Checklist (Summary)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Auth
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Email + password with bcrypt (12+ rounds)&lt;/li&gt;
&lt;li&gt;[ ] Magic links&lt;/li&gt;
&lt;li&gt;[ ] OAuth: Google, GitHub&lt;/li&gt;
&lt;li&gt;[ ] Database sessions (not JWT-only)&lt;/li&gt;
&lt;li&gt;[ ] Rate limiting on all auth endpoints&lt;/li&gt;
&lt;li&gt;[ ] Email verification before first login&lt;/li&gt;
&lt;li&gt;[ ] Password reset with expiring single-use tokens&lt;/li&gt;
&lt;li&gt;[ ] Account deletion (GDPR)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Billing
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Stripe Customer on signup&lt;/li&gt;
&lt;li&gt;[ ] Subscription or one-time payment model&lt;/li&gt;
&lt;li&gt;[ ] Webhook idempotency (Redis + PostgreSQL)&lt;/li&gt;
&lt;li&gt;[ ] Self-service billing portal&lt;/li&gt;
&lt;li&gt;[ ] Free trial with feature gating&lt;/li&gt;
&lt;li&gt;[ ] Upgrade/downgrade with proration&lt;/li&gt;
&lt;li&gt;[ ] Dunning sequence for failed payments&lt;/li&gt;
&lt;li&gt;[ ] All five critical webhook events handled&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Database
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[ ] PostgreSQL with Drizzle ORM&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;created_at&lt;/code&gt;, &lt;code&gt;updated_at&lt;/code&gt;, &lt;code&gt;deleted_at&lt;/code&gt; on every table&lt;/li&gt;
&lt;li&gt;[ ] Soft deletes&lt;/li&gt;
&lt;li&gt;[ ] Migrations in version control&lt;/li&gt;
&lt;li&gt;[ ] Automated daily backups with tested restore&lt;/li&gt;
&lt;li&gt;[ ] Connection pooling&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  API
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Rate limiting per IP and per API key&lt;/li&gt;
&lt;li&gt;[ ] Machine-readable error codes&lt;/li&gt;
&lt;li&gt;[ ] Cursor-based pagination&lt;/li&gt;
&lt;li&gt;[ ] API versioning (&lt;code&gt;/api/v1/&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;[ ] OpenAPI documentation&lt;/li&gt;
&lt;li&gt;[ ] Zod validation on all inputs&lt;/li&gt;
&lt;li&gt;[ ] Standard rate limit headers&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Email
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Resend or Mailgun for delivery&lt;/li&gt;
&lt;li&gt;[ ] Welcome sequence&lt;/li&gt;
&lt;li&gt;[ ] Email verification&lt;/li&gt;
&lt;li&gt;[ ] Payment confirmation&lt;/li&gt;
&lt;li&gt;[ ] Failed payment recovery (3-email sequence)&lt;/li&gt;
&lt;li&gt;[ ] Cancellation confirmation&lt;/li&gt;
&lt;li&gt;[ ] React Email HTML templates with plain text fallback&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Monitoring
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Sentry error tracking with source maps&lt;/li&gt;
&lt;li&gt;[ ] Uptime monitoring with 1-minute alert threshold&lt;/li&gt;
&lt;li&gt;[ ] Structured JSON logs with correlation IDs&lt;/li&gt;
&lt;li&gt;[ ] Health check endpoint&lt;/li&gt;
&lt;li&gt;[ ] Alert channel (Telegram or similar)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Security
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[ ] HTTPS enforced at infrastructure level&lt;/li&gt;
&lt;li&gt;[ ] Content Security Policy (start report-only)&lt;/li&gt;
&lt;li&gt;[ ] Parameterized queries (ORM)&lt;/li&gt;
&lt;li&gt;[ ] Secrets in environment variables only&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;npm audit&lt;/code&gt; in CI&lt;/li&gt;
&lt;li&gt;[ ] Rate limiting on all mutating endpoints&lt;/li&gt;
&lt;li&gt;[ ] Server-side input validation&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  SEO and Analytics
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Server-side rendered landing pages&lt;/li&gt;
&lt;li&gt;[ ] Dynamic &lt;code&gt;sitemap.xml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;robots.txt&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Open Graph tags on all public pages&lt;/li&gt;
&lt;li&gt;[ ] GA4 server-side tracking&lt;/li&gt;
&lt;li&gt;[ ] Structured data (JSON-LD)&lt;/li&gt;
&lt;li&gt;[ ] Canonical URLs&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you're &lt;a href="https://iurii.rogulia.fi/services/mvp-development" rel="noopener noreferrer"&gt;building a SaaS for the EU market&lt;/a&gt;, you'll run into every item on this list — plus the EU-specific ones like VAT compliance and GDPR that didn't make it into the generic sections. I've shipped this stack across &lt;a href="https://iurii.rogulia.fi/projects/vatnode-vat-validation" rel="noopener noreferrer"&gt;vatnode&lt;/a&gt;, HTPBE?, &lt;a href="https://iurii.rogulia.fi/projects/pi-pi-b2b-ecommerce" rel="noopener noreferrer"&gt;pi-pi&lt;/a&gt;, and pikkuna.fi. The checklist isn't theory; it's what I actually verify before I consider a product launch-ready.&lt;/p&gt;

&lt;p&gt;If you need a senior developer who can own this end-to-end — architecture through launch and beyond — &lt;a href="https://iurii.rogulia.fi/contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt;. I build production-ready products, not MVPs that need to be rewritten in six months. Available for freelance projects and long-term engagements.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>drizzle</category>
      <category>betterauth</category>
    </item>
    <item>
      <title>Common PDF Editing Tools and How We Detect Their Traces</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Fri, 22 May 2026 10:00:27 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/common-pdf-editing-tools-and-how-we-detect-their-traces-7j0</link>
      <guid>https://dev.to/iurii_rogulia/common-pdf-editing-tools-and-how-we-detect-their-traces-7j0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/common-pdf-editing-tools-detection-traces" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt;. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every PDF editing tool leaves traces in the files it processes. These “fingerprints” — embedded in metadata, file structure, and processing patterns — reveal which applications created and modified PDF documents.&lt;/p&gt;

&lt;p&gt;Understanding these traces is essential for PDF forensics and document fraud detection. By analyzing tool fingerprints, forensic analysts can identify editing tools, detect modifications, and reconstruct document processing history.&lt;/p&gt;

&lt;p&gt;This article explores how PDF editing works technically, which tools leave which traces, and how forensic analysis detects editing tool usage. Whether you are a security researcher, IT forensics professional, or curious technical user, this deep-dive explains the technical details behind PDF tool detection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every Edit Leaves a Trace
&lt;/h2&gt;

&lt;p&gt;PDF editing is not invisible. Every tool that creates or modifies a PDF leaves distinctive markers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Metadata fingerprints&lt;/strong&gt;: Application names and versions in metadata&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structural signatures&lt;/strong&gt;: Tool-specific file structure patterns&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Processing artifacts&lt;/strong&gt;: Editing traces in PDF structure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version markers&lt;/strong&gt;: PDF specification versions used&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These traces persist even when content appears unchanged, making tool detection possible through forensic analysis.&lt;/p&gt;

&lt;p&gt;As &lt;a href="https://www.researchgate.net/publication/393261461_A_Technique_for_the_Detection_of_PDF_Tampering_or_Forgery" rel="noopener noreferrer"&gt;ResearchGate research&lt;/a&gt; shows, tool fingerprinting is a reliable method for detecting PDF modifications.&lt;/p&gt;

&lt;h2&gt;
  
  
  How PDF Editing Works Technically
&lt;/h2&gt;

&lt;p&gt;Understanding PDF structure helps explain how editing leaves traces:&lt;/p&gt;

&lt;h3&gt;
  
  
  Incremental Updates
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PDFs can be modified using incremental updates&lt;/li&gt;
&lt;li&gt;Changes are appended to file without rewriting entire document&lt;/li&gt;
&lt;li&gt;Original content remains, new content added&lt;/li&gt;
&lt;li&gt;Cross-reference table updated to point to new content&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Forensic value:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Incremental updates create revision history&lt;/li&gt;
&lt;li&gt;Multiple updates indicate multiple editing sessions&lt;/li&gt;
&lt;li&gt;Update sequence reveals editing timeline&lt;/li&gt;
&lt;li&gt;Original content preserved for comparison&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Multiple cross-reference tables indicate updates&lt;/li&gt;
&lt;li&gt;Incremental update markers show modification points&lt;/li&gt;
&lt;li&gt;Object version tracking reveals editing history&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Object Modification
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PDFs consist of objects (text, images, fonts, etc.)&lt;/li&gt;
&lt;li&gt;Editing modifies or adds objects&lt;/li&gt;
&lt;li&gt;Object references updated in cross-reference table&lt;/li&gt;
&lt;li&gt;Deleted objects may remain in file (marked as deleted)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Forensic value:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Object modifications reveal editing activity&lt;/li&gt;
&lt;li&gt;Deleted objects provide editing history&lt;/li&gt;
&lt;li&gt;Object references show modification scope&lt;/li&gt;
&lt;li&gt;Object structure reveals editing methods&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Cross-reference table analysis shows object changes&lt;/li&gt;
&lt;li&gt;Deleted object markers indicate removals&lt;/li&gt;
&lt;li&gt;Object structure analysis reveals modifications&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Stream Editing
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PDF content stored in streams (compressed data)&lt;/li&gt;
&lt;li&gt;Editing may modify stream content&lt;/li&gt;
&lt;li&gt;Stream compression and encoding reveal processing&lt;/li&gt;
&lt;li&gt;Stream dictionaries contain processing information&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Forensic value:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stream modifications indicate content changes&lt;/li&gt;
&lt;li&gt;Compression methods reveal processing tools&lt;/li&gt;
&lt;li&gt;Stream dictionaries contain tool information&lt;/li&gt;
&lt;li&gt;Encoding methods show processing history&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Stream analysis reveals modifications&lt;/li&gt;
&lt;li&gt;Compression fingerprinting identifies tools&lt;/li&gt;
&lt;li&gt;Dictionary analysis shows processing information&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As &lt;a href="https://www.forensicfocus.com/forums/general/pdf-manipulated/" rel="noopener noreferrer"&gt;Forensic Focus discussions&lt;/a&gt; explain, understanding PDF structure is essential for forensic analysis.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common PDF Editing Tools and Their Fingerprints
&lt;/h2&gt;

&lt;p&gt;Different tools leave distinctive traces:&lt;/p&gt;

&lt;h3&gt;
  
  
  Adobe Acrobat (Pro, DC, Reader)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Metadata fingerprints:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Producer: “Adobe Acrobat” or “Adobe Acrobat Pro”&lt;/li&gt;
&lt;li&gt;Creator: Original application (if converted)&lt;/li&gt;
&lt;li&gt;PDF version: Typically 1.4 or higher&lt;/li&gt;
&lt;li&gt;Application version: Included in metadata&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Structural signatures:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Standard PDF structure&lt;/li&gt;
&lt;li&gt;Incremental updates when editing&lt;/li&gt;
&lt;li&gt;Cross-reference table patterns&lt;/li&gt;
&lt;li&gt;Object organization&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Processing patterns:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Linearization for web optimization&lt;/li&gt;
&lt;li&gt;Metadata preservation&lt;/li&gt;
&lt;li&gt;Signature support&lt;/li&gt;
&lt;li&gt;Form field handling&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Producer field identifies Adobe products&lt;/li&gt;
&lt;li&gt;Version information reveals Acrobat version&lt;/li&gt;
&lt;li&gt;Processing patterns match Adobe workflows&lt;/li&gt;
&lt;li&gt;Structural signatures consistent with Adobe tools&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Foxit PhantomPDF
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Metadata fingerprints:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Producer: “Foxit” or “Foxit PhantomPDF”&lt;/li&gt;
&lt;li&gt;Creator: Original application&lt;/li&gt;
&lt;li&gt;PDF version: Varies by version&lt;/li&gt;
&lt;li&gt;Application identification: Foxit-specific markers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Structural signatures:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Foxit-specific object patterns&lt;/li&gt;
&lt;li&gt;Custom metadata fields&lt;/li&gt;
&lt;li&gt;Processing artifacts&lt;/li&gt;
&lt;li&gt;Version-specific structures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Processing patterns:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Form handling methods&lt;/li&gt;
&lt;li&gt;Annotation support&lt;/li&gt;
&lt;li&gt;Signature implementation&lt;/li&gt;
&lt;li&gt;Optimization techniques&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Producer field identifies Foxit&lt;/li&gt;
&lt;li&gt;Structural analysis reveals Foxit patterns&lt;/li&gt;
&lt;li&gt;Processing artifacts show Foxit usage&lt;/li&gt;
&lt;li&gt;Version markers indicate Foxit version&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As &lt;a href="https://community.adobe.com/questions-12/pdf-files-manipulation-detection-1526564" rel="noopener noreferrer"&gt;Adobe Community discussions&lt;/a&gt; note, different tools create distinctive patterns.&lt;/p&gt;

&lt;h3&gt;
  
  
  Nitro PDF
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Metadata fingerprints:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Producer: “Nitro” or “Nitro PDF”&lt;/li&gt;
&lt;li&gt;Creator: Source application&lt;/li&gt;
&lt;li&gt;PDF version: Typically 1.4+&lt;/li&gt;
&lt;li&gt;Nitro-specific markers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Structural signatures:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nitro processing patterns&lt;/li&gt;
&lt;li&gt;Custom metadata&lt;/li&gt;
&lt;li&gt;Object organization&lt;/li&gt;
&lt;li&gt;File structure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Processing patterns:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Conversion methods&lt;/li&gt;
&lt;li&gt;Editing techniques&lt;/li&gt;
&lt;li&gt;Optimization approaches&lt;/li&gt;
&lt;li&gt;Form handling&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Producer identification&lt;/li&gt;
&lt;li&gt;Structural fingerprinting&lt;/li&gt;
&lt;li&gt;Processing pattern analysis&lt;/li&gt;
&lt;li&gt;Version detection&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Online Editors (iLovePDF, SmallPDF, etc.)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Metadata fingerprints:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Producer: Often generic or service name&lt;/li&gt;
&lt;li&gt;Creator: May show original source&lt;/li&gt;
&lt;li&gt;PDF version: Varies&lt;/li&gt;
&lt;li&gt;Service identification: May include service markers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Structural signatures:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Web-based processing patterns&lt;/li&gt;
&lt;li&gt;Conversion artifacts&lt;/li&gt;
&lt;li&gt;Service-specific structures&lt;/li&gt;
&lt;li&gt;Processing markers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Processing patterns:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Online conversion methods&lt;/li&gt;
&lt;li&gt;Server-side processing&lt;/li&gt;
&lt;li&gt;Optimization techniques&lt;/li&gt;
&lt;li&gt;Format conversion patterns&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Producer field may identify service&lt;/li&gt;
&lt;li&gt;Structural analysis reveals online processing&lt;/li&gt;
&lt;li&gt;Processing patterns show web-based editing&lt;/li&gt;
&lt;li&gt;Artifacts indicate online tool usage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Privacy note&lt;/strong&gt;: Online editors may process documents on servers, raising privacy concerns.&lt;/p&gt;

&lt;h3&gt;
  
  
  LibreOffice Draw
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Metadata fingerprints:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Producer: “LibreOffice” or version-specific&lt;/li&gt;
&lt;li&gt;Creator: “LibreOffice Draw”&lt;/li&gt;
&lt;li&gt;PDF version: Typically 1.4&lt;/li&gt;
&lt;li&gt;Application version: Included&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Structural signatures:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LibreOffice-specific patterns&lt;/li&gt;
&lt;li&gt;Object organization&lt;/li&gt;
&lt;li&gt;Processing methods&lt;/li&gt;
&lt;li&gt;Version markers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Processing patterns:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open-source tool patterns&lt;/li&gt;
&lt;li&gt;Conversion methods&lt;/li&gt;
&lt;li&gt;Optimization techniques&lt;/li&gt;
&lt;li&gt;Form handling&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Producer identification&lt;/li&gt;
&lt;li&gt;Structural fingerprinting&lt;/li&gt;
&lt;li&gt;Processing pattern recognition&lt;/li&gt;
&lt;li&gt;Version detection&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Preview (macOS)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Metadata fingerprints:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Producer: “Mac OS X” or version-specific&lt;/li&gt;
&lt;li&gt;Creator: Original application&lt;/li&gt;
&lt;li&gt;PDF version: Varies&lt;/li&gt;
&lt;li&gt;macOS-specific markers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Structural signatures:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;macOS processing patterns&lt;/li&gt;
&lt;li&gt;Quartz PDF patterns&lt;/li&gt;
&lt;li&gt;System-specific structures&lt;/li&gt;
&lt;li&gt;Version markers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Processing patterns:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Native macOS methods&lt;/li&gt;
&lt;li&gt;System integration&lt;/li&gt;
&lt;li&gt;Optimization techniques&lt;/li&gt;
&lt;li&gt;Processing artifacts&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Producer field identifies macOS&lt;/li&gt;
&lt;li&gt;Structural analysis reveals macOS patterns&lt;/li&gt;
&lt;li&gt;Processing artifacts show system usage&lt;/li&gt;
&lt;li&gt;Version markers indicate macOS version&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As &lt;a href="https://www.quora.com/Can-it-be-possible-to-find-out-whether-a-pdf-file-is-edited-modified" rel="noopener noreferrer"&gt;Quora discussions&lt;/a&gt; explain, tool detection requires analyzing multiple indicators.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Each Tool Leaves Behind
&lt;/h2&gt;

&lt;p&gt;Understanding tool-specific traces:&lt;/p&gt;

&lt;h3&gt;
  
  
  Producer Field Changes
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it reveals:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Last application that processed PDF&lt;/li&gt;
&lt;li&gt;Tool identification&lt;/li&gt;
&lt;li&gt;Version information&lt;/li&gt;
&lt;li&gt;Processing history&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Forensic value:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Identifies editing tools&lt;/li&gt;
&lt;li&gt;Shows processing sequence&lt;/li&gt;
&lt;li&gt;Reveals tool usage&lt;/li&gt;
&lt;li&gt;Detects unexpected applications&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Can be spoofed&lt;/li&gt;
&lt;li&gt;May not reflect all processing&lt;/li&gt;
&lt;li&gt;Some tools do not update producer&lt;/li&gt;
&lt;li&gt;Legitimate workflows use multiple tools&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Metadata Patterns
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Tool-specific metadata:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Application names&lt;/li&gt;
&lt;li&gt;Version information&lt;/li&gt;
&lt;li&gt;Processing timestamps&lt;/li&gt;
&lt;li&gt;Custom metadata fields&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Forensic value:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tool identification&lt;/li&gt;
&lt;li&gt;Version detection&lt;/li&gt;
&lt;li&gt;Processing timeline&lt;/li&gt;
&lt;li&gt;Custom field analysis&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Metadata field analysis&lt;/li&gt;
&lt;li&gt;Pattern recognition&lt;/li&gt;
&lt;li&gt;Version matching&lt;/li&gt;
&lt;li&gt;Custom field identification&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Structural Signatures
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;File structure patterns:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Object organization&lt;/li&gt;
&lt;li&gt;Cross-reference table structure&lt;/li&gt;
&lt;li&gt;Stream organization&lt;/li&gt;
&lt;li&gt;File layout&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Forensic value:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tool-specific structures&lt;/li&gt;
&lt;li&gt;Processing patterns&lt;/li&gt;
&lt;li&gt;Editing methods&lt;/li&gt;
&lt;li&gt;Optimization techniques&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Structural analysis&lt;/li&gt;
&lt;li&gt;Pattern matching&lt;/li&gt;
&lt;li&gt;Comparison with known patterns&lt;/li&gt;
&lt;li&gt;Anomaly detection&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As &lt;a href="https://www.opswat.com/blog/how-to-recognize-and-remediate-this-common-foxit-pdf-reader-vulnerability" rel="noopener noreferrer"&gt;OPSWAT reports&lt;/a&gt;, structural analysis reveals tool-specific patterns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Advanced Techniques
&lt;/h2&gt;

&lt;p&gt;Sophisticated forensic analysis methods:&lt;/p&gt;

&lt;h3&gt;
  
  
  Incremental Save Analysis
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Analyzes incremental update sequence&lt;/li&gt;
&lt;li&gt;Identifies editing sessions&lt;/li&gt;
&lt;li&gt;Tracks modification timeline&lt;/li&gt;
&lt;li&gt;Reveals editing history&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Forensic value:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Editing session identification&lt;/li&gt;
&lt;li&gt;Timeline reconstruction&lt;/li&gt;
&lt;li&gt;Modification tracking&lt;/li&gt;
&lt;li&gt;History analysis&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Cross-reference table analysis&lt;/li&gt;
&lt;li&gt;Update marker examination&lt;/li&gt;
&lt;li&gt;Object version tracking&lt;/li&gt;
&lt;li&gt;Sequence analysis&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Cross-Reference Table Examination
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Analyzes cross-reference table structure&lt;/li&gt;
&lt;li&gt;Identifies object references&lt;/li&gt;
&lt;li&gt;Detects modifications&lt;/li&gt;
&lt;li&gt;Reveals editing patterns&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Forensic value:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Modification detection&lt;/li&gt;
&lt;li&gt;Object change tracking&lt;/li&gt;
&lt;li&gt;Structure analysis&lt;/li&gt;
&lt;li&gt;Editing method identification&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Table structure analysis&lt;/li&gt;
&lt;li&gt;Reference pattern examination&lt;/li&gt;
&lt;li&gt;Anomaly detection&lt;/li&gt;
&lt;li&gt;Comparison with originals&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Object Tree Analysis
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Examines PDF object hierarchy&lt;/li&gt;
&lt;li&gt;Analyzes object relationships&lt;/li&gt;
&lt;li&gt;Identifies modifications&lt;/li&gt;
&lt;li&gt;Tracks object changes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Forensic value:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Modification detection&lt;/li&gt;
&lt;li&gt;Editing method identification&lt;/li&gt;
&lt;li&gt;Structure analysis&lt;/li&gt;
&lt;li&gt;Change tracking&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Object tree parsing&lt;/li&gt;
&lt;li&gt;Relationship analysis&lt;/li&gt;
&lt;li&gt;Modification identification&lt;/li&gt;
&lt;li&gt;Pattern recognition&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As &lt;a href="https://www.foxit.com/pdf-editor/version-history.html" rel="noopener noreferrer"&gt;Foxit documentation&lt;/a&gt; shows, advanced analysis requires deep PDF structure knowledge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations and Challenges
&lt;/h2&gt;

&lt;p&gt;Tool detection has limitations:&lt;/p&gt;

&lt;h3&gt;
  
  
  Spoofing
&lt;/h3&gt;

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

&lt;ul&gt;
&lt;li&gt;Metadata can be manipulated&lt;/li&gt;
&lt;li&gt;Producer fields can be changed&lt;/li&gt;
&lt;li&gt;Structural patterns can be mimicked&lt;/li&gt;
&lt;li&gt;Tool identification can be spoofed&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Analyze multiple indicators&lt;/li&gt;
&lt;li&gt;Check structural consistency&lt;/li&gt;
&lt;li&gt;Check metadata against structure&lt;/li&gt;
&lt;li&gt;Use comprehensive analysis&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Tool Evolution
&lt;/h3&gt;

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

&lt;ul&gt;
&lt;li&gt;Tools update and change patterns&lt;/li&gt;
&lt;li&gt;New versions create new signatures&lt;/li&gt;
&lt;li&gt;Patterns evolve over time&lt;/li&gt;
&lt;li&gt;Detection methods need updates&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Maintain tool signature database&lt;/li&gt;
&lt;li&gt;Update detection patterns&lt;/li&gt;
&lt;li&gt;Analyze version-specific markers&lt;/li&gt;
&lt;li&gt;Continuous pattern learning&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Legitimate Workflows
&lt;/h3&gt;

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

&lt;ul&gt;
&lt;li&gt;Multiple tools used legitimately&lt;/li&gt;
&lt;li&gt;Processing chains create complex patterns&lt;/li&gt;
&lt;li&gt;Normal workflows involve multiple tools&lt;/li&gt;
&lt;li&gt;Distinguishing legitimate from suspicious&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Context-aware analysis&lt;/li&gt;
&lt;li&gt;Workflow pattern recognition&lt;/li&gt;
&lt;li&gt;Legitimate pattern identification&lt;/li&gt;
&lt;li&gt;Suspicious pattern detection&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  False Positives
&lt;/h3&gt;

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

&lt;ul&gt;
&lt;li&gt;Legitimate edits trigger detection&lt;/li&gt;
&lt;li&gt;Normal processing creates patterns&lt;/li&gt;
&lt;li&gt;Tool usage may be expected&lt;/li&gt;
&lt;li&gt;Context matters for interpretation&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Confidence scoring&lt;/li&gt;
&lt;li&gt;Context consideration&lt;/li&gt;
&lt;li&gt;Pattern validation&lt;/li&gt;
&lt;li&gt;Manual review for ambiguous cases&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How HTPBE? Uses This Knowledge
&lt;/h2&gt;

&lt;p&gt;HTPBE?’s algorithm leverages tool detection:&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-Indicator Analysis
&lt;/h3&gt;

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

&lt;ul&gt;
&lt;li&gt;Producer field identification&lt;/li&gt;
&lt;li&gt;Metadata pattern analysis&lt;/li&gt;
&lt;li&gt;Structural signature detection&lt;/li&gt;
&lt;li&gt;Processing pattern recognition&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;More accurate tool identification&lt;/li&gt;
&lt;li&gt;Reduced false positives&lt;/li&gt;
&lt;li&gt;Comprehensive analysis&lt;/li&gt;
&lt;li&gt;Reliable detection&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Tool Fingerprint Database
&lt;/h3&gt;

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

&lt;ul&gt;
&lt;li&gt;Known tool signatures&lt;/li&gt;
&lt;li&gt;Version-specific patterns&lt;/li&gt;
&lt;li&gt;Processing artifacts&lt;/li&gt;
&lt;li&gt;Structural markers&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Accurate tool identification&lt;/li&gt;
&lt;li&gt;Version detection&lt;/li&gt;
&lt;li&gt;Pattern matching&lt;/li&gt;
&lt;li&gt;Continuous updates&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Confidence Scoring
&lt;/h3&gt;

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

&lt;ul&gt;
&lt;li&gt;Tool identification confidence&lt;/li&gt;
&lt;li&gt;Pattern match strength&lt;/li&gt;
&lt;li&gt;Detection reliability&lt;/li&gt;
&lt;li&gt;Interpretation guidance&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Clear results&lt;/li&gt;
&lt;li&gt;Actionable information&lt;/li&gt;
&lt;li&gt;Reduced ambiguity&lt;/li&gt;
&lt;li&gt;Better decision-making&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;PDF editing tools leave distinctive traces in the files they process. These fingerprints — in metadata, structure, and processing patterns — enable forensic analysis to identify editing tools and detect modifications.&lt;/p&gt;

&lt;p&gt;Understanding tool detection helps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Identify editing tools&lt;/strong&gt;: Know which applications processed documents&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detect modifications&lt;/strong&gt;: Recognize editing activity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconstruct history&lt;/strong&gt;: Understand document processing timeline&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check authenticity&lt;/strong&gt;: Confirm expected tool usage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tool detection is one layer of comprehensive PDF tamper detection. Combined with metadata analysis, signature fraud detection, and structural examination, it provides powerful forensic capabilities.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>forensics</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Handle VIES Downtime in Your Application</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Fri, 22 May 2026 09:00:29 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/how-to-handle-vies-downtime-in-your-application-afn</link>
      <guid>https://dev.to/iurii_rogulia/how-to-handle-vies-downtime-in-your-application-afn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/vies-downtime-guide" 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;VIES national nodes go offline. It is not an edge case — it happens regularly, especially for Germany, which is both the most important EU market and one of the most unreliable VIES nodes. Here is how to build a system that handles it correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why VIES goes down and how often
&lt;/h2&gt;

&lt;p&gt;VIES is not a single system — it is a network of 27 national nodes, each operated by a member state's tax authority. When you query VIES for an Irish VAT number, the European Commission infrastructure routes the request to Ireland's Revenue Commissioners system in real time. If that system is unavailable, VIES returns an error.&lt;/p&gt;

&lt;p&gt;Individual nodes go offline for planned maintenance (typically outside business hours), for unplanned outages, and in Germany's case, for rate limiting — the BZSt node throttles requests during peak periods. Outages can last minutes or hours. There is no official status page for VIES node availability.&lt;/p&gt;

&lt;h2&gt;
  
  
  The VIES_UNAVAILABLE error
&lt;/h2&gt;

&lt;p&gt;When the vatnode API cannot reach VIES for a given country, it returns HTTP 503 with a structured error body:&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;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"VIES_UNAVAILABLE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The VIES service for IE is temporarily unavailable. Retry later."&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;This is distinct from an invalid VAT number (HTTP 200 with &lt;code&gt;"valid": false&lt;/code&gt;) or a format error (HTTP 400 with &lt;code&gt;INVALID_FORMAT&lt;/code&gt;). A 503 always means "try again later" — not "this VAT number is invalid".&lt;/p&gt;

&lt;h2&gt;
  
  
  The right pattern: queue, not block
&lt;/h2&gt;

&lt;p&gt;The most common mistake is blocking a checkout or invoice on VIES unavailability. This is wrong for two reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The customer is almost certainly legitimate.&lt;/strong&gt; VIES being down does not mean the VAT number is invalid. The customer provided it in good faith and their registration is almost certainly still active.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You cannot verify either way.&lt;/strong&gt; Blocking is not a safety measure — it is just friction that loses you a sale.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The correct approach: allow the transaction to proceed, apply standard VAT (the safe default), and queue an async job to re-validate when VIES recovers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateVatForCheckout&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;customerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`https://api.vatnode.dev/v1/vat/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VATNODE_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="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;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// VIES unavailable — queue for retry, do not block&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&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-retry&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;vatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;scheduledFor&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;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&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="c1"&gt;// retry in 5 minutes&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;valid&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="c1"&gt;// unknown — pending retry&lt;/span&gt;
      &lt;span class="na"&gt;vatRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// apply standard VAT in the meantime&lt;/span&gt;
      &lt;span class="na"&gt;requiresRetry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;vatRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;checkId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;requiresRetry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Germany: BZSt fallback
&lt;/h2&gt;

&lt;p&gt;Germany is a special case. The VIES DE node has historically been rate-limited or unavailable more frequently than other member states. The German Bundeszentralamt für Steuern (BZSt) operates a separate VAT validation service — eVatR — that vatnode uses as an automatic fallback.&lt;/p&gt;

&lt;p&gt;When you validate a German VAT number (prefix &lt;code&gt;DE&lt;/code&gt;) and the VIES DE node is unavailable, vatnode automatically retries against BZSt. If BZSt responds, the response includes &lt;code&gt;"source": "BZST"&lt;/code&gt; instead of &lt;code&gt;"source": "VIES"&lt;/code&gt;. Note that BZSt returns valid/invalid status only — company name and address are not available from this source.&lt;/p&gt;

&lt;p&gt;This means German VAT numbers almost never return &lt;code&gt;VIES_UNAVAILABLE&lt;/code&gt; from vatnode — the BZSt fallback keeps the validation working even when the VIES DE node is down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry strategy
&lt;/h2&gt;

&lt;p&gt;When VIES is unavailable, a good retry strategy uses exponential backoff with a maximum delay. VIES outages typically resolve within 30–60 minutes:&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;// Retry intervals (minutes): 5, 15, 30, 60, 120&lt;/span&gt;
&lt;span class="c1"&gt;// After 5 retries (~3.5 hours), flag for manual review&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RETRY_DELAYS&lt;/span&gt; &lt;span class="o"&gt;=&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="mi"&gt;15&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="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;scheduleVatRetry&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;customerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attempt&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;RETRY_DELAYS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Flag for manual review after exhausting retries&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;flagForManualReview&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;customerId&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;delayMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;RETRY_DELAYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&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-retry&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;vatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;delayMs&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;If you are considering moving away from a direct SOAP connection to VIES, the &lt;a href="https://vatnode.dev/guides/vies-api-alternative" rel="noopener noreferrer"&gt;VIES REST API alternative with automatic fallback&lt;/a&gt; covers the migration path and what changes in your codebase.&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  Build a resilient VAT integration with vatnode
&lt;/h3&gt;

&lt;p&gt;vatnode handles retries, BZSt fallback, and structured error codes so your application can implement the right pattern. Free plan, 100 requests/month.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://vatnode.dev/register" rel="noopener noreferrer"&gt;Get Free API Key&lt;/a&gt; · &lt;a href="https://vatnode.dev/docs/errors" rel="noopener noreferrer"&gt;Error Code Reference&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>tax</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Stripe Webhooks: Idempotency, Retries, and Queue Setup</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Fri, 22 May 2026 07:00:27 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/stripe-webhooks-idempotency-retries-and-queue-setup-33bf</link>
      <guid>https://dev.to/iurii_rogulia/stripe-webhooks-idempotency-retries-and-queue-setup-33bf</guid>
      <description>&lt;p&gt;Stripe sent one webhook. Your database has three orders. What happened?&lt;/p&gt;

&lt;p&gt;This is not a hypothetical. I've seen it in production — on vatnode.dev, on pikkuna.fi, on pi-pi.ee. The Stripe webhook hits your endpoint, your server takes 800ms to respond, Stripe times out and queues a retry. Meanwhile your server did complete the work. Now you have a duplicate. Then another one arrives 30 minutes later.&lt;/p&gt;

&lt;p&gt;The naive implementation — parse the JSON, run your business logic, return 200 — fails in ways that only show up under real traffic. Here's what production actually looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Naive Implementation Breaks
&lt;/h2&gt;

&lt;p&gt;Stripe's retry policy is more aggressive than most developers expect. If your endpoint returns anything other than a 2xx status code, or takes longer than 30 seconds to respond, Stripe retries. The schedule: immediately, then at 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and so on — up to 72 hours and roughly 15–18 total attempts.&lt;/p&gt;

&lt;p&gt;That means if your server returned 500 at 9 AM on Monday due to a database hiccup, Stripe will still be trying to deliver the same event on Tuesday morning. If your server is back up, it will process the event — possibly creating a duplicate subscription, a duplicate order, or sending a duplicate email to your customer.&lt;/p&gt;

&lt;p&gt;The naive handler looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/webhooks/stripe/route.ts — DON'T do this&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// Wrong: parsed body won't verify&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;body&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Event&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;payment_intent.succeeded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// No idempotency check&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="s2"&gt;ok&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;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;Three problems here: no signature verification, no protection against replay attacks, and synchronous processing that blocks the response while your business logic runs. If &lt;code&gt;createOrder&lt;/code&gt; takes 3 seconds and Stripe's timeout is strict, you'll get retries even when the order was created successfully.&lt;/p&gt;

&lt;p&gt;
  name="How to handle Stripe webhooks reliably in production"&lt;br&gt;
  totalTime="PT3H"&lt;br&gt;
  tools={[&lt;br&gt;
    "Next.js 16",&lt;br&gt;
    "TypeScript",&lt;br&gt;
    "Stripe SDK",&lt;br&gt;
    "BullMQ",&lt;br&gt;
    "ioredis",&lt;br&gt;
    "Drizzle ORM",&lt;br&gt;
    "PostgreSQL",&lt;br&gt;
  ]}&lt;br&gt;
  steps={[&lt;br&gt;
    {&lt;br&gt;
      name: "Verify the signature using raw bytes",&lt;br&gt;
      text: "Use request.arrayBuffer() — not request.json() — to get the raw body before passing it to stripe.webhooks.constructEvent. Parsing the body first changes the bytes and makes signature verification always fail.",&lt;br&gt;
    },&lt;br&gt;
    {&lt;br&gt;
      name: "Implement dual-layer idempotency",&lt;br&gt;
      text: "Store processed Stripe event IDs in PostgreSQL as the source of truth. Add a Redis pre-check layer for sub-millisecond lookups on hot paths. Return 200 immediately when a duplicate is detected.",&lt;br&gt;
    },&lt;br&gt;
    {&lt;br&gt;
      name: "Enqueue the event with BullMQ",&lt;br&gt;
      text: "Return 200 from the HTTP handler within 50ms by enqueuing to BullMQ using event.id as the jobId for deduplication. Never run business logic synchronously inside the webhook handler.",&lt;br&gt;
    },&lt;br&gt;
    {&lt;br&gt;
      name: "Process events in a separate worker",&lt;br&gt;
      text: "Run a BullMQ worker with concurrency 5 that handles payment_intent.succeeded, customer.subscription.deleted, and invoice.payment_failed. Double-check idempotency inside the worker to cover edge cases like mid-job restarts.",&lt;br&gt;
    },&lt;br&gt;
    {&lt;br&gt;
      name: "Wrap critical handlers in transactions",&lt;br&gt;
      text: "Use a database transaction for payment_intent.succeeded so that partial writes never leave inconsistent state. Send confirmation emails outside the transaction — a failed email shouldn't roll back the order.",&lt;br&gt;
    },&lt;br&gt;
    {&lt;br&gt;
      name: "Test with Stripe CLI locally",&lt;br&gt;
      text: "Use stripe listen --forward-to localhost:3000/api/webhooks/stripe and stripe trigger payment_intent.succeeded to replay events. Verify idempotency by re-sending the same event ID and confirming the duplicate response.",&lt;br&gt;
    },&lt;br&gt;
  ]}&lt;br&gt;
/&amp;gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Idempotency — The Foundation
&lt;/h2&gt;

&lt;p&gt;
  slug="api-integrations"&lt;br&gt;
  text="Need a production-grade Stripe webhook pipeline — idempotency, BullMQ, signature verification, retry handling — wired into your SaaS or e-commerce stack? This is what I build."&lt;br&gt;
/&amp;gt;&lt;/p&gt;

&lt;p&gt;Before any queue, before any worker, you need idempotency: the guarantee that processing the same event twice produces the same result as processing it once.&lt;/p&gt;

&lt;p&gt;Stripe makes this straightforward — every event has a unique &lt;code&gt;id&lt;/code&gt; field (e.g., &lt;code&gt;evt_1OqXyz...&lt;/code&gt;). Store this ID when you process the event. On subsequent attempts, check the store first and short-circuit.&lt;/p&gt;

&lt;p&gt;I use PostgreSQL for durable idempotency keys and Redis for a fast pre-check layer. Here's the Drizzle ORM schema:&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;// packages/db/schema/webhook-events.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pgTable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;jsonb&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;drizzle-orm/pg-core&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;webhookEvents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pgTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webhook_events&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;id&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;primaryKey&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// Stripe event ID — evt_1OqXyz...&lt;/span&gt;
  &lt;span class="na"&gt;type&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// payment_intent.succeeded, etc.&lt;/span&gt;
  &lt;span class="na"&gt;processedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;processed_at&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;defaultNow&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;payload&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;notNull&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;And the idempotency check — run this before any business logic:&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/webhook-idempotency.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/packages/db&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;webhookEvents&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/packages/db/schema/webhook-events&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;eq&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;drizzle-orm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/redis&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ioredis instance&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;isAlreadyProcessed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventId&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;boolean&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;// Fast path: Redis check first (sub-millisecond)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&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;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`webhook:processed:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Slow path: database check (handles Redis eviction or restarts)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;webhookEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;webhookEvents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;webhookEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Restore to Redis cache so future checks are fast&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`webhook:processed:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;eventId&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EX&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;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;markAsProcessed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;type&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;payload&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="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="c1"&gt;// Write to DB first — this is the source of truth&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;webhookEvents&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;,&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;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Then cache in Redis for fast future lookups&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`webhook:processed:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;eventId&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EX&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;7&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;
  
  
  Signature Verification in Next.js App Router
&lt;/h2&gt;

&lt;p&gt;Here's the subtle thing that breaks almost every Next.js webhook tutorial: &lt;code&gt;request.json()&lt;/code&gt; returns a parsed object. Stripe's signature verification requires the &lt;strong&gt;raw bytes&lt;/strong&gt; of the original request body. Once parsed, the signature check will always fail.&lt;/p&gt;

&lt;p&gt;In the Pages Router, you'd disable &lt;code&gt;bodyParser&lt;/code&gt;. In the App Router, you use &lt;code&gt;request.arrayBuffer()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/webhooks/stripe/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isAlreadyProcessed&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/webhook-idempotency&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;webhookQueue&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/webhook-queue&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;stripe&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;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&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="na"&gt;apiVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2025-01-27.acacia&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;webhookSecret&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;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&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="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// arrayBuffer() gives us raw bytes — required for signature verification&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;arrayBuffer&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;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe-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;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;signature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Missing 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="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Convert ArrayBuffer to Buffer for the Stripe SDK&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&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;rawBody&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;webhookSecret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Webhook signature verification failed:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Check idempotency before doing anything else&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alreadyProcessed&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;isAlreadyProcessed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alreadyProcessed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Return 200 so Stripe stops retrying — this is intentional&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&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;duplicate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Enqueue the event — don't process synchronously&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;webhookQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// BullMQ deduplicates by jobId within the active window&lt;/span&gt;
      &lt;span class="na"&gt;attempts&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="na"&gt;backoff&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;exponential&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Return immediately — Stripe considers this success&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice. First, the idempotency check runs before enqueuing — so if Stripe retries and the job is still in the queue (not yet processed), you return 200 and BullMQ's &lt;code&gt;jobId&lt;/code&gt; deduplication handles the rest. Second, the endpoint returns immediately after enqueuing. This response time is typically under 50ms, well within Stripe's timeout window.&lt;/p&gt;

&lt;h2&gt;
  
  
  BullMQ Queue Architecture
&lt;/h2&gt;

&lt;p&gt;Processing webhooks synchronously in the HTTP handler means your response time is tied to every downstream API call — CRM updates, email sends, database writes. One slow dependency and you're getting retries.&lt;/p&gt;

&lt;p&gt;The right architecture: webhook endpoint enqueues, a separate worker processes.&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/webhook-queue.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Queue&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bullmq&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/redis&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;webhookQueue&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;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe-webhooks&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;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;defaultJobOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;attempts&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="na"&gt;backoff&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;exponential&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 5s, 10s, 20s, 40s, 80s&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;removeOnComplete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// Keep last 1000 for debugging&lt;/span&gt;
    &lt;span class="na"&gt;removeOnFail&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="c1"&gt;// Keep failed jobs in DLQ for inspection&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;// workers/stripe-webhook.worker.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Job&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bullmq&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/redis&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isAlreadyProcessed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;markAsProcessed&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/webhook-idempotency&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;handlePaymentSucceeded&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./handlers/payment-succeeded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;handleSubscriptionDeleted&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./handlers/subscription-deleted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;handleInvoicePaymentFailed&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./handlers/invoice-payment-failed&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;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe-webhooks&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Job&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Event&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Double-check idempotency inside the worker — edge case: worker restart mid-job&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alreadyProcessed&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;isAlreadyProcessed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alreadyProcessed&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;skipped&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;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;duplicate&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;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;payment_intent.succeeded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handlePaymentSucceeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PaymentIntent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;customer.subscription.deleted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleSubscriptionDeleted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Subscription&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice.payment_failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleInvoicePaymentFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Unhandled webhook type: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;skipped&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;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unhandled_type&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;// Mark processed only after the handler succeeds&lt;/span&gt;
    &lt;span class="c1"&gt;// If the handler throws, BullMQ retries the job — we don't mark it done&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markAsProcessed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&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;processed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;concurrency&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&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;job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// After all retries exhausted, alert your team&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="s2"&gt;`Webhook job &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; failed permanently:`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dead letter queue behavior is built into BullMQ — failed jobs (after all retries) stay in the &lt;code&gt;failed&lt;/code&gt; state. I keep a Telegram alert on the &lt;code&gt;failed&lt;/code&gt; event so I know immediately when something needs manual intervention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling the Key Events
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;payment_intent.succeeded&lt;/code&gt;&lt;/strong&gt; — the most critical handler. Wrap it in a database transaction so partial writes don't leave inconsistent state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// workers/handlers/payment-succeeded.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/packages/db&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;users&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/packages/db/schema&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;eq&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;drizzle-orm&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;handlePaymentSucceeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paymentIntent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PaymentIntent&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;customerId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;paymentIntent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;paymentIntent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;stripePaymentIntentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;paymentIntent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;paymentIntent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;paymentIntent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;paid&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;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;planActivatedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stripeCustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Send confirmation email outside the transaction — failure here&lt;/span&gt;
  &lt;span class="c1"&gt;// doesn't roll back the order; it just retries the email separately&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendOrderConfirmation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;paymentIntentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;paymentIntent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;customer.subscription.deleted&lt;/code&gt;&lt;/strong&gt; — downgrade the user, don't delete their data. Keep at least 30 days of data post-cancellation. Stripe fires this for both immediate cancellations and end-of-period ones — check &lt;code&gt;cancel_at_period_end&lt;/code&gt; to differentiate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;invoice.payment_failed&lt;/code&gt;&lt;/strong&gt; — send a dunning email, don't immediately revoke access. Stripe's Smart Retries will attempt the charge again. Give the customer a grace period to update their payment method.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing with Stripe CLI
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Forward all events to your local Next.js dev server&lt;/span&gt;
stripe listen &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--forward-to&lt;/span&gt; localhost:3000/api/webhooks/stripe

&lt;span class="c"&gt;# Trigger specific events&lt;/span&gt;
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For testing idempotency: copy the event ID from the CLI output and call your endpoint twice with the same payload — the second call should return &lt;code&gt;{ received: true, duplicate: true }&lt;/code&gt; within milliseconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas That Cost Me Real Time
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;request.json()&lt;/code&gt; silently breaks signature verification.&lt;/strong&gt; The raw body and the serialized JSON aren't byte-for-byte identical. Always use &lt;code&gt;request.arrayBuffer()&lt;/code&gt;. This is the most common issue I see in Next.js webhook implementations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BullMQ &lt;code&gt;jobId&lt;/code&gt; deduplication only covers active jobs.&lt;/strong&gt; If a job is completed or failed, BullMQ will accept a new job with the same ID. That's why the database idempotency check is still necessary — it covers retries arriving weeks after the original was processed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;STRIPE_WEBHOOK_SECRET&lt;/code&gt; differs between environments.&lt;/strong&gt; The CLI secret (&lt;code&gt;whsec_...&lt;/code&gt; with CLI prefix) is different from the dashboard secret. Use separate environment variables for each environment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never trust &lt;code&gt;event.data.object&lt;/code&gt; amounts without checking &lt;code&gt;currency&lt;/code&gt;.&lt;/strong&gt; EUR amounts are in cents (100 = €1.00). JPY has no minor unit (100 = ¥100). Always pair the amount with the currency field when storing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Looks Like in Production
&lt;/h2&gt;

&lt;p&gt;On vatnode.dev, webhooks hit the endpoint and return within 40–60ms. The BullMQ worker processes the actual subscription logic asynchronously, with 5 concurrent workers handling bursts. Zero duplicate subscriptions created since launch.&lt;/p&gt;

&lt;p&gt;On pikkuna.fi, the same architecture drives the full order pipeline — Stripe fires the webhook, the worker triggers Zoho CRM, PostNord shipment creation, Netvisor invoice, and Mailgun confirmation email in sequence. The webhook endpoint returns 200 within 50ms; the full chain completes in under 2 minutes.&lt;/p&gt;




&lt;p&gt;If you're building a SaaS or e-commerce platform with Stripe, you'll hit exactly these problems — usually at the worst moment, like during a product launch or after a server restart.&lt;/p&gt;

&lt;p&gt;I've built reliable Stripe integrations across several production systems, from subscription SaaS (&lt;a href="https://iurii.rogulia.fi/projects/vatnode-vat-validation" rel="noopener noreferrer"&gt;vatnode.dev&lt;/a&gt;) to high-volume international e-commerce (&lt;a href="https://iurii.rogulia.fi/projects/pikkuna-ecommerce-platform" rel="noopener noreferrer"&gt;pikkuna.fi&lt;/a&gt;). Once the webhook pipeline is solid, the order automation layer on top becomes straightforward — see &lt;a href="https://iurii.rogulia.fi/blog/ecommerce-order-automation" rel="noopener noreferrer"&gt;how the full e-commerce order pipeline works&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you need a senior developer who can own the payment infrastructure end-to-end — &lt;a href="https://iurii.rogulia.fi/contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt;. I'm available for &lt;a href="https://iurii.rogulia.fi/services/api-integrations" rel="noopener noreferrer"&gt;API integration&lt;/a&gt; and &lt;a href="https://iurii.rogulia.fi/services/e-commerce" rel="noopener noreferrer"&gt;e-commerce development&lt;/a&gt; projects and long-term engagements.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Related projects:&lt;/strong&gt; &lt;a href="https://iurii.rogulia.fi/projects/pikkuna-ecommerce-platform" rel="noopener noreferrer"&gt;Pikkuna E-commerce Platform&lt;/a&gt; — full order pipeline with Stripe, Zoho CRM, and PostNord integration. &lt;a href="https://iurii.rogulia.fi/projects/vatnode-vat-validation" rel="noopener noreferrer"&gt;Vatnode VAT validation SaaS&lt;/a&gt; — subscription billing where this webhook architecture is in production.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>node</category>
      <category>bullmq</category>
    </item>
    <item>
      <title>EU VAT Rates 2026 — Complete Country-by-Country Guide for Developers</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Thu, 21 May 2026 19:30:12 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/eu-vat-rates-2026-complete-country-by-country-guide-for-developers-22o8</link>
      <guid>https://dev.to/iurii_rogulia/eu-vat-rates-2026-complete-country-by-country-guide-for-developers-22o8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://vatnode.dev/blog/eu-vat-rates-2026" 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;Standard, reduced, super-reduced, and parking VAT rates for all 27 EU member states in 2026. Includes recent changes, local currency, and how to access this data programmatically via the vatnode API or the open-source &lt;a href="https://vatnode.dev/vat-rates" rel="noopener noreferrer"&gt;eu-vat-rates-data&lt;/a&gt; package (JavaScript, Python, PHP, Go, Ruby).&lt;/p&gt;

&lt;h2&gt;
  
  
  Recent rate changes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;🇫🇮 &lt;strong&gt;Finland&lt;/strong&gt; — Standard rate raised from 24% to 25.5%. Effective September 2024 · Current standard rate: &lt;strong&gt;25.5%&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;🇪🇪 &lt;strong&gt;Estonia&lt;/strong&gt; — Standard rate raised from 22% to 24%. Effective January 2024 · Current standard rate: &lt;strong&gt;24%&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;🇸🇰 &lt;strong&gt;Slovakia&lt;/strong&gt; — Standard rate raised from 20% to 23%. Effective January 2025 · Current standard rate: &lt;strong&gt;23%&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;🇷🇴 &lt;strong&gt;Romania&lt;/strong&gt; — Standard rate raised from 19% to 21%. Effective January 2025 · Current standard rate: &lt;strong&gt;21%&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  EU VAT Rates 2026 — All 27 Member States
&lt;/h2&gt;

&lt;p&gt;Rates are sourced daily from the &lt;strong&gt;European Commission Tax and Duty Database (TEDB)&lt;/strong&gt;. Last updated: March 2026.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Country&lt;/th&gt;
&lt;th&gt;Currency&lt;/th&gt;
&lt;th&gt;Standard&lt;/th&gt;
&lt;th&gt;Reduced&lt;/th&gt;
&lt;th&gt;Super-Red.&lt;/th&gt;
&lt;th&gt;Parking&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/AT" rel="noopener noreferrer"&gt;Austria&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;td&gt;10%, 13%, 19%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/BE" rel="noopener noreferrer"&gt;Belgium&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;21%&lt;/td&gt;
&lt;td&gt;6%, 12%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;12%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/BG" rel="noopener noreferrer"&gt;Bulgaria&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;BGN&lt;/td&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;td&gt;9%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/HR" rel="noopener noreferrer"&gt;Croatia&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;td&gt;5%, 13%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/CY" rel="noopener noreferrer"&gt;Cyprus&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;19%&lt;/td&gt;
&lt;td&gt;5%, 9%&lt;/td&gt;
&lt;td&gt;3%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/CZ" rel="noopener noreferrer"&gt;Czech Republic&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;CZK&lt;/td&gt;
&lt;td&gt;21%&lt;/td&gt;
&lt;td&gt;12%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/DK" rel="noopener noreferrer"&gt;Denmark&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;DKK&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/EE" rel="noopener noreferrer"&gt;Estonia&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;24%&lt;/td&gt;
&lt;td&gt;9%, 13%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://vatnode.dev/check/FI" rel="noopener noreferrer"&gt;Finland&lt;/a&gt; &lt;em&gt;(Raised from 24% in September 2024)&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;25.5%&lt;/td&gt;
&lt;td&gt;10%, 13.5%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://vatnode.dev/check/FR" rel="noopener noreferrer"&gt;France&lt;/a&gt; &lt;em&gt;(Multiple rates; DOM territories vary)&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;td&gt;5.5%, 10%&lt;/td&gt;
&lt;td&gt;2.1%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/DE" rel="noopener noreferrer"&gt;Germany&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;19%&lt;/td&gt;
&lt;td&gt;7%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/GR" rel="noopener noreferrer"&gt;Greece&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;24%&lt;/td&gt;
&lt;td&gt;6%, 13%&lt;/td&gt;
&lt;td&gt;4%&lt;/td&gt;
&lt;td&gt;13%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/HU" rel="noopener noreferrer"&gt;Hungary&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;HUF&lt;/td&gt;
&lt;td&gt;27%&lt;/td&gt;
&lt;td&gt;5%, 18%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/IE" rel="noopener noreferrer"&gt;Ireland&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;23%&lt;/td&gt;
&lt;td&gt;9%, 13.5%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/IT" rel="noopener noreferrer"&gt;Italy&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;22%&lt;/td&gt;
&lt;td&gt;5%, 10%&lt;/td&gt;
&lt;td&gt;4%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/LV" rel="noopener noreferrer"&gt;Latvia&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;21%&lt;/td&gt;
&lt;td&gt;5%, 12%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/LT" rel="noopener noreferrer"&gt;Lithuania&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;21%&lt;/td&gt;
&lt;td&gt;5%, 12%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/LU" rel="noopener noreferrer"&gt;Luxembourg&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;17%&lt;/td&gt;
&lt;td&gt;8%, 14%&lt;/td&gt;
&lt;td&gt;3%&lt;/td&gt;
&lt;td&gt;14%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/MT" rel="noopener noreferrer"&gt;Malta&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;18%&lt;/td&gt;
&lt;td&gt;5%, 7%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;12%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/NL" rel="noopener noreferrer"&gt;Netherlands&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;21%&lt;/td&gt;
&lt;td&gt;9%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/PL" rel="noopener noreferrer"&gt;Poland&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;PLN&lt;/td&gt;
&lt;td&gt;23%&lt;/td&gt;
&lt;td&gt;5%, 8%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://vatnode.dev/check/PT" rel="noopener noreferrer"&gt;Portugal&lt;/a&gt; &lt;em&gt;(Azores and Madeira have lower rates)&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;23%&lt;/td&gt;
&lt;td&gt;6%, 13%&lt;/td&gt;
&lt;td&gt;6%&lt;/td&gt;
&lt;td&gt;13%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/RO" rel="noopener noreferrer"&gt;Romania&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;RON&lt;/td&gt;
&lt;td&gt;21%&lt;/td&gt;
&lt;td&gt;11%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/SK" rel="noopener noreferrer"&gt;Slovakia&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;23%&lt;/td&gt;
&lt;td&gt;5%, 19%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/SI" rel="noopener noreferrer"&gt;Slovenia&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;22%&lt;/td&gt;
&lt;td&gt;5%, 9.5%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/ES" rel="noopener noreferrer"&gt;Spain&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;EUR&lt;/td&gt;
&lt;td&gt;21%&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;4%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://vatnode.dev/check/SE" rel="noopener noreferrer"&gt;Sweden&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;SEK&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;td&gt;6%, 12%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Rate types explained
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Standard rate&lt;/strong&gt; — The main VAT rate that applies to most goods and services. Ranges from 17% (Luxembourg) to 27% (Hungary) across the EU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reduced rate&lt;/strong&gt; — Lower rates that apply to specific categories — typically food, books, medicines, public transport, and cultural services. Countries may have one or two reduced rates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Super-reduced rate&lt;/strong&gt; — An even lower rate (below 5%) that a subset of EU countries apply to essential goods. Examples: 2.1% in France for some newspapers and medicines, 3% in Luxembourg.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parking rate&lt;/strong&gt; — A transitional rate some countries kept when the EU harmonized VAT rules. Applies to goods and services that were at a reduced rate before the EU's 6th VAT Directive. Only Belgium, Greece, Luxembourg, Malta, and Portugal still use parking rates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Access VAT rates programmatically
&lt;/h2&gt;

&lt;p&gt;The vatnode API returns the current VAT rates for a country as part of every validation response — no separate rates call needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.vatnode.dev/v1/vat/FI29845875 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$VATNODE_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# countryVat block included in every response:&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"countryVat"&lt;/span&gt;: &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"vatName"&lt;/span&gt;: &lt;span class="s2"&gt;"Arvonlisävero"&lt;/span&gt;,   // Finnish: &lt;span class="s2"&gt;"value added tax"&lt;/span&gt;
    &lt;span class="s2"&gt;"vatAbbr"&lt;/span&gt;: &lt;span class="s2"&gt;"ALV"&lt;/span&gt;,             // abbreviation &lt;span class="k"&gt;for &lt;/span&gt;invoice printing
    &lt;span class="s2"&gt;"currency"&lt;/span&gt;: &lt;span class="s2"&gt;"EUR"&lt;/span&gt;,
    &lt;span class="s2"&gt;"standardRate"&lt;/span&gt;: 25.5,         // updated September 2024
    &lt;span class="s2"&gt;"reducedRates"&lt;/span&gt;: &lt;span class="o"&gt;[&lt;/span&gt;10, 13.5],
    &lt;span class="s2"&gt;"superReducedRate"&lt;/span&gt;: null,
    &lt;span class="s2"&gt;"parkingRate"&lt;/span&gt;: null,
    &lt;span class="s2"&gt;"countryVatUpdatedAt"&lt;/span&gt;: &lt;span class="s2"&gt;"2026-03-30"&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also query rates directly by country code without validating a VAT number — useful for displaying tax information in your UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Free endpoint — no API key required&lt;/span&gt;
curl https://api.vatnode.dev/v1/rates/DE

&lt;span class="c"&gt;# Response&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"countryCode"&lt;/span&gt;: &lt;span class="s2"&gt;"DE"&lt;/span&gt;,
  &lt;span class="s2"&gt;"countryName"&lt;/span&gt;: &lt;span class="s2"&gt;"Germany"&lt;/span&gt;,
  &lt;span class="s2"&gt;"vatName"&lt;/span&gt;: &lt;span class="s2"&gt;"Mehrwertsteuer"&lt;/span&gt;,
  &lt;span class="s2"&gt;"vatAbbr"&lt;/span&gt;: &lt;span class="s2"&gt;"MwSt"&lt;/span&gt;,
  &lt;span class="s2"&gt;"currency"&lt;/span&gt;: &lt;span class="s2"&gt;"EUR"&lt;/span&gt;,
  &lt;span class="s2"&gt;"standardRate"&lt;/span&gt;: 19,
  &lt;span class="s2"&gt;"reducedRates"&lt;/span&gt;: &lt;span class="o"&gt;[&lt;/span&gt;7],
  &lt;span class="s2"&gt;"superReducedRate"&lt;/span&gt;: null,
  &lt;span class="s2"&gt;"parkingRate"&lt;/span&gt;: null,
  &lt;span class="s2"&gt;"countryVatUpdatedAt"&lt;/span&gt;: &lt;span class="s2"&gt;"2026-03-30"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a standalone open-source package (no API key, no rate limits), see the &lt;a href="https://vatnode.dev/vat-rates" rel="noopener noreferrer"&gt;EU VAT rates tool&lt;/a&gt; available for JavaScript, Python, PHP, Go, and Ruby.&lt;/p&gt;

&lt;p&gt;Once you have the correct rate, the next step is confirming the buyer's VAT registration is active. See the guide on how to &lt;a href="https://vatnode.dev/guides/how-to-validate-vat-numbers" rel="noopener noreferrer"&gt;validate EU VAT numbers programmatically&lt;/a&gt; for a complete checkout integration pattern.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Always-current VAT rates via API&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;vatnode syncs rates daily from the EC TEDB. The rates in your API response are always up to date — including recent changes like Finland's 25.5% and Slovakia's 23%.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://vatnode.dev/register" rel="noopener noreferrer"&gt;Get Free API Key&lt;/a&gt; · &lt;a href="https://vatnode.dev/docs/rates" rel="noopener noreferrer"&gt;Rates API Docs&lt;/a&gt; · &lt;a href="https://vatnode.dev/vat-rates" rel="noopener noreferrer"&gt;Open-Source Package&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>tax</category>
      <category>europe</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Check a PDF Before Payment: 7-Step Checklist</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Thu, 21 May 2026 10:00:27 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/how-to-check-a-pdf-before-payment-7-step-checklist-ani</link>
      <guid>https://dev.to/iurii_rogulia/how-to-check-a-pdf-before-payment-7-step-checklist-ani</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/how-to-verify-pdf-before-payment" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt;. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The moment before you click “Pay” is critical. Once funds are transferred, recovery becomes difficult or impossible. Yet many businesses process payments based on PDF invoices without fraud detection, trusting that what they see is authentic.&lt;/p&gt;

&lt;p&gt;This trust is often misplaced. Invoice fraud through PDF modification is a growing threat, with criminals altering invoices to redirect payments to fraudulent accounts. According to the &lt;a href="https://www.ic3.gov/" rel="noopener noreferrer"&gt;FBI Internet Crime Complaint Center&lt;/a&gt;, business email compromise attacks involving modified invoices resulted in losses exceeding $2.7 billion in 2022.&lt;/p&gt;

&lt;p&gt;This article provides a practical 7-step checklist to check PDF invoices before making payments. Following this checklist can prevent devastating financial losses and protect your business from payment fraud.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Moment Before You Click “Pay”
&lt;/h2&gt;

&lt;p&gt;Payment processing is a critical business function, but it is also a prime target for fraudsters. The combination of trust in invoices, time pressure, and the difficulty of recovering funds makes payment fraud highly effective.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Invoices look official and are trusted&lt;/li&gt;
&lt;li&gt;Time pressure encourages skipping fraud detection&lt;/li&gt;
&lt;li&gt;PDFs can be modified without obvious signs&lt;/li&gt;
&lt;li&gt;Funds are difficult to recover once transferred&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The solution:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Systematic fraud detection before every payment&lt;/li&gt;
&lt;li&gt;Multiple fraud detection methods&lt;/li&gt;
&lt;li&gt;Independent confirmation channels&lt;/li&gt;
&lt;li&gt;Automated PDF tamper detection tools&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As &lt;a href="https://ramp.com/blog/invoice-verification" rel="noopener noreferrer"&gt;Ramp explains&lt;/a&gt;, fraud detection is not optional — it is essential for payment security.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Fraud Detection Matters
&lt;/h2&gt;

&lt;p&gt;Understanding the risks helps justify fraud detection time:&lt;/p&gt;

&lt;h3&gt;
  
  
  Fraud Statistics
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Average loss&lt;/strong&gt;: $150,000 per invoice fraud incident&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recovery rate&lt;/strong&gt;: Less than 10% of funds recovered&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frequency&lt;/strong&gt;: Increasing year-over-year&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Target&lt;/strong&gt;: All businesses, regardless of size&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Common Attack Methods
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Email interception&lt;/strong&gt;: Criminals intercept legitimate invoices&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDF modification&lt;/strong&gt;: Bank account details changed in PDF&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Social engineering&lt;/strong&gt;: Urgency created to bypass fraud detection&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impersonation&lt;/strong&gt;: Fake emails from compromised accounts&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Consequences
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Financial loss&lt;/strong&gt;: Direct theft of funds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operational disruption&lt;/strong&gt;: Payment processing delays&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reputation damage&lt;/strong&gt;: Loss of vendor trust&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legal issues&lt;/strong&gt;: Compliance and regulatory problems&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As &lt;a href="https://tipalti.com/resources/learn/invoice-verification/" rel="noopener noreferrer"&gt;Tipalti reports&lt;/a&gt;, fraud detection prevents the majority of payment fraud attempts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 7-Step Pre-Payment Fraud Detection Checklist
&lt;/h2&gt;

&lt;p&gt;Use this checklist for every payment:&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Check Sender Email Address
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What to check:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Email address matches vendor records&lt;/li&gt;
&lt;li&gt;No slight variations (e.g., &lt;code&gt;vendor@company.com&lt;/code&gt; vs &lt;code&gt;vendor@cornpany.com&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Email domain matches vendor website&lt;/li&gt;
&lt;li&gt;Sender name matches known contacts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How to check:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Compare with vendor master file&lt;/li&gt;
&lt;li&gt;Check previous email correspondence&lt;/li&gt;
&lt;li&gt;Check domain through vendor website&lt;/li&gt;
&lt;li&gt;Contact vendor if email address changed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Red flags:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New email address without prior notice&lt;/li&gt;
&lt;li&gt;Slight variations in domain name&lt;/li&gt;
&lt;li&gt;Email from personal account instead of business&lt;/li&gt;
&lt;li&gt;Unexpected sender name&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why it matters&lt;/strong&gt;: Email compromise is the primary attack vector for invoice fraud. Checking sender identity is the first line of defense.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Check PDF Metadata for Anomalies
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What to check:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Creation and modification dates&lt;/li&gt;
&lt;li&gt;Creator and producer applications&lt;/li&gt;
&lt;li&gt;Metadata consistency&lt;/li&gt;
&lt;li&gt;Unexpected applications&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How to check:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open PDF Properties (File → Properties)&lt;/li&gt;
&lt;li&gt;Review creation and modification dates&lt;/li&gt;
&lt;li&gt;Check creator and producer fields&lt;/li&gt;
&lt;li&gt;Look for unexpected applications&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Red flags:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Recent modification of old invoice&lt;/li&gt;
&lt;li&gt;Invoice created in unexpected application&lt;/li&gt;
&lt;li&gt;Multiple producer entries&lt;/li&gt;
&lt;li&gt;Metadata inconsistencies&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why it matters&lt;/strong&gt;: Metadata reveals document processing history. Anomalies can indicate PDF modification.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automated option&lt;/strong&gt;: Use &lt;a href="https://htpbe.tech" rel="noopener noreferrer"&gt;HTPBE?&lt;/a&gt; to automatically check metadata and detect modifications — just upload the invoice PDF for instant analysis.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Compare Bank Details with Records
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What to check:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bank account number matches vendor records&lt;/li&gt;
&lt;li&gt;Routing number is correct&lt;/li&gt;
&lt;li&gt;Bank name matches expected institution&lt;/li&gt;
&lt;li&gt;SWIFT code (for international payments) is correct&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How to check:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Compare with vendor master file&lt;/li&gt;
&lt;li&gt;Check previous payment records&lt;/li&gt;
&lt;li&gt;Check bank details independently&lt;/li&gt;
&lt;li&gt;Confirm with vendor if details changed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Red flags:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New bank account without prior notice&lt;/li&gt;
&lt;li&gt;Account number different from records&lt;/li&gt;
&lt;li&gt;Bank name changed unexpectedly&lt;/li&gt;
&lt;li&gt;Routing number does not match bank&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why it matters&lt;/strong&gt;: Bank account changes are the primary goal of invoice fraud. Checking account details prevents payment redirection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Critical&lt;/strong&gt;: Never trust bank details from email alone. Always check through independent channels.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Check Digital Signatures if Present
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What to check:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Digital signature presence&lt;/li&gt;
&lt;li&gt;Signature validity status&lt;/li&gt;
&lt;li&gt;Certificate validity&lt;/li&gt;
&lt;li&gt;Signature scope&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How to check:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open PDF in Adobe Acrobat Reader&lt;/li&gt;
&lt;li&gt;Look for signature panel or field&lt;/li&gt;
&lt;li&gt;Click signature to view status&lt;/li&gt;
&lt;li&gt;Check signature validity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Red flags:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Invalid digital signature&lt;/li&gt;
&lt;li&gt;Signature missing when expected&lt;/li&gt;
&lt;li&gt;Certificate expired or revoked&lt;/li&gt;
&lt;li&gt;Signature scope does not cover entire document&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why it matters&lt;/strong&gt;: Digital signatures provide cryptographic proof of document integrity. Invalid signatures indicate modification.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;: Not all invoices have digital signatures. When present, they provide strong authenticity proof.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Call Vendor to Confirm (Use Known Number)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What to check:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Invoice authenticity&lt;/li&gt;
&lt;li&gt;Bank account details&lt;/li&gt;
&lt;li&gt;Payment amount&lt;/li&gt;
&lt;li&gt;Payment timing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How to check:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Call vendor using known phone number (not from email)&lt;/li&gt;
&lt;li&gt;Confirm invoice details&lt;/li&gt;
&lt;li&gt;Check bank account information&lt;/li&gt;
&lt;li&gt;Confirm payment amount and timing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Red flags:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vendor cannot confirm invoice&lt;/li&gt;
&lt;li&gt;Bank details do not match vendor records&lt;/li&gt;
&lt;li&gt;Vendor unaware of invoice&lt;/li&gt;
&lt;li&gt;Urgency pressure to skip fraud detection&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why it matters&lt;/strong&gt;: Independent fraud detection through phone call prevents email-based fraud. Using known numbers prevents phone number spoofing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Critical&lt;/strong&gt;: Always use phone numbers from your records, never from emails or invoices.&lt;/p&gt;

&lt;p&gt;As &lt;a href="https://resolvepay.com/blog/invoice-verification-call" rel="noopener noreferrer"&gt;ResolvePay explains&lt;/a&gt;, fraud detection calls are essential for preventing payment fraud.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Check for Duplicate Invoice Numbers
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What to check:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Invoice number matches records&lt;/li&gt;
&lt;li&gt;No duplicate invoice numbers&lt;/li&gt;
&lt;li&gt;Sequential invoice numbering&lt;/li&gt;
&lt;li&gt;Invoice number format consistency&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How to check:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Check invoice number against records&lt;/li&gt;
&lt;li&gt;Search for duplicate invoice numbers&lt;/li&gt;
&lt;li&gt;Check invoice numbering sequence&lt;/li&gt;
&lt;li&gt;Confirm invoice number format&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Red flags:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Duplicate invoice number&lt;/li&gt;
&lt;li&gt;Invoice number out of sequence&lt;/li&gt;
&lt;li&gt;Invoice number format changed&lt;/li&gt;
&lt;li&gt;Missing invoice numbers in sequence&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why it matters&lt;/strong&gt;: Duplicate invoices can indicate fraud or processing errors. Checking invoice numbers helps detect both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7: Use automated fraud detection Tools
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What to check:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PDF modification detection&lt;/li&gt;
&lt;li&gt;Comprehensive analysis&lt;/li&gt;
&lt;li&gt;Confidence scoring&lt;/li&gt;
&lt;li&gt;Detailed reporting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How to check:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Upload invoice PDF to fraud-detection tool&lt;/li&gt;
&lt;li&gt;Review analysis results&lt;/li&gt;
&lt;li&gt;Check confidence scores&lt;/li&gt;
&lt;li&gt;Examine detailed indicators&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Detects modifications manual review misses&lt;/li&gt;
&lt;li&gt;Provides confidence scores&lt;/li&gt;
&lt;li&gt;Analyzes multiple indicators&lt;/li&gt;
&lt;li&gt;Creates fraud detection records&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why it matters&lt;/strong&gt;: Automated tools provide comprehensive analysis that manual review cannot match. They detect sophisticated modifications and provide objective assessment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integration&lt;/strong&gt;: Use automated fraud detection as part of payment workflow for consistent application.&lt;/p&gt;

&lt;p&gt;As &lt;a href="https://planergy.com/blog/invoice-verification-process/" rel="noopener noreferrer"&gt;PLANERGY notes&lt;/a&gt;, automated fraud detection tools are essential for modern payment security.&lt;/p&gt;

&lt;h2&gt;
  
  
  Red Flags That Should Stop Payment
&lt;/h2&gt;

&lt;p&gt;Certain red flags should immediately halt payment processing:&lt;/p&gt;

&lt;h3&gt;
  
  
  Critical Red Flags
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Stop payment if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PDF tamper detection shows modification&lt;/li&gt;
&lt;li&gt;Bank account details changed without prior notice&lt;/li&gt;
&lt;li&gt;Vendor cannot confirm invoice by phone&lt;/li&gt;
&lt;li&gt;Digital signature is invalid&lt;/li&gt;
&lt;li&gt;Multiple fraud detection failures&lt;/li&gt;
&lt;li&gt;Urgency pressure to skip fraud detection&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Warning Signs
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Investigate before payment:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sender email address changed&lt;/li&gt;
&lt;li&gt;Metadata shows unexpected modifications&lt;/li&gt;
&lt;li&gt;Invoice number is duplicate&lt;/li&gt;
&lt;li&gt;Payment amount is higher than expected&lt;/li&gt;
&lt;li&gt;Payment timing is unusual&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  When in Doubt
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Best practice:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do not proceed with payment&lt;/li&gt;
&lt;li&gt;Investigate further&lt;/li&gt;
&lt;li&gt;Check through multiple channels&lt;/li&gt;
&lt;li&gt;Get management approval&lt;/li&gt;
&lt;li&gt;Document concerns&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As &lt;a href="https://www.invoiceowl.com/invoicing-guide/how-to-verify-an-invoice/" rel="noopener noreferrer"&gt;InvoiceOwl explains&lt;/a&gt;, when in doubt, delay payment until fraud detection is complete.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Do If Something Seems Wrong
&lt;/h2&gt;

&lt;p&gt;If fraud detection reveals problems:&lt;/p&gt;

&lt;h3&gt;
  
  
  Immediate Actions
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stop payment&lt;/strong&gt;: Halt payment processing immediately&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document findings&lt;/strong&gt;: Record all detection results&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notify management&lt;/strong&gt;: Escalate concerns to management&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contact vendor&lt;/strong&gt;: Check directly with vendor&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Investigate further&lt;/strong&gt;: Determine if fraud or error&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Investigation Steps
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Review detection results&lt;/strong&gt;: Examine all indicators&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contact vendor&lt;/strong&gt;: Check invoice authenticity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check records&lt;/strong&gt;: Compare with vendor master file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review communication&lt;/strong&gt;: Check email history&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document everything&lt;/strong&gt;: Keep records of investigation&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Resolution
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;If fraud confirmed:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Notify law enforcement&lt;/li&gt;
&lt;li&gt;Report to FBI IC3&lt;/li&gt;
&lt;li&gt;Contact bank to attempt recovery&lt;/li&gt;
&lt;li&gt;Review security processes&lt;/li&gt;
&lt;li&gt;Update fraud detection procedures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;If error confirmed:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Process payment with correct details&lt;/li&gt;
&lt;li&gt;Update vendor records&lt;/li&gt;
&lt;li&gt;Document error resolution&lt;/li&gt;
&lt;li&gt;Review processes to prevent recurrence&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As &lt;a href="https://xelix.com/resources/accounts-payable-processes/invoice-fraud-detection-what-is-invoice-fraud-and-how-can-you-spot-it" rel="noopener noreferrer"&gt;Xelix explains&lt;/a&gt;, quick action improves fraud prevention and recovery chances.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating a fraud-detection workflow
&lt;/h2&gt;

&lt;p&gt;Implementing systematic fraud detection:&lt;/p&gt;

&lt;h3&gt;
  
  
  Workflow Design
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Process steps:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Receive invoice&lt;/li&gt;
&lt;li&gt;Check sender email&lt;/li&gt;
&lt;li&gt;Check PDF metadata&lt;/li&gt;
&lt;li&gt;Compare bank details&lt;/li&gt;
&lt;li&gt;Check digital signature (if present)&lt;/li&gt;
&lt;li&gt;Call vendor to confirm&lt;/li&gt;
&lt;li&gt;Check invoice number&lt;/li&gt;
&lt;li&gt;Use automated fraud detection&lt;/li&gt;
&lt;li&gt;Approve payment if all checks pass&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Automation Opportunities
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Automate where possible:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PDF tamper detection (automated tools)&lt;/li&gt;
&lt;li&gt;Invoice number checking (database lookup)&lt;/li&gt;
&lt;li&gt;Bank detail comparison (vendor master file)&lt;/li&gt;
&lt;li&gt;Email fraud detection (sender validation)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Manual steps:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Phone call to vendor&lt;/li&gt;
&lt;li&gt;Management approval&lt;/li&gt;
&lt;li&gt;Exception handling&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Documentation
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Maintain records:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;detection results&lt;/li&gt;
&lt;li&gt;Phone call confirmations&lt;/li&gt;
&lt;li&gt;Exception approvals&lt;/li&gt;
&lt;li&gt;Fraud incidents&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Audit trail:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Complete fraud detection history&lt;/li&gt;
&lt;li&gt;Decision documentation&lt;/li&gt;
&lt;li&gt;Approval records&lt;/li&gt;
&lt;li&gt;Incident reports&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tools and Resources
&lt;/h2&gt;

&lt;p&gt;Fraud Detection tools and resources:&lt;/p&gt;

&lt;h3&gt;
  
  
  PDF Tamper Detection
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://htpbe.tech" rel="noopener noreferrer"&gt;HTPBE?&lt;/a&gt;&lt;/strong&gt; provides automated PDF tamper detection with confidence scores — upload any invoice PDF and get instant analysis showing whether the document has been modified. The free web interface requires no signup and processes documents in seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Vendor Management
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vendor master files&lt;/strong&gt;: Centralized vendor information&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contact databases&lt;/strong&gt;: Known phone numbers and emails&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payment records&lt;/strong&gt;: Historical payment information&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Communication Channels
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Phone fraud detection&lt;/strong&gt;: Known vendor phone numbers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secure portals&lt;/strong&gt;: Vendor document submission&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encrypted email&lt;/strong&gt;: Secure communication channels&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Checking PDF invoices before payment is essential for preventing fraud. The 7-step checklist provides a systematic approach:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check sender email address&lt;/li&gt;
&lt;li&gt;Check PDF metadata for anomalies&lt;/li&gt;
&lt;li&gt;Compare bank details with records&lt;/li&gt;
&lt;li&gt;Check digital signatures if present&lt;/li&gt;
&lt;li&gt;Call vendor to confirm (use known number)&lt;/li&gt;
&lt;li&gt;Check for duplicate invoice numbers&lt;/li&gt;
&lt;li&gt;Use automated fraud detection tools&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Following this checklist prevents the majority of payment fraud attempts. When combined with automated fraud detection tools, it provides comprehensive protection against invoice fraud.&lt;/p&gt;

&lt;p&gt;Remember: a few minutes spent checking an invoice can prevent devastating financial losses. When in doubt, delay payment until fraud detection is complete.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>tutorial</category>
      <category>fraud</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Technical SEO for Next.js: SSR, JSON-LD, and Sitemaps</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Thu, 21 May 2026 07:00:26 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/technical-seo-for-nextjs-ssr-json-ld-and-sitemaps-3cij</link>
      <guid>https://dev.to/iurii_rogulia/technical-seo-for-nextjs-ssr-json-ld-and-sitemaps-3cij</guid>
      <description>&lt;p&gt;I don't offer SEO as a service. But every site I build ships with technical SEO baked in — because it's not marketing, it's engineering. A site that search engines can't crawl or understand is a site that doesn't work properly. In the &lt;a href="https://iurii.rogulia.fi/projects/pikkuna-ecommerce-platform" rel="noopener noreferrer"&gt;pikkuna.fi e-commerce build&lt;/a&gt;, that meant correct &lt;code&gt;hreflang&lt;/code&gt; across 30 languages, server-rendered product pages, and JSON-LD that survived localization. Same principles, scaled up.&lt;/p&gt;

&lt;p&gt;
  slug="mvp-development"&lt;br&gt;
  text="Building a Next.js project that needs to rank from day one? SSR, structured data, and sitemaps are part of what I ship — not add-ons."&lt;br&gt;
/&amp;gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Server-Side Rendering by Default
&lt;/h2&gt;

&lt;p&gt;Single-page apps with client-side rendering are invisible to most crawlers. Every project I build uses Next.js App Router with server components:&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/products/[slug]/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&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;ProductPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="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;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;h1&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/h1&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/p&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/article&lt;/span&gt;&lt;span class="err"&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 HTML arrives fully rendered. No JavaScript required for indexing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dynamic Meta Tags
&lt;/h2&gt;

&lt;p&gt;Every page gets proper meta tags generated from actual content:&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/products/[slug]/page.tsx&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;generateMetadata&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;params&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="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="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;Metadata&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; | Store`&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="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;160&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&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;This handles SEO meta tags, Open Graph for social sharing, and Twitter cards — all from one function.&lt;/p&gt;

&lt;h2&gt;
  
  
  JSON-LD Structured Data
&lt;/h2&gt;

&lt;p&gt;Schema.org markup tells search engines exactly what the content represents:&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;ProductJsonLd&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Product&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@context&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://schema.org&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Product&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;offers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Offer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;priceCurrency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EUR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;availability&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://schema.org/InStock&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;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;script&lt;/span&gt;
      &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/ld+json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="nx"&gt;dangerouslySetInnerHTML&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="na"&gt;__html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;
    &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&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;I add appropriate schemas based on content type: &lt;code&gt;Product&lt;/code&gt;, &lt;code&gt;Article&lt;/code&gt;, &lt;code&gt;Organization&lt;/code&gt;, &lt;code&gt;FAQPage&lt;/code&gt;, &lt;code&gt;BreadcrumbList&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automatic Sitemap Generation
&lt;/h2&gt;

&lt;p&gt;Next.js can generate sitemaps from your data:&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/sitemap.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&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;sitemap&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;MetadataRoute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Sitemap&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;products&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;getAllProducts&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="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;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lastModified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://example.com/products/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;lastModified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updatedAt&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;No plugins, no external services. Just code that generates &lt;code&gt;/sitemap.xml&lt;/code&gt; at build time.&lt;/p&gt;




&lt;p&gt;This isn't optimization work you hire someone for later. It's the baseline for a properly built website. When I deliver a project, the technical SEO foundation is already there — because skipping it means shipping broken software.&lt;/p&gt;

&lt;p&gt;If you want to go further with the indexing layer, read &lt;a href="https://iurii.rogulia.fi/blog/indexnow-for-developers" rel="noopener noreferrer"&gt;IndexNow in Next.js: Instant Indexing After Every Deploy&lt;/a&gt; and &lt;a href="https://iurii.rogulia.fi/blog/llms-txt-ai-discoverability" rel="noopener noreferrer"&gt;llms.txt for AI Discoverability&lt;/a&gt;. Together with the patterns above, that's the full stack.&lt;/p&gt;

&lt;p&gt;
  slug="seo-audit"&lt;br&gt;
  text="Want this same checklist applied to your existing site? Fixed-fee Technical SEO Audit — keyword research per market, schema, sitemap, hreflang, Core Web Vitals, indexing, broken links, OG images — delivered as a prioritised written report in 5 working days."&lt;br&gt;
/&amp;gt;&lt;/p&gt;

&lt;p&gt;For &lt;a href="https://iurii.rogulia.fi/services/mvp-development" rel="noopener noreferrer"&gt;MVP development&lt;/a&gt; with this foundation built in from the start — &lt;a href="https://iurii.rogulia.fi/contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>typescript</category>
      <category>seo</category>
    </item>
    <item>
      <title>How to Report a Modified PDF to the Sender: A Professional Guide</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Wed, 20 May 2026 10:00:25 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/how-to-report-a-modified-pdf-to-the-sender-a-professional-guide-1f4b</link>
      <guid>https://dev.to/iurii_rogulia/how-to-report-a-modified-pdf-to-the-sender-a-professional-guide-1f4b</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/how-to-report-modified-pdf-to-sender" 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;Discovering that a PDF document you received has been modified can be unsettling. Whether it is an invoice, contract, certificate, or payment confirmation, document tampering raises serious concerns about authenticity and trust.&lt;/p&gt;

&lt;p&gt;But here is the important thing: &lt;strong&gt;a “modified” result does not automatically mean fraud&lt;/strong&gt;. Many legitimate actions create modifications — re-saving a file, adding digital signatures, converting formats, or making authorized edits. The key is knowing how to communicate professionally and gather more information.&lt;/p&gt;

&lt;p&gt;This guide will help you navigate this sensitive situation while maintaining professional relationships.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understanding HTPBE?’s Modification Confidence
&lt;/h2&gt;

&lt;p&gt;Before reaching out to anyone, understand what this analysis actually tells you:&lt;/p&gt;

&lt;h3&gt;
  
  
  100% Confidence (Definitive Finding)
&lt;/h3&gt;

&lt;p&gt;When HTPBE? shows &lt;strong&gt;100% Confidence&lt;/strong&gt;, it means the analysis has found conclusive cryptographic evidence that the PDF was modified after its digital signature was applied. This is a definitive finding — it cannot be a false positive.&lt;/p&gt;

&lt;p&gt;This happens when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A digitally signed PDF was edited after signing&lt;/li&gt;
&lt;li&gt;The signature hash no longer matches the document content&lt;/li&gt;
&lt;li&gt;There are modifications detected after the signature timestamp&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  High Confidence
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;High Confidence&lt;/strong&gt; indicates strong structural evidence that the PDF was modified after its initial creation. While highly reliable, false positives can occur in some legitimate workflows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Linearized PDFs (optimized for web viewing)&lt;/li&gt;
&lt;li&gt;PDF/A conversion for archival&lt;/li&gt;
&lt;li&gt;Re-saving without content changes&lt;/li&gt;
&lt;li&gt;Form field filling&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Standard Detection
&lt;/h3&gt;

&lt;p&gt;For files without digital signatures, HTPBE?’s analysis examines metadata, cross-reference tables, incremental updates, and other technical indicators. These provide strong evidence but should be considered alongside the document context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Share the Result
&lt;/h2&gt;

&lt;p&gt;The easiest way to start a conversation is to &lt;strong&gt;share your HTPBE? result directly&lt;/strong&gt; with the sender. Every result page has a &lt;strong&gt;Share&lt;/strong&gt; button that generates a shareable link.&lt;/p&gt;

&lt;p&gt;When you share a result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The recipient sees the exact same analysis you see&lt;/li&gt;
&lt;li&gt;They can review all technical details themselves&lt;/li&gt;
&lt;li&gt;No accusations needed — just “here is what I found”&lt;/li&gt;
&lt;li&gt;The conversation starts from a neutral, factual basis&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Sample Message with Shared Result
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;Hi [Name],&lt;/p&gt;

&lt;p&gt;I was reviewing the [document type] you sent on [date] as part of my standard detection process. I used htpbe.tech to check the PDF, and it flagged some concerns I wanted to discuss with you.&lt;/p&gt;

&lt;p&gt;Here is the analysis report: [paste HTPBE result link]&lt;/p&gt;

&lt;p&gt;Could you help me understand if any modifications were made to this document before sending? I want to make sure we are working with the correct version.&lt;/p&gt;

&lt;p&gt;Thanks for your help!&lt;/p&gt;

&lt;p&gt;[Your Name]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This approach is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Non-accusatory&lt;/strong&gt; — you are asking for clarification, not making accusations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transparent&lt;/strong&gt; — they see exactly what you see&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Professional&lt;/strong&gt; — you are following a detection process, not singling them out&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 2: Understanding Their Response
&lt;/h2&gt;

&lt;h3&gt;
  
  
  If They Confirm Legitimate Modification
&lt;/h3&gt;

&lt;p&gt;Many modifications are perfectly normal:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Modification Type&lt;/th&gt;
&lt;th&gt;Legitimate Reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Re-saved file&lt;/td&gt;
&lt;td&gt;Software auto-save, format conversion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Digital signature added&lt;/td&gt;
&lt;td&gt;Normal signing workflow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Form fields filled&lt;/td&gt;
&lt;td&gt;Standard form completion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Combined documents&lt;/td&gt;
&lt;td&gt;Merging multiple PDFs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Converted from Word&lt;/td&gt;
&lt;td&gt;Standard document creation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If their explanation makes sense:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Request documentation of the changes if needed for records&lt;/li&gt;
&lt;li&gt;Ask for the original version if your compliance requires it&lt;/li&gt;
&lt;li&gt;Update your fraud detection records accordingly&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  If They Are Unaware of Modifications
&lt;/h3&gt;

&lt;p&gt;This is a red flag that requires escalation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Request a fresh copy&lt;/strong&gt; directly from their source system (not email)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check through an alternative channel&lt;/strong&gt; — call them directly using a known number&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check for email compromise&lt;/strong&gt; — fraudsters often intercept emails and modify attachments&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document everything&lt;/strong&gt; — keep records of all communication&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; Invoice fraud often involves intercepting legitimate emails and changing payment details. If an invoice shows unexpected bank account changes along with modification markers, treat this as a serious security incident.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  If They Do Not Respond
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Send a follow-up after 48–72 hours&lt;/li&gt;
&lt;li&gt;Use an alternative contact method (phone call, different email)&lt;/li&gt;
&lt;li&gt;Document all communication attempts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not proceed with payments or actions&lt;/strong&gt; based on unchecked documents&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3: When You Disagree with the Result
&lt;/h2&gt;

&lt;p&gt;What if you believe this analysis is incorrect? HTPBE? has a &lt;strong&gt;Dispute This Result&lt;/strong&gt; feature specifically for this situation.&lt;/p&gt;

&lt;h3&gt;
  
  
  How the Dispute Process Works
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;“Dispute This Result”&lt;/strong&gt; on any result page&lt;/li&gt;
&lt;li&gt;Optionally provide your email for follow-up&lt;/li&gt;
&lt;li&gt;Submit the dispute&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;What happens next:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The HTPBE team receives the analysis metadata (filename, scores, technical indicators)&lt;/li&gt;
&lt;li&gt;They manually review the file using advanced forensic techniques&lt;/li&gt;
&lt;li&gt;If they find the analysis was incorrect, they update the result&lt;/li&gt;
&lt;li&gt;If you provided an email, they contact you with their findings&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Privacy note:&lt;/strong&gt; We only receive metadata — your actual PDF content is never transmitted during a dispute.&lt;/p&gt;

&lt;h3&gt;
  
  
  When to Use Dispute
&lt;/h3&gt;

&lt;p&gt;Use the dispute feature when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You know the document is original but it shows as modified&lt;/li&gt;
&lt;li&gt;The sender confirms no modifications were made&lt;/li&gt;
&lt;li&gt;You have additional context HTPBE? might not have considered&lt;/li&gt;
&lt;li&gt;The analysis seems inconsistent with the document source&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Professional Email Templates
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Template 1: Initial Inquiry (Neutral Tone)
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Subject:&lt;/strong&gt; Document Fraud Detection Request — [Document Name/Reference]&lt;/p&gt;

&lt;p&gt;Dear [Name],&lt;/p&gt;

&lt;p&gt;I hope this message finds you well. I am writing regarding the [document type] you sent on [date].&lt;/p&gt;

&lt;p&gt;As part of my standard document fraud detection process, I ran the file through htpbe.tech. The analysis detected some indicators that suggest the file may have been modified after its original creation.&lt;/p&gt;

&lt;p&gt;You can view the full analysis here: [HTPBE Result Link]&lt;/p&gt;

&lt;p&gt;This is not necessarily a concern — many legitimate actions like adding signatures or converting formats can trigger these indicators. However, I wanted to reach out to confirm:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Were any modifications made to this document before sending?&lt;/li&gt;
&lt;li&gt;Could you check that this is the correct and final version?&lt;/li&gt;
&lt;li&gt;If available, could you provide the document directly from your source system?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I appreciate your understanding and cooperation.&lt;/p&gt;

&lt;p&gt;Best regards,&lt;br&gt;
[Your Name]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Template 2: Follow-Up (After No Response)
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Subject:&lt;/strong&gt; Follow-Up: Document Fraud Detection — [Document Name/Reference]&lt;/p&gt;

&lt;p&gt;Dear [Name],&lt;/p&gt;

&lt;p&gt;I am following up on my previous message from [date] regarding the [document type].&lt;/p&gt;

&lt;p&gt;The detection process flagged some concerns that I need to resolve before we can proceed. You can review the analysis here: [HTPBE Result Link]&lt;/p&gt;

&lt;p&gt;Could you please respond at your earliest convenience? If I do not hear back by [date], I will need to [escalate to your supervisor / delay processing / use alternative fraud detection methods].&lt;/p&gt;

&lt;p&gt;Thank you for your prompt attention to this matter.&lt;/p&gt;

&lt;p&gt;Best regards,&lt;br&gt;
[Your Name]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Template 3: Escalation (Serious Concerns)
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Subject:&lt;/strong&gt; URGENT: Document Authentication Required — [Document Name/Reference]&lt;/p&gt;

&lt;p&gt;Dear [Name/Department],&lt;/p&gt;

&lt;p&gt;I am escalating a document fraud detection issue that requires immediate attention.&lt;/p&gt;

&lt;p&gt;The [document type] dated [date] has been flagged by htpbe.tech with &lt;strong&gt;high-confidence modification indicators&lt;/strong&gt;. You can view the technical analysis here: [HTPBE Result Link]&lt;/p&gt;

&lt;p&gt;Given the nature of this document [describe importance — e.g., “which includes payment instructions for $X”], I need to check its authenticity before proceeding.&lt;/p&gt;

&lt;p&gt;Please:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Confirm whether this document was intentionally modified&lt;/li&gt;
&lt;li&gt;Provide the original, unmodified version if available&lt;/li&gt;
&lt;li&gt;Check the payment/banking details through an independent channel&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I am available at [phone number] if you prefer to discuss this directly.&lt;/p&gt;

&lt;p&gt;Regards,&lt;br&gt;
[Your Name]&lt;br&gt;
[Your Position]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Best Practices for Document Security
&lt;/h2&gt;

&lt;p&gt;To minimize issues with modified documents in the future:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Always check important documents&lt;/strong&gt; — make PDF tamper detection part of your standard workflow&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request digitally signed PDFs&lt;/strong&gt; — signatures provide cryptographic proof of authenticity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Establish fraud detection protocols&lt;/strong&gt; with regular partners and vendors&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use secure channels&lt;/strong&gt; for document exchange (encrypted email, secure portals)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check payment changes&lt;/strong&gt; through phone calls to known numbers, never through email alone&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep records&lt;/strong&gt; of all detection results for audit purposes&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  When to Involve Security or Legal Teams
&lt;/h2&gt;

&lt;p&gt;Consider escalating to your security, compliance, or legal team if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The document involves significant financial transactions&lt;/li&gt;
&lt;li&gt;Modifications appear to alter critical information (amounts, dates, account numbers, terms)&lt;/li&gt;
&lt;li&gt;The sender cannot or will not explain the modifications&lt;/li&gt;
&lt;li&gt;You suspect intentional fraud or document tampering&lt;/li&gt;
&lt;li&gt;The document is part of a legal or regulatory matter&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  HTPBE? as Your Neutral Arbiter
&lt;/h2&gt;

&lt;p&gt;If you and the document sender cannot agree on whether a file has been modified, HTPBE? can serve as a neutral third party:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Both parties review the same HTPBE result&lt;/li&gt;
&lt;li&gt;Either party can submit a dispute for manual review&lt;/li&gt;
&lt;li&gt;Our forensic team provides an independent assessment&lt;/li&gt;
&lt;li&gt;The result serves as objective evidence for both sides&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is particularly useful in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vendor disputes about invoice authenticity&lt;/li&gt;
&lt;li&gt;Contract disagreements about document versions&lt;/li&gt;
&lt;li&gt;Insurance claims requiring document fraud detection&lt;/li&gt;
&lt;li&gt;Legal matters where document integrity is questioned&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Discovering a modified PDF does not automatically mean fraud or wrongdoing. Many modifications are innocent and explainable. The key is approaching the situation professionally:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Share the result&lt;/strong&gt; — let the other party see what you see&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask questions&lt;/strong&gt; — give them a chance to explain&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document everything&lt;/strong&gt; — keep records of all communication&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escalate when needed&lt;/strong&gt; — do not proceed with unchecked critical documents&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the dispute process&lt;/strong&gt; — let us help when you need a neutral assessment&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By following these steps, you protect yourself and your organization while maintaining professional relationships and trust.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>tutorial</category>
      <category>fraud</category>
      <category>forensics</category>
    </item>
    <item>
      <title>How to Detect Tampered PDFs (Forensics Tutorial)</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Wed, 20 May 2026 07:00:25 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/how-to-detect-tampered-pdfs-forensics-tutorial-4ken</link>
      <guid>https://dev.to/iurii_rogulia/how-to-detect-tampered-pdfs-forensics-tutorial-4ken</guid>
      <description>&lt;p&gt;A client sends you a signed PDF contract. Looks legitimate. The signature block is there, the date is right, the numbers look fine. But something feels off. How do you know if it's the original or if someone changed a figure on page 3 and re-exported it?&lt;/p&gt;

&lt;p&gt;I spent 5 days and 7 algorithm versions figuring this out. The result is &lt;a href="https://iurii.rogulia.fi/projects/htpbe-pdf-analysis" rel="noopener noreferrer"&gt;HTPBE?&lt;/a&gt; — Has This PDF Been Edited? — a SaaS that analyzes a PDF in under 9 seconds and tells you exactly how many times it was modified, by what software, and which forensic markers triggered. Here's how it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a PDF Is Actually Structured
&lt;/h2&gt;

&lt;p&gt;Before talking about tamper detection, you need to understand what a PDF file actually contains at the binary level. It's not magic — it's a structured document format from 1993 that has some remarkably useful forensic properties.&lt;/p&gt;

&lt;p&gt;A PDF has four sections:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Header&lt;/strong&gt; — identifies the PDF version (&lt;code&gt;%PDF-1.7&lt;/code&gt;, &lt;code&gt;%PDF-2.0&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Body&lt;/strong&gt; — the actual objects: pages, fonts, images, text streams&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-reference table (xref table)&lt;/strong&gt; — an index that maps object numbers to their byte offsets in the file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trailer&lt;/strong&gt; — points to the xref table and contains document metadata&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The xref table is the key. It's how a PDF reader finds objects quickly without scanning the entire file. And it's where tampering leaves an unmistakable trail.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Core Insight: Count the xref Tables
&lt;/h3&gt;

&lt;p&gt;When a PDF is created from scratch — by InDesign, Word, a PDF printer, anything — it contains exactly one xref table. That's the baseline.&lt;/p&gt;

&lt;p&gt;Now someone opens the file in Adobe Acrobat and changes a number. Acrobat doesn't rewrite the entire file. That would be slow and could corrupt things. Instead, it appends the changes to the end of the file and adds a second xref table pointing to the new and modified objects. The original content is still there, underneath — just superseded.&lt;/p&gt;

&lt;p&gt;This is called an &lt;strong&gt;incremental update&lt;/strong&gt;. And it's the foundation of my detection approach:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One xref table = original document. Two or more xref tables = the document was modified after initial creation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every editor does this: Adobe Acrobat, Preview on macOS, LibreOffice, Foxit, PDF-XChange. They all append, never rewrite.&lt;/p&gt;

&lt;p&gt;There is one legitimate exception, which took me several algorithm versions to handle correctly: &lt;strong&gt;LTV (Long-Term Validation) updates&lt;/strong&gt;. When a digitally signed PDF needs to embed certificate revocation data for long-term validation, the signing software adds an incremental update with OCSP responses and CRL data. This is legitimate, expected, and should not be flagged as tampering. Missing this in v1 of my algorithm caused a flood of false positives on signed legal documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 7 Forensic Markers
&lt;/h2&gt;

&lt;p&gt;Counting xref tables is the primary signal. But a thorough analysis needs multiple corroborating markers. Here are the seven I check in every analysis:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. xref Table Count
&lt;/h3&gt;

&lt;p&gt;The count of &lt;code&gt;xref&lt;/code&gt; or &lt;code&gt;startxref&lt;/code&gt; keywords in the binary. More than one means incremental updates exist. I also check whether those updates are LTV-related before flagging them.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Incremental Update Signatures
&lt;/h3&gt;

&lt;p&gt;Beyond just counting, I look at the structure of each incremental update: which object types were added or modified. A legitimate signing update touches only certain object types (signature dictionaries, DSS dictionaries). An update that modifies page content objects is a much stronger tampering signal.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Producer Metadata Field
&lt;/h3&gt;

&lt;p&gt;Every PDF has a &lt;code&gt;Producer&lt;/code&gt; field in its document info dictionary. This is set by whatever software created or last saved the file. A PDF generated by a hospital system might have &lt;code&gt;Producer: Epic Systems&lt;/code&gt;. If the Producer field says &lt;code&gt;Adobe Acrobat 23.0&lt;/code&gt; but the document claims to be an original bank statement — that's a mismatch worth investigating.&lt;/p&gt;

&lt;p&gt;I also look for multiple Producer strings across different revisions, which indicates the file passed through more than one software tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Creation Date vs. Modification Date Mismatch
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;CreationDate&lt;/code&gt; and &lt;code&gt;ModDate&lt;/code&gt; fields in PDF metadata. If they differ significantly — especially if &lt;code&gt;ModDate&lt;/code&gt; is after &lt;code&gt;CreationDate&lt;/code&gt; by years — that's a signal. If &lt;code&gt;CreationDate&lt;/code&gt; is missing entirely but &lt;code&gt;ModDate&lt;/code&gt; exists, that's unusual. If both are present but identical after an apparent edit, someone tried to cover their tracks.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Orphaned Objects
&lt;/h3&gt;

&lt;p&gt;When PDF editors modify or delete content, the original objects often remain in the file — they're just no longer referenced from the active xref table. They become "orphaned" objects: still in the binary, not reachable from the current document tree. I scan for these. Their presence indicates prior revisions, and their content sometimes reveals what was changed.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Encryption and Permissions Changes
&lt;/h3&gt;

&lt;p&gt;If a PDF's encryption dictionary changes between revisions, that's notable. It can indicate an attempt to remove password protection, change editing permissions, or re-encrypt content. I compare encryption settings across xref revisions where detectable.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Font Embedding Changes
&lt;/h3&gt;

&lt;p&gt;Fonts embedded in a PDF are large objects. Changing text requires access to the right fonts. If a new font appears in a later xref revision that wasn't in the original, and it's not a system font added by a viewer for display purposes, that's a meaningful signal — particularly if the new font covers a character range used in key fields like amounts or dates.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 7 Algorithm Versions (What Actually Failed)
&lt;/h2&gt;

&lt;p&gt;I'll be honest about the iteration process: it wasn't linear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v1&lt;/strong&gt; was exactly what it sounds like: count occurrences of &lt;code&gt;xref&lt;/code&gt; and &lt;code&gt;startxref&lt;/code&gt; in the binary, report the number. This produced catastrophic false positives. Signed PDF documents routinely have 2–3 incremental updates from the signing and LTV process. I was flagging every notarized document as tampered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v2&lt;/strong&gt; added a heuristic: if the file contains a digital signature (&lt;code&gt;/Sig&lt;/code&gt; object), ignore the first two incremental updates. Better, but wrong. The number of LTV updates is variable — some signing workflows produce one, some produce three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v3&lt;/strong&gt; was the LTV breakthrough: instead of counting incremental updates after signatures, I actually parse the structure of each update to determine if it contains only DSS (Document Security Store) objects and OCSP/CRL data. If so, it's marked as LTV and excluded from the tampering score. This eliminated 90% of false positives on signed documents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v4&lt;/strong&gt; added the Producer field analysis. This is where I discovered that some PDF generators write garbage into the Producer field — truncated strings, encoding artifacts, empty strings — which made simple string matching unreliable. I had to normalize and sanitize the field before comparison.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v5&lt;/strong&gt; introduced confidence scoring instead of binary yes/no. A single incremental update by itself is weak evidence. An incremental update &lt;em&gt;plus&lt;/em&gt; a Producer field change &lt;em&gt;plus&lt;/em&gt; orphaned content objects is strong evidence. Each marker contributes a weighted score to an overall confidence percentage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v6&lt;/strong&gt; broke things. I tried to add binary-level entropy analysis to detect unusual compression patterns. It was interesting but the false positive rate on legitimately compressed PDFs made it useless in practice. I removed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v7&lt;/strong&gt; is what ships. Confidence scoring, LTV exclusion, seven markers, no entropy analysis.&lt;/p&gt;

&lt;p&gt;
  slug="mvp-development"&lt;br&gt;
  text="Need to build document verification, fraud detection, or KYC tooling into your product? I can own this from the forensics layer to the billing and API surface."&lt;br&gt;
/&amp;gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Technical Implementation
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Parsing with pdf-lib
&lt;/h3&gt;

&lt;p&gt;I use &lt;a href="https://pdf-lib.js.org/" rel="noopener noreferrer"&gt;pdf-lib&lt;/a&gt; for structural parsing. It handles the xref table and object access well. For raw binary analysis — finding byte offsets, scanning for marker strings — I work directly on the &lt;code&gt;ArrayBuffer&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is the core xref analysis:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;XrefEntry&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;isLTV&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;producerField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;hasContentChanges&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="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;analyzeXrefTables&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fileBuffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;ArrayBuffer&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;XrefEntry&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;bytes&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;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fileBuffer&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;text&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;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;latin1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;XrefEntry&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="c1"&gt;// Find all startxref markers — each one marks an xref table or xref stream&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;startxrefPattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/startxref&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;(\d&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="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RegExpExecArray&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;while &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;startxrefPattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="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="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Skip the terminal startxref before %%EOF — offset 0 is the document terminator&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;offset&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;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&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;parseXrefRevision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseXrefRevision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;XrefEntry&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;trailerStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&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="s2"&gt;trailer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offset&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;trailerEnd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&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="s2"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;trailerStart&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;trailerContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;trailerStart&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="nx"&gt;text&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;trailerStart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;trailerEnd&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// DSS (Document Security Store) presence indicates an LTV update&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasDSSObject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;trailerContent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/DSS&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;hasOnlySigObjects&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;checkForSignatureOnlyUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offset&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;producerMatch&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;Producer&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;)&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)\)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;4096&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;producerField&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;producerMatch&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;producerMatch&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;trim&lt;/span&gt;&lt;span class="p"&gt;()&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasContentChanges&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;checkForContentModifications&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;isLTV&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hasDSSObject&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;hasOnlySigObjects&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;producerField&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;hasContentChanges&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkForContentModifications&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// /Contents, /Page, and BT/ET (Begin Text/End Text) operators signal content edits&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;revisionSlice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&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;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;16384&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;contentIndicators&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/Contents&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/Page&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/Pages&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BT&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ET&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;contentIndicators&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="nx"&gt;indicator&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;revisionSlice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;indicator&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;computeTamperingScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;XrefEntry&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nonLTVEntries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isLTV&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Base document always has one xref — anything beyond is an edit&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;editCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nonLTVEntries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;editCount&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;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;score&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;editCount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Content modifications are a stronger signal than metadata-only changes&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contentEdits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nonLTVEntries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasContentChanges&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;contentEdits&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;15&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Multiple distinct Producer strings across revisions indicate tool switching&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;producers&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="nx"&gt;nonLTVEntries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;producerField&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;producers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Bypassing Vercel's 4.5MB Serverless Limit
&lt;/h3&gt;

&lt;p&gt;PDF files up to 10MB need to be analyzed. Vercel's serverless functions have a 4.5MB request body limit. Sending a 10MB PDF directly to an API route fails with a 413 — or worse, silently truncates the body.&lt;/p&gt;

&lt;p&gt;The solution is Vercel Blob with client-side upload. The browser uploads directly to Vercel Blob storage (bypassing the serverless function entirely via a token endpoint), then passes the blob URL to a Server Action for analysis. The file never flows through the serverless function body at all.&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;// components/Hero/index.tsx — Step 1: browser → Vercel Blob&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;upload&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@vercel/blob/client&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;blob&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;upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;access&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;handleUploadUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/blob-token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;clientPayload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Step 2: pass blob URL to Server Action for analysis&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;analyzePdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// app/api/blob-token/route.ts — origin-based security for token generation&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;handleUpload&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@vercel/blob/client&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;ALLOWED_ORIGINS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_APP_URL&lt;/span&gt;&lt;span class="o"&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;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="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;origin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;origin&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isAllowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED_ORIGINS&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="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;o&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;isAllowed&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="s2"&gt;Forbidden&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;403&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;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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jsonResponse&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;handleUpload&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;request&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="na"&gt;onBeforeGenerateToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;allowedContentTypes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;maximumSizeInBytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jsonResponse&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Async Processing with Polling
&lt;/h3&gt;

&lt;p&gt;Analysis takes up to 9 seconds. That's too long for a synchronous Vercel function. I use an async pattern: the job is queued, a job ID is returned immediately, the client polls for results.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/analyze/route.ts — accepts the Vercel Blob URL, returns a job ID immediately&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="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;blobUrl&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`job:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;jobId&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="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;queued&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;analysisQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;analyze-pdf&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;blobUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;jobId&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;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// app/api/analyze/[jobId]/route.ts — client polls this until status === "completed"&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;GET&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="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;jobId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&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;raw&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;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`job:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;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;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Job not found&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;404&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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;raw&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 client polls every 1.5 seconds. Most files finish in 3–5 seconds. The 9-second ceiling is for maximum-size PDFs with complex xref structures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas Worth Knowing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;LTV updates will fool a naive implementation.&lt;/strong&gt; If you just count xref tables, every notarized PDF, every DocuSign document, every government e-signature will appear tampered. You must parse the update structure and identify LTV-specific object types before flagging anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;base64-encoded PDFs need special handling.&lt;/strong&gt; Some APIs transmit PDFs as base64 strings. If you run binary analysis on the raw base64 string without decoding first, your byte-offset calculations will be completely wrong. Decode to &lt;code&gt;Uint8Array&lt;/code&gt; before any binary analysis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File size from binary vs. encoded differs by ~33%.&lt;/strong&gt; The File API's &lt;code&gt;.size&lt;/code&gt; property returns the correct binary byte count. The &lt;code&gt;.length&lt;/code&gt; of a base64 string is approximately 33% larger. Keep this straight or your 10MB validation will reject valid files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The terminal &lt;code&gt;startxref&lt;/code&gt; before &lt;code&gt;%%EOF&lt;/code&gt; has offset 0.&lt;/strong&gt; This is the document terminator marker, not a real xref table. Naive regex counting includes it and inflates the xref count by one. Filter out any entry where &lt;code&gt;offset === 0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Some enterprise PDF generators produce split xref tables in the original document.&lt;/strong&gt; Certain systems write multiple xref sections at creation time (not incremental updates). I handle this by checking whether all xref sections share the same Producer metadata — if they do and there are no trailer &lt;code&gt;Prev&lt;/code&gt; pointers, it's a single logical revision.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Analysis time: under 9 seconds for files up to 10MB&lt;/li&gt;
&lt;li&gt;False positive rate on legitimately signed documents: near zero after v3 LTV handling&lt;/li&gt;
&lt;li&gt;Detection coverage: 100% for the 7 deterministic markers&lt;/li&gt;
&lt;li&gt;File size limit: 10MB, handled via presigned S3 uploads to bypass serverless body limits&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The confidence scoring approach handles ambiguous cases better than a binary flag would. A document with one extra xref table from a PDF optimizer (score: 25, low confidence) is treated very differently from a document with three non-LTV incremental updates, two different Producer strings, and orphaned content objects (score: 85+, high confidence). Users get actionable context, not just an alarm.&lt;/p&gt;

&lt;p&gt;Try it at &lt;a href="https://iurii.rogulia.fi/projects/htpbe-pdf-analysis" rel="noopener noreferrer"&gt;HTPBE?&lt;/a&gt;. Upload any PDF — a bank statement, a contract, a signed invoice — and see the full forensic breakdown.&lt;/p&gt;




&lt;p&gt;If you're building fintech or legaltech tooling where document authenticity matters — loan origination, contract management, insurance claims, KYC workflows — this kind of verification layer is worth integrating directly into your pipeline. The seven markers described here are implementable in any language with binary file access.&lt;/p&gt;

&lt;p&gt;If you need a senior developer who can build document verification systems, &lt;a href="https://iurii.rogulia.fi/services/automation-workflows" rel="noopener noreferrer"&gt;automation pipelines&lt;/a&gt;, or SaaS infrastructure from scratch — &lt;a href="https://iurii.rogulia.fi/contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt;. I'm available for freelance projects and long-term engagements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related reading:&lt;/strong&gt; &lt;a href="https://iurii.rogulia.fi/blog/technical-seo-by-default" rel="noopener noreferrer"&gt;Technical SEO I Build Into Every Project&lt;/a&gt; — because the same "build it right the first time" discipline applies to indexing infrastructure too. | &lt;a href="https://iurii.rogulia.fi/blog/open-source-package-five-registries" rel="noopener noreferrer"&gt;One Package, Five Registries&lt;/a&gt; — another example of shipping multiple layers of a system end-to-end.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>pdflib</category>
      <category>pdf</category>
    </item>
    <item>
      <title>Understanding PDF Metadata: What Your Documents Reveal</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Tue, 19 May 2026 10:00:27 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/understanding-pdf-metadata-what-your-documents-reveal-4ip</link>
      <guid>https://dev.to/iurii_rogulia/understanding-pdf-metadata-what-your-documents-reveal-4ip</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/understanding-pdf-metadata-what-documents-reveal" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt;. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every PDF document contains hidden information called metadata — data about the document itself rather than its visible content. This metadata reveals how the document was created, when it was modified, which applications processed it, and much more.&lt;/p&gt;

&lt;p&gt;Most users never see this information, but it is always there, embedded in every PDF file. Understanding PDF metadata helps you check document authenticity, protect privacy, and detect modifications.&lt;/p&gt;

&lt;p&gt;This article explains what PDF metadata is, what information it contains, how to view it, what it reveals about documents, and how it is used in document fraud detection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hidden Data in Every PDF
&lt;/h2&gt;

&lt;p&gt;PDF metadata is like a document’s “birth certificate” — it records the document’s creation and processing history. This information is embedded in the PDF file structure and can be accessed by anyone who knows how to view it.&lt;/p&gt;

&lt;p&gt;As &lt;a href="https://helpx.adobe.com/acrobat/desktop/edit-documents/edit-pdf-properties/pdf-properties.html" rel="noopener noreferrer"&gt;Adobe explains&lt;/a&gt;, metadata provides valuable information about document origin and history, but it can also raise privacy concerns if sensitive information is included.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is Metadata?
&lt;/h2&gt;

&lt;p&gt;Metadata is “data about data” — information that describes other information. In PDFs, metadata describes the document itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Who created it&lt;/strong&gt;: Author and creator application&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When it was created&lt;/strong&gt;: Creation and modification dates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What it is&lt;/strong&gt;: Title, subject, keywords&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How it was processed&lt;/strong&gt;: Producer application, PDF version&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Technical details&lt;/strong&gt;: File size, page count, encryption status&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Unlike the visible content of a PDF (text, images, layout), metadata is embedded in the file structure and requires special tools to view.&lt;/p&gt;

&lt;h2&gt;
  
  
  Types of PDF Metadata
&lt;/h2&gt;

&lt;p&gt;PDF metadata includes several categories of information:&lt;/p&gt;

&lt;h3&gt;
  
  
  Standard Fields
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Basic document information:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Title&lt;/strong&gt;: Document title&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Author&lt;/strong&gt;: Person or organization that created the document&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subject&lt;/strong&gt;: Document subject or description&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keywords&lt;/strong&gt;: Searchable keywords for document classification&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Purpose&lt;/strong&gt;: These fields help organize and search documents, but they are often left blank or contain default values.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creation and Modification Dates
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Temporal information:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Creation Date&lt;/strong&gt;: When the PDF was first created&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Modification Date&lt;/strong&gt;: When the PDF was last modified&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What it reveals:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Document age and history&lt;/li&gt;
&lt;li&gt;Modification timeline&lt;/li&gt;
&lt;li&gt;Potential tampering indicators&lt;/li&gt;
&lt;li&gt;Processing chronology&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Important&lt;/strong&gt;: These dates can be manipulated, so they are not always reliable indicators of authenticity.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creator Application
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Source information:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Creator&lt;/strong&gt;: Application that originally created the PDF&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Producer&lt;/strong&gt;: Software that last processed the PDF&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What it reveals:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Document origin (Word, Excel, Photoshop, etc.)&lt;/li&gt;
&lt;li&gt;Processing history&lt;/li&gt;
&lt;li&gt;Editing tool usage&lt;/li&gt;
&lt;li&gt;Application fingerprints&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Example&lt;/strong&gt;: A document created in “Microsoft Word” but produced by “Adobe Acrobat Pro” suggests the document was edited after creation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Producer Information
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Processing details:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Producer&lt;/strong&gt;: Software that last processed the PDF&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDF Version&lt;/strong&gt;: PDF specification version used&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encryption&lt;/strong&gt;: Encryption status and method&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What it reveals:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Last processing application&lt;/li&gt;
&lt;li&gt;PDF specification compliance&lt;/li&gt;
&lt;li&gt;Security settings&lt;/li&gt;
&lt;li&gt;Technical capabilities used&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As &lt;a href="https://blog.pics.io/what-is-pdf-metadata-a-complete-guide/" rel="noopener noreferrer"&gt;Pics.io explains&lt;/a&gt;, producer information can reveal editing history even when other indicators are hidden.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Metadata Comes From
&lt;/h2&gt;

&lt;p&gt;Understanding metadata sources helps interpret the information:&lt;/p&gt;

&lt;h3&gt;
  
  
  Application-Generated Metadata
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Automatic creation:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Applications automatically populate metadata&lt;/li&gt;
&lt;li&gt;Uses information from source documents&lt;/li&gt;
&lt;li&gt;Includes application identification&lt;/li&gt;
&lt;li&gt;Records processing timestamps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Common sources:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Microsoft Office applications (Word, Excel, PowerPoint)&lt;/li&gt;
&lt;li&gt;Adobe applications (Acrobat, Photoshop, Illustrator)&lt;/li&gt;
&lt;li&gt;Online PDF converters&lt;/li&gt;
&lt;li&gt;PDF editing tools&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  User-Provided Metadata
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Manual entry:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Users can manually enter metadata&lt;/li&gt;
&lt;li&gt;Often left blank or with defaults&lt;/li&gt;
&lt;li&gt;May contain sensitive information&lt;/li&gt;
&lt;li&gt;Can be intentionally misleading&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Privacy concern&lt;/strong&gt;: Users may inadvertently include sensitive information in metadata fields.&lt;/p&gt;

&lt;h3&gt;
  
  
  Processing Metadata
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Tool-generated:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PDF processing tools add metadata&lt;/li&gt;
&lt;li&gt;Records processing history&lt;/li&gt;
&lt;li&gt;Includes tool identification&lt;/li&gt;
&lt;li&gt;Tracks modification history&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Forensic value&lt;/strong&gt;: Processing metadata can reveal document editing history.&lt;/p&gt;

&lt;p&gt;As &lt;a href="https://allyant.com/blog/pdf-metadata-definition-view-edit-change-remove-importance/" rel="noopener noreferrer"&gt;Allyant notes&lt;/a&gt;, metadata accumulates as documents are processed by different tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to View PDF Metadata
&lt;/h2&gt;

&lt;p&gt;Viewing metadata is straightforward:&lt;/p&gt;

&lt;h3&gt;
  
  
  Adobe Acrobat Reader
&lt;/h3&gt;

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

&lt;ol&gt;
&lt;li&gt;Open the PDF file&lt;/li&gt;
&lt;li&gt;Right-click and select “Properties” (or File → Properties)&lt;/li&gt;
&lt;li&gt;Review metadata in the “Description” tab&lt;/li&gt;
&lt;li&gt;Check the “Advanced” tab for additional technical details&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Information displayed:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Title, Author, Subject, Keywords&lt;/li&gt;
&lt;li&gt;Creation and Modification dates&lt;/li&gt;
&lt;li&gt;Creator and Producer applications&lt;/li&gt;
&lt;li&gt;PDF version and security settings&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Free Online Tools
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Web-based viewers:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Upload PDF to online metadata viewer&lt;/li&gt;
&lt;li&gt;View metadata without installing software&lt;/li&gt;
&lt;li&gt;Access from any device&lt;/li&gt;
&lt;li&gt;No software installation required&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;HTPBE?&lt;/strong&gt; provides free metadata analysis along with modification detection — upload your PDF at &lt;a href="https://htpbe.tech" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt; for instant results without signup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Privacy note&lt;/strong&gt;: Be cautious uploading sensitive documents to online tools. HTPBE? does not store your files after analysis.&lt;/p&gt;

&lt;h3&gt;
  
  
  Operating System Methods
&lt;/h3&gt;

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

&lt;ol&gt;
&lt;li&gt;Right-click PDF file&lt;/li&gt;
&lt;li&gt;Select “Properties”&lt;/li&gt;
&lt;li&gt;Check the “Details” tab for metadata&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ol&gt;
&lt;li&gt;Right-click PDF file&lt;/li&gt;
&lt;li&gt;Select “Get Info”&lt;/li&gt;
&lt;li&gt;Review metadata in the info window&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Limitations&lt;/strong&gt;: Operating system methods show limited metadata compared to PDF-specific tools.&lt;/p&gt;

&lt;p&gt;As &lt;a href="https://www.adobe.com/acrobat/hub/find-out-when-a-pdf-was-created.html" rel="noopener noreferrer"&gt;Adobe explains&lt;/a&gt;, different tools show different levels of metadata detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Metadata Reveals About a Document
&lt;/h2&gt;

&lt;p&gt;Metadata provides insights into document history and authenticity:&lt;/p&gt;

&lt;h3&gt;
  
  
  Document Origin
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Creation source:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which application created the document&lt;/li&gt;
&lt;li&gt;When document was created&lt;/li&gt;
&lt;li&gt;Who created it (if author field populated)&lt;/li&gt;
&lt;li&gt;Original document type (Word, Excel, etc.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use case&lt;/strong&gt;: Checking document origin matches expected source.&lt;/p&gt;

&lt;h3&gt;
  
  
  Processing History
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Modification timeline:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When document was modified&lt;/li&gt;
&lt;li&gt;Which applications processed it&lt;/li&gt;
&lt;li&gt;Processing sequence&lt;/li&gt;
&lt;li&gt;Modification frequency&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use case&lt;/strong&gt;: Detecting unauthorized modifications or editing history.&lt;/p&gt;

&lt;h3&gt;
  
  
  Application Fingerprints
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Tool identification:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Creator application signatures&lt;/li&gt;
&lt;li&gt;Producer application patterns&lt;/li&gt;
&lt;li&gt;Version information&lt;/li&gt;
&lt;li&gt;Tool-specific metadata&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use case&lt;/strong&gt;: Identifying editing tools used, detecting unexpected applications.&lt;/p&gt;

&lt;h3&gt;
  
  
  Technical Details
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;File characteristics:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PDF version used&lt;/li&gt;
&lt;li&gt;Encryption status&lt;/li&gt;
&lt;li&gt;File size&lt;/li&gt;
&lt;li&gt;Page count&lt;/li&gt;
&lt;li&gt;Security settings&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use case&lt;/strong&gt;: Understanding document technical properties and capabilities.&lt;/p&gt;

&lt;p&gt;As &lt;a href="https://pypdf.readthedocs.io/en/stable/user/metadata.html" rel="noopener noreferrer"&gt;pypdf documentation&lt;/a&gt; explains, metadata analysis provides forensic insights into document processing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy Concerns: What You Might Be Sharing
&lt;/h2&gt;

&lt;p&gt;Metadata can contain sensitive information:&lt;/p&gt;

&lt;h3&gt;
  
  
  Personal Information
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Potential exposure:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Author names (may reveal document creator)&lt;/li&gt;
&lt;li&gt;Company names (may reveal organization)&lt;/li&gt;
&lt;li&gt;Email addresses (if included in metadata)&lt;/li&gt;
&lt;li&gt;User names (from application settings)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Risk&lt;/strong&gt;: Metadata travels with PDF files and can be viewed by anyone who receives the document.&lt;/p&gt;

&lt;h3&gt;
  
  
  Document History
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Revealed information:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Creation dates (may reveal document age)&lt;/li&gt;
&lt;li&gt;Modification dates (may reveal editing history)&lt;/li&gt;
&lt;li&gt;Application names (may reveal software used)&lt;/li&gt;
&lt;li&gt;File paths (may reveal directory structure)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Risk&lt;/strong&gt;: Document history can reveal sensitive information about document processing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Organizational Information
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Exposed details:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Company names in author fields&lt;/li&gt;
&lt;li&gt;Department information&lt;/li&gt;
&lt;li&gt;Project names in titles&lt;/li&gt;
&lt;li&gt;Internal file naming conventions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Risk&lt;/strong&gt;: Organizational information in metadata can be valuable to competitors or attackers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Best Practices for Privacy
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Protect metadata:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Remove sensitive metadata before sharing&lt;/li&gt;
&lt;li&gt;Use metadata cleaning tools&lt;/li&gt;
&lt;li&gt;Avoid including personal information&lt;/li&gt;
&lt;li&gt;Review metadata before distribution&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As &lt;a href="https://www.4n6k.com/2014/02/forensics-quickie-pdf-metadata.html" rel="noopener noreferrer"&gt;4n6k forensics notes&lt;/a&gt;, metadata forensics can reveal more than intended, making privacy protection important.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Edit or Remove Metadata
&lt;/h2&gt;

&lt;p&gt;You can edit or remove metadata to protect privacy:&lt;/p&gt;

&lt;h3&gt;
  
  
  Adobe Acrobat
&lt;/h3&gt;

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

&lt;ol&gt;
&lt;li&gt;Open PDF in Adobe Acrobat&lt;/li&gt;
&lt;li&gt;Go to File → Properties&lt;/li&gt;
&lt;li&gt;Edit metadata fields in the Description tab&lt;/li&gt;
&lt;li&gt;Click “OK” to save changes&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Limitations&lt;/strong&gt;: Some metadata (like creation date) may not be editable in all versions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Metadata Cleaning Tools
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Specialized tools:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PDF metadata editors&lt;/li&gt;
&lt;li&gt;Privacy-focused PDF tools&lt;/li&gt;
&lt;li&gt;Command-line utilities&lt;/li&gt;
&lt;li&gt;Online metadata cleaners&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Remove all metadata&lt;/li&gt;
&lt;li&gt;Edit specific fields&lt;/li&gt;
&lt;li&gt;Batch processing&lt;/li&gt;
&lt;li&gt;Privacy protection&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Best Practices
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Before sharing:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Review metadata for sensitive information&lt;/li&gt;
&lt;li&gt;Remove unnecessary metadata&lt;/li&gt;
&lt;li&gt;Edit author fields if needed&lt;/li&gt;
&lt;li&gt;Clean metadata for public distribution&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Privacy protection:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Remove personal information&lt;/li&gt;
&lt;li&gt;Clean company-specific data&lt;/li&gt;
&lt;li&gt;Remove file paths&lt;/li&gt;
&lt;li&gt;Sanitize before sharing&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Metadata in Document Fraud Detection
&lt;/h2&gt;

&lt;p&gt;Metadata plays a crucial role in PDF tamper detection. Tools like &lt;strong&gt;HTPBE?&lt;/strong&gt; analyze metadata patterns to detect document modifications automatically:&lt;/p&gt;

&lt;h3&gt;
  
  
  Modification Detection
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Compare creation and modification dates&lt;/li&gt;
&lt;li&gt;Check for unexpected date patterns&lt;/li&gt;
&lt;li&gt;Identify suspicious modifications&lt;/li&gt;
&lt;li&gt;Detect timeline anomalies&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use case&lt;/strong&gt;: Identifying documents modified after creation or signing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Application Fingerprinting
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Tool identification:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Identify creator and producer applications&lt;/li&gt;
&lt;li&gt;Detect unexpected editing tools&lt;/li&gt;
&lt;li&gt;Match application patterns&lt;/li&gt;
&lt;li&gt;Identify tool-specific signatures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use case&lt;/strong&gt;: Detecting documents edited with unexpected applications.&lt;/p&gt;

&lt;h3&gt;
  
  
  Timeline Analysis
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Chronological fraud detection:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Compare dates with expected timeline&lt;/li&gt;
&lt;li&gt;Check modification sequence&lt;/li&gt;
&lt;li&gt;Check for date inconsistencies&lt;/li&gt;
&lt;li&gt;Validate processing order&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use case&lt;/strong&gt;: Checking document processing matches expected workflow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cross-Reference Analysis
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Metadata consistency:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Compare metadata with document content&lt;/li&gt;
&lt;li&gt;Check for metadata inconsistencies&lt;/li&gt;
&lt;li&gt;Validate application claims&lt;/li&gt;
&lt;li&gt;Detect metadata manipulation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use case&lt;/strong&gt;: Identifying metadata that has been manipulated to hide modifications.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can Metadata Be Faked or Removed?
&lt;/h2&gt;

&lt;p&gt;Yes — metadata can be edited or stripped from PDF files using various tools. However, manipulation is harder to hide than it appears:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Editing metadata leaves traces&lt;/strong&gt;: Changing timestamps or creator fields introduces structural inconsistencies that forensic analysis can detect&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing metadata is suspicious&lt;/strong&gt;: PDFs generated by professional software always include metadata. A document with no creation date, no creator, and no producer is unusual and warrants scrutiny&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inconsistent patterns&lt;/strong&gt;: Metadata that does not match the claimed source (a bank statement with a generic consumer PDF editor as the creator, for example) is a red flag even if individual fields look plausible&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is why HTPBE? does not rely on metadata alone. Multi-layer analysis combines metadata inspection with internal structure examination, signature fraud detection, and pattern matching against known fraud signatures. Metadata is the starting point, not the final word.&lt;/p&gt;

&lt;h2&gt;
  
  
  How HTPBE? Uses Metadata: The 5-Layer Process
&lt;/h2&gt;

&lt;p&gt;When you upload a PDF to HTPBE?, metadata analysis is the first of five fraud detection layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Extract all metadata fields&lt;/strong&gt;: Creation date, modification date, creator, producer, PDF version, and all available properties&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check for date inconsistencies&lt;/strong&gt;: Does the modification date precede the creation date? Is the creation date in the future relative to the upload time? Do timestamps align with the document’s claimed origin?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analyze creator and producer patterns&lt;/strong&gt;: Do the applications match the expected source? Are there signs of editing tools that should not appear in this workflow?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Examine internal PDF structure&lt;/strong&gt;: Cross-reference metadata claims against the actual file structure — incremental updates, xref tables, object modifications&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare against known fraud signatures&lt;/strong&gt;: Match patterns against a database of known modification techniques and fraud indicators&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result combines all signals into a single verdict: Intact, Modified, or Cannot Verify.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;PDF metadata is embedded information that reveals document creation and processing history. Understanding metadata helps you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check authenticity&lt;/strong&gt;: Check document origin and modification history&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detect modifications&lt;/strong&gt;: Identify unauthorized changes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protect privacy&lt;/strong&gt;: Remove sensitive information before sharing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Understand documents&lt;/strong&gt;: Learn about document processing and tools used&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Metadata is a powerful tool for document fraud detection, but it can also raise privacy concerns. Review metadata before sharing documents, and use metadata analysis as part of comprehensive PDF tamper detection.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>tutorial</category>
      <category>metadata</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
