<?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: PDFops</title>
    <description>The latest articles on DEV Community by PDFops (@pdfops).</description>
    <link>https://dev.to/pdfops</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3982206%2F4660bc3b-b786-4bda-974f-3ad3af86fbea.png</url>
      <title>DEV Community: PDFops</title>
      <link>https://dev.to/pdfops</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pdfops"/>
    <language>en</language>
    <item>
      <title>Automating PDF fills, merges, and invoices in n8n without a Function node</title>
      <dc:creator>PDFops</dc:creator>
      <pubDate>Thu, 13 Aug 2026 13:06:51 +0000</pubDate>
      <link>https://dev.to/pdfops/automating-pdf-fills-merges-and-invoices-in-n8n-without-a-function-node-318b</link>
      <guid>https://dev.to/pdfops/automating-pdf-fills-merges-and-invoices-in-n8n-without-a-function-node-318b</guid>
      <description>&lt;p&gt;Most n8n workflows that touch PDFs end up in the same place: an HTTP Request node with a hand-built multipart body, or a Code node calling pdf-lib and hoping the binary handling survives the next n8n upgrade. &lt;a href="https://www.npmjs.com/package/n8n-nodes-pdfops" rel="noopener noreferrer"&gt;n8n-nodes-pdfops&lt;/a&gt; is a verified community node that turns PDF inspect, fill, merge, and invoice generation into typed n8n operations, with binary PDFs flowing in and out like any other node.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing it
&lt;/h2&gt;

&lt;p&gt;On n8n Cloud, open the nodes panel and search "PDFops"; it installs in one click because it carries n8n's verified badge. Self-hosted, it's Settings → Community Nodes → Install, or from the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;n8n community-node &lt;span class="nb"&gt;install &lt;/span&gt;n8n-nodes-pdfops
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The node works with no credential at all, against the same 100-requests-per-IP-per-month anonymous tier the &lt;a href="https://pdfops.dev/playground" rel="noopener noreferrer"&gt;playground&lt;/a&gt; uses. Add a &lt;strong&gt;PDFops API&lt;/strong&gt; credential (an API key in the &lt;code&gt;X-API-Key&lt;/code&gt; header) once you want a higher quota or the usage operation described below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five operations
&lt;/h2&gt;

&lt;p&gt;One node, five operations, picked from a dropdown: &lt;strong&gt;Inspect Fields&lt;/strong&gt; lists a PDF's AcroForm field names, types, and current values, plus a fill template you can paste straight into the next node. &lt;strong&gt;Fill Form&lt;/strong&gt; writes values into those fields and can flatten the result so it stops being editable. &lt;strong&gt;Merge&lt;/strong&gt; concatenates every PDF on the incoming items, in item order, into one file. &lt;strong&gt;Generate Invoice&lt;/strong&gt; renders a complete invoice PDF from a JSON object, no template required. &lt;strong&gt;Get Usage&lt;/strong&gt; returns tier, quota, used, and reset date for a key. Fill, merge, and invoice are deterministic: run the same workflow twice on the same input and the output PDF is byte-identical, so a re-run never shows up as a spurious diff in whatever you archive it to.&lt;/p&gt;

&lt;h2&gt;
  
  
  A worked example: fill from a webhook, catch the typo first
&lt;/h2&gt;

&lt;p&gt;The node ships with three ready-to-import workflow templates. The one worth walking through end to end is &lt;strong&gt;fill and flatten PDF forms from webhook data&lt;/strong&gt;, because it solves the failure mode that makes "just fill the PDF" workflows unreliable: a field name typed wrong in the JSON payload doesn't error, it just fills nothing, and the caller gets back a document that looks correct until someone opens it.&lt;/p&gt;

&lt;p&gt;The workflow receives a webhook carrying a PDF URL, an email address, and a &lt;code&gt;fields&lt;/code&gt; object, validates the payload, then runs &lt;strong&gt;Inspect Fields&lt;/strong&gt; on the blank PDF before it fills anything. A Code node diffs the requested field names against the real ones the inspect call returned. Any name that doesn't match routes straight to an error response, naming the bad field, instead of silently producing a blank form. Only a payload that passes the diff reaches &lt;strong&gt;Fill Form&lt;/strong&gt; with &lt;code&gt;flatten&lt;/code&gt; on, so the result can't be edited after it ships. From there it fans out: Gmail sends the filled PDF, Google Drive archives a copy, and the caller gets a JSON receipt.&lt;/p&gt;

&lt;p&gt;Swap Gmail for SMTP or Slack and Drive for S3 without touching the PDF steps; none of them know or care where the file ends up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two more recipes, already built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Merge two forms into one packet.&lt;/strong&gt; A workflow named &lt;em&gt;onboarding packet from two PDF forms&lt;/em&gt; takes two blank PDF URLs and two field objects (a new-hire packet, a new-tenant packet, whatever your business fills in pairs), fills each with its own &lt;strong&gt;Fill Form&lt;/strong&gt; node, waits for both branches, and joins them with &lt;strong&gt;Merge&lt;/strong&gt; so the first form always lands as page one. It refuses to upload a packet unless both branches actually filled, so a partial run never produces a half-finished file that looks whole.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An invoice on every Stripe payment.&lt;/strong&gt; A Stripe trigger on &lt;code&gt;payment_intent.succeeded&lt;/code&gt; feeds a Code node that shapes the charge into invoice JSON, and this is the step where most hand-rolled invoice code breaks quietly: Stripe reports amounts in the smallest currency unit, but JPY, KRW, and thirteen other currencies don't have 100 of those units, so a reflexive &lt;code&gt;amount / 100&lt;/code&gt; issues an invoice a hundred times too small. The template checks the currency against a zero-decimal list before it divides. &lt;strong&gt;Generate Invoice&lt;/strong&gt; renders the deterministic PDF, then it archives to S3, emails the customer when an address is on file, and logs a row to a spreadsheet ledger. A failed render posts to Slack with the payment ID, so a missing invoice gets noticed instead of just not existing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keyless works; a key gets you further
&lt;/h2&gt;

&lt;p&gt;Every operation runs without a credential at the shared 100/IP/mo anonymous rate, the same limit the API enforces everywhere else. A &lt;a href="https://pdfops.dev/pricing" rel="noopener noreferrer"&gt;free API key&lt;/a&gt; (250 requests/month, email in, key out, no card) raises that ceiling and is required for the &lt;strong&gt;Get Usage&lt;/strong&gt; operation, which returns your remaining quota so a workflow can check headroom before it burns the month's last call. Paid tiers exist once a workflow outgrows 250 a month, but the free key runs a real production automation, not just a demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Search "PDFops" in the n8n nodes panel, or install &lt;code&gt;n8n-nodes-pdfops&lt;/code&gt; self-hosted. The three templates above are importable as-is from the node's &lt;a href="https://www.npmjs.com/package/n8n-nodes-pdfops" rel="noopener noreferrer"&gt;npm page&lt;/a&gt;; swap in your own PDF, Stripe account, or storage provider and the PDF steps don't change. Building on the raw API instead? Start at &lt;a href="https://pdfops.dev/docs" rel="noopener noreferrer"&gt;/docs&lt;/a&gt; or the &lt;a href="https://pdfops.dev/openapi.json" rel="noopener noreferrer"&gt;OpenAPI spec&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>webdev</category>
      <category>serverless</category>
      <category>api</category>
    </item>
    <item>
      <title>Filling encrypted government PDFs: what actually works, measured</title>
      <dc:creator>PDFops</dc:creator>
      <pubDate>Fri, 07 Aug 2026 13:46:06 +0000</pubDate>
      <link>https://dev.to/pdfops/filling-encrypted-government-pdfs-what-actually-works-measured-25am</link>
      <guid>https://dev.to/pdfops/filling-encrypted-government-pdfs-what-actually-works-measured-25am</guid>
      <description>&lt;p&gt;You download an official blank from a court or tax site, it opens in every viewer without asking for anything, and then your code throws: &lt;code&gt;Input document to PDFDocument.load is encrypted&lt;/code&gt;. There is no password to supply, because the file was published for the public. We ran two real forms in this class, California's FL-100 divorce petition and Canada's TD1 tax credits return, through seven independent fill tools and recorded exactly which ones cope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a public form rejects your library
&lt;/h2&gt;

&lt;p&gt;These blanks are AES-encrypted with an &lt;em&gt;empty user password&lt;/em&gt;. The PDF spec allows a document to carry two passwords: a user password that gates opening, and an owner password that gates modification. Publishers set the user password to the empty string, so any viewer decrypts the file silently, and set a real owner password to discourage edits. Strict parsers see the encryption dictionary, notice they were given no credentials, and refuse the whole file. That is the correct reading of the spec and a useless outcome for you.&lt;/p&gt;

&lt;p&gt;pdf-lib and its maintained fork &lt;code&gt;@cantoo/pdf-lib&lt;/code&gt; both reject FL-100 and TD1 at load. The &lt;a href="https://pdfops.dev/docs/fill-form" rel="noopener noreferrer"&gt;PDFops fill API&lt;/a&gt; is built on the same family and refuses too, with a &lt;code&gt;400 encrypted_pdf&lt;/code&gt; that tells you the workaround instead of leaving you to guess. pypdf opens both files directly when installed as &lt;code&gt;pypdf[crypto]&lt;/code&gt;, but writes text fields only. The full per-tool results live on the registry pages for &lt;a href="https://pdfops.dev/registry/ca-fl-100" rel="noopener noreferrer"&gt;ca-fl-100&lt;/a&gt; and &lt;a href="https://pdfops.dev/registry/cra-td1" rel="noopener noreferrer"&gt;cra-td1&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shell answer: pdftk fills them natively
&lt;/h2&gt;

&lt;p&gt;The one mainstream tool that treats an empty user password like a viewer does is &lt;code&gt;pdftk-java&lt;/code&gt;. It enumerated all 158 fields of FL-100, filled 148 of them (every text field, checkbox and radio group; the rest take no value by design), and re-reading its output confirmed all 148 values landed. Same story on TD1: 27 of 27. No decrypt step, and the output file comes out unencrypted.&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;# enumerate fields (works despite the encryption)&lt;/span&gt;
pdftk fl100.pdf dump_data_fields &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; fields.txt

&lt;span class="c"&gt;# fill from an FDF file&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; values.fdf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
%FDF-1.2
1 0 obj
&amp;lt;&amp;lt; /FDF &amp;lt;&amp;lt; /Fields [
&amp;lt;&amp;lt; /T (FL-100[0].Page1[0].CaptionP1_sf[0].AttyInfo[0].AttyFor_ft[0]) /V (Jane Doe) &amp;gt;&amp;gt;
] &amp;gt;&amp;gt; &amp;gt;&amp;gt;
endobj
trailer
&amp;lt;&amp;lt; /Root 1 0 R &amp;gt;&amp;gt;
%%EOF
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;pdftk fl100.pdf fill_form values.fdf output filled.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;pdftk prints a warning about the owner password and carries on. The owner password restricts editing as a matter of policy, and pdftk takes the position that a file the public can open is a file the public can fill. Adobe's own tools take the stricter position. Know which side your compliance story needs before you rely on this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dump_data_fields trap
&lt;/h2&gt;

&lt;p&gt;One gotcha cost us an hour and produces a convincing false negative. On forms that carry an XFA layer alongside the normal field tree, &lt;code&gt;dump_data_fields&lt;/code&gt; prints &lt;em&gt;two&lt;/em&gt; &lt;code&gt;FieldValue&lt;/code&gt; lines per field: an empty one first, then the real value.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FieldName: FL-100[0].Page1[0].CaptionP1_sf[0].AttyInfo[0].AttyFor_ft[0]
FieldNameAlt: ATTORNEY FOR (name):
FieldFlags: 8388608
FieldValue:
FieldValue: Jane Doe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A verification script that greps the first &lt;code&gt;FieldValue&lt;/code&gt; line reads an empty string, concludes the fill silently failed, and sends you debugging a problem that does not exist. Collect every &lt;code&gt;FieldValue&lt;/code&gt; line for a field and check whether any of them matches what you wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  The in-process answer: decrypt first
&lt;/h2&gt;

&lt;p&gt;If your fill runs inside a JS or Python service rather than a shell pipeline, remove the restriction up front with qpdf. The transformation is lossless: same pages, same fields, same appearance.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qpdf &lt;span class="nt"&gt;--decrypt&lt;/span&gt; fl100.pdf fl100-open.pdf

curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://pdfops.dev/api/fill-form &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"pdf=@fl100-open.pdf"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;'fields={"FL-100[0].Page1[0].CaptionP1_sf[0].AttyInfo[0].AttyFor_ft[0]":"Jane Doe"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"flatten=true"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; filled.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the decrypt, the file behaves like any other AcroForm: pdf-lib loads it, the API fills and flattens it, and field values verify on re-inspection. In our runs this path landed every supported field on both forms.&lt;/p&gt;

&lt;h2&gt;
  
  
  How common is this class?
&lt;/h2&gt;

&lt;p&gt;More common than two unlucky picks. After this post was drafted we extended the corpus to 22 forms, and five of them ship empty-password AES: FL-100 and &lt;a href="https://pdfops.dev/registry/ca-fl-150" rel="noopener noreferrer"&gt;FL-150&lt;/a&gt; (so it is the California Judicial Council's house style, on 2 of 2 sampled), TD1 and &lt;a href="https://pdfops.dev/registry/cra-t2200" rel="noopener noreferrer"&gt;T2200&lt;/a&gt; (same for the CRA, 2 of 2), and &lt;a href="https://pdfops.dev/registry/ssa-89" rel="noopener noreferrer"&gt;SSA-89&lt;/a&gt;, the SSN verification consent signed in most US mortgage closings. pdftk filled all five natively; every pdf-lib-family tool refused all five. If your product touches court filings, Canadian tax paperwork or lending, you will meet this class.&lt;/p&gt;

&lt;h2&gt;
  
  
  The forms this does not save
&lt;/h2&gt;

&lt;p&gt;Encryption is sometimes the smaller half of the problem. Every current USCIS form we sampled except the &lt;a href="https://pdfops.dev/registry/uscis-i-9" rel="noopener noreferrer"&gt;I-9&lt;/a&gt; combines this same empty-password AES with a &lt;em&gt;dynamic XFA&lt;/em&gt; body, which means the visible form is rendered from an embedded XML template rather than from the AcroForm field tree. qpdf gets you through the encryption, but fillers in the pdf-lib family then write the AcroForm layer and drop XFA on save, and pdftk inherits the same limits. For that class, plan on the AcroForm output being correct in mainstream viewers and blank in old XFA-first Adobe products, or use the agency's own e-filing channel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

</description>
      <category>pdf</category>
      <category>webdev</category>
      <category>serverless</category>
      <category>api</category>
    </item>
    <item>
      <title>PDFops v0.10: fill a PDF by email, make any PDF fillable</title>
      <dc:creator>PDFops</dc:creator>
      <pubDate>Thu, 06 Aug 2026 15:04:05 +0000</pubDate>
      <link>https://dev.to/pdfops/pdfops-v010-fill-a-pdf-by-email-make-any-pdf-fillable-26a2</link>
      <guid>https://dev.to/pdfops/pdfops-v010-fill-a-pdf-by-email-make-any-pdf-fillable-26a2</guid>
      <description>&lt;p&gt;v0.10 is the no-code on-ramps release. The API is unchanged — same two primitives, fill and merge — but there are now two ways to use PDFops before you've written a line of code, and one of the oldest dead-ends in PDF automation ("this PDF has no form fields") finally has a fix that doesn't involve Acrobat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fill a PDF by email — &lt;a href="mailto:fill@pdfops.dev"&gt;fill@pdfops.dev&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;Attach a blank fillable PDF and your field values to an email, send it to &lt;a href="https://pdfops.dev/fill-by-email" rel="noopener noreferrer"&gt;fill@pdfops.dev&lt;/a&gt;, and the reply comes back with the filled PDF — plus the exact &lt;code&gt;curl&lt;/code&gt; command that produced it, so the jump from "it works" to "it's in my codebase" is copy-paste. Values go in the body as plain &lt;code&gt;key: value&lt;/code&gt; lines, as raw JSON, or as a &lt;code&gt;values.json&lt;/code&gt; attachment. Add &lt;code&gt;flatten: true&lt;/code&gt; to bake the result flat.&lt;/p&gt;

&lt;p&gt;Limits are deliberate: blank templates only (a PDF that already contains data is refused — attachment contents are never stored or logged, and refusing filled-in forms means this address can't become the thing that forwards someone's data around), 5 MB per PDF, evaluation volume rather than production volume. The full contract is on &lt;a href="https://pdfops.dev/fill-by-email" rel="noopener noreferrer"&gt;/fill-by-email&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make any flat PDF fillable — in the browser
&lt;/h2&gt;

&lt;p&gt;Most PDFs that &lt;em&gt;should&lt;/em&gt; be forms aren't — they're flat: text and layout baked into the page, no interactive field layer. Until now, every PDFops surface answered that with "sorry, no AcroForm fields." The new &lt;a href="https://pdfops.dev/tools/make-fillable" rel="noopener noreferrer"&gt;make-fillable tool&lt;/a&gt; closes that gap: drop the flat PDF, draw text and checkbox fields where they belong, rename them to something your code will recognize, and download a standard AcroForm PDF that works in Acrobat, Preview, browsers, and every PDF library.&lt;/p&gt;

&lt;p&gt;It runs entirely in your browser — rendering (pdf.js) and field creation (pdf-lib) both happen client-side, so the PDF never leaves your machine. On export you also get the ready-to-POST &lt;a href="https://pdfops.dev/docs/fill-form" rel="noopener noreferrer"&gt;/api/fill-form&lt;/a&gt; call with your own field names already in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The chain, end to end
&lt;/h2&gt;

&lt;p&gt;Those two pieces compose. Starting from a PDF with no fields at all:&lt;/p&gt;

&lt;h2&gt;
  
  
  Deterministic fills, and &lt;code&gt;flatten=true&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Since late July, &lt;code&gt;/api/fill-form&lt;/code&gt; and &lt;code&gt;/api/merge&lt;/code&gt; are byte-deterministic: the same template and the same values return a byte-identical PDF, every time, across deployments. If you snapshot-test documents in CI or need audit-grade reproducibility, you can now assert on a hash — the how and why is in &lt;a href="https://pdfops.dev/blog/snapshot-testing-pdfs-in-ci" rel="noopener noreferrer"&gt;snapshot-testing PDFs in CI&lt;/a&gt;. &lt;code&gt;fill-form&lt;/code&gt; also accepts &lt;code&gt;flatten=true&lt;/code&gt;, which bakes the filled fields into the page content so the output is no longer editable — re-inspecting a flattened fill reports zero fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also since the last release notes
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The 30-second version, no signup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://pdfops.dev/api/fill-form &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"pdf=@your-template.pdf"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;'fields={"customer_name":"Acme Co","total":"$1,250.00"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; filled.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or skip the terminal entirely: &lt;a href="https://pdfops.dev/tools/make-fillable" rel="noopener noreferrer"&gt;make a PDF fillable&lt;/a&gt;, &lt;a href="https://pdfops.dev/fill-by-email" rel="noopener noreferrer"&gt;fill it by email&lt;/a&gt;, or use the &lt;a href="https://pdfops.dev/playground" rel="noopener noreferrer"&gt;playground&lt;/a&gt;. Questions, bugs, or a workflow these primitives don't cover? &lt;a href="https://pdfops.dev/contact" rel="noopener noreferrer"&gt;Tell us&lt;/a&gt; — the roadmap is driven by what people actually hit.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>webdev</category>
      <category>serverless</category>
      <category>api</category>
    </item>
    <item>
      <title>Merging PDFs in a Bun server: page order, quota, and the 429 you'll eventually hit</title>
      <dc:creator>PDFops</dc:creator>
      <pubDate>Thu, 06 Aug 2026 13:08:28 +0000</pubDate>
      <link>https://dev.to/pdfops/merging-pdfs-in-a-bun-server-page-order-quota-and-the-429-youll-eventually-hit-2mm8</link>
      <guid>https://dev.to/pdfops/merging-pdfs-in-a-bun-server-page-order-quota-and-the-429-youll-eventually-hit-2mm8</guid>
      <description>&lt;p&gt;Bun ships a fast HTTP server out of the box, so it's a common place to put a small endpoint that stitches a few PDFs together: an application packet, a signed contract plus its exhibits, a statement plus its cover letter. The merge call itself is one request. The part that actually breaks in production is what happens the day a real user's request hits your free-tier ceiling instead of your laptop's IP.&lt;/p&gt;

&lt;h2&gt;
  
  
  The endpoint
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://pdfops.dev/docs/merge" rel="noopener noreferrer"&gt;POST /api/merge&lt;/a&gt; takes a multipart body with a repeated &lt;code&gt;pdf&lt;/code&gt; field and concatenates the inputs in the order the fields appear, not the order the filenames sort in. A Bun server accepting three uploaded files and forwarding them in a fixed order (cover letter, resume, transcript) 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;// server.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PDFOPS_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PDFOPS_API_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;Bun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fetch&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="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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&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;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/apply&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="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;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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;incoming&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;formData&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;cover&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;incoming&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;cover&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;resume&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;incoming&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;resume&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;transcript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;incoming&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;transcript&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cover&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;File&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resume&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;File&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transcript&lt;/span&gt; &lt;span class="k"&gt;instanceof&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="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;missing_file&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;// Order matters: pages concatenate in the order these fields are&lt;/span&gt;
    &lt;span class="c1"&gt;// appended, not the order they were uploaded or their filenames.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;merged&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;FormData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cover&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cover.pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;resume.pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transcript.pdf&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://pdfops.dev/api/merge&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PDFOPS_KEY&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;X-API-Key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PDFOPS_KEY&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;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&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="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="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;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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="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="nx"&gt;res&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="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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-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;application/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="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;Bun's &lt;code&gt;fetch&lt;/code&gt; and &lt;code&gt;FormData&lt;/code&gt; are the same web standard APIs Node and Deno expose, so this isn't Bun-specific code: it's the pattern any of the three edge-adjacent runtimes use to call PDFops. What's worth pointing at is what the endpoint does &lt;em&gt;after&lt;/em&gt; the happy path, because that's the part every one of these worked examples tends to skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 429 you'll eventually hit
&lt;/h2&gt;

&lt;p&gt;Anonymous calls to &lt;code&gt;/api/merge&lt;/code&gt; are metered per source IP at 100 requests a month. A Bun server proxying merge requests on behalf of many end users looks like one IP to PDFops, so that ceiling arrives fast: one busy afternoon, not a slow month. When it's exhausted, PDFops returns &lt;code&gt;429&lt;/code&gt; with a &lt;code&gt;Retry-After&lt;/code&gt; header set to the seconds remaining until the 1st of next month UTC, which is not a number worth waiting on inside a live request. The right move is to surface it as a clear, distinct error instead of a generic 500:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="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;retryAfter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;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;Retry-After&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;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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;merge_quota_exhausted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;retryAfterSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;retryAfter&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;retryAfter&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="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;503&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;503&lt;/code&gt; back to your own caller (rather than passing the raw &lt;code&gt;429&lt;/code&gt; through) is a judgment call worth making deliberately: it tells your client "the service is temporarily unable to complete this," which is closer to the truth than "you personally are rate-limited," since the limit here belongs to your server's IP, not to the end user who just uploaded three files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting past the anonymous cap
&lt;/h2&gt;

&lt;p&gt;A free key raises that ceiling from 100 requests/month per IP to 250 requests/month tied to the key itself, which is the more honest unit for a server proxying requests on behalf of many users. Signing up doesn't require a card:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://pdfops.dev/api/signup &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"you@example.com"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key arrives by email; set it as &lt;code&gt;PDFOPS_API_KEY&lt;/code&gt; in the Bun process's environment and the server code above already picks it up and sends it as &lt;code&gt;X-API-Key&lt;/code&gt;. An invalid or revoked key returns &lt;code&gt;401 invalid_api_key&lt;/code&gt; rather than silently falling back to the anonymous IP cap, so a typo in the env var fails loudly instead of quietly eating into a shared quota.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watching usage before you hit the wall
&lt;/h2&gt;

&lt;p&gt;Waiting for a 429 to find out you're close to the ceiling means your first signal is a broken request in front of a real user. &lt;a href="https://pdfops.dev/docs/usage" rel="noopener noreferrer"&gt;GET /api/usage&lt;/a&gt; reads the same counter the rate limiter enforces, so a periodic check against it can warn before the wall instead of after:&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;usage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://pdfops.dev/api/usage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-API-Key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PDFOPS_KEY&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="c1"&gt;// { tier: "free", limit: 250, used: 231, remaining: 19, period: "2026-08", resetsAt: "2026-09-01T00:00:00Z" }&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;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;20&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;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`pdfops quota low: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; of &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; left this period`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that check on a schedule, a Bun cron job, or just a log line at the top of a handler that fires once an hour, and the upgrade decision happens on your terms, with the usage curve in front of you, instead of during an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  What doesn't carry across the merge
&lt;/h2&gt;

&lt;p&gt;Merging preserves every page of every input in the order supplied, but document-level structure doesn't union: AcroForm fields belong to the document, not the individual pages, so a merge of two filled forms doesn't produce one document with both sets of fields intact. If the cover letter or resume above were AcroForm templates that still needed values written in, that has to happen via &lt;a href="https://pdfops.dev/docs/fill-form" rel="noopener noreferrer"&gt;POST /api/fill-form&lt;/a&gt; before the merge call, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

</description>
      <category>pdf</category>
      <category>webdev</category>
      <category>serverless</category>
      <category>api</category>
    </item>
    <item>
      <title>Snapshot-testing generated PDFs in CI with a hash instead of a diff</title>
      <dc:creator>PDFops</dc:creator>
      <pubDate>Thu, 30 Jul 2026 13:08:17 +0000</pubDate>
      <link>https://dev.to/pdfops/snapshot-testing-generated-pdfs-in-ci-with-a-hash-instead-of-a-diff-5g4m</link>
      <guid>https://dev.to/pdfops/snapshot-testing-generated-pdfs-in-ci-with-a-hash-instead-of-a-diff-5g4m</guid>
      <description>&lt;p&gt;A headless-browser renderer stamps a fresh &lt;code&gt;/CreationDate&lt;/code&gt; into every PDF it produces, reorders internal objects between runs, and can re-hint fonts slightly differently depending on the machine that ran it. A snapshot test built on "diff these bytes" fails on every single run against that kind of renderer, whether or not the invoice actually changed. PDFops fills the same template and field values into byte-identical output every time, so a hash-based snapshot test can catch what actually changed: the template, or the data, instead of drowning in render noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a raw PDF diff doesn't work
&lt;/h2&gt;

&lt;p&gt;Open two PDFs produced a minute apart by the same rendering pipeline and run a byte-level diff, and you'll get hits even when the visible content is identical:&lt;/p&gt;

&lt;p&gt;None of that is a bug in the renderer. It's just what "render HTML to a page" costs you: the output is a fresh artifact each time, built from a pipeline with several points where non-content bytes can legitimately differ. A snapshot test needs the opposite guarantee: same input, same bytes, always.&lt;/p&gt;

&lt;h2&gt;
  
  
  What determinism changes
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://pdfops.dev/docs/fill-form" rel="noopener noreferrer"&gt;POST /api/fill-form&lt;/a&gt; only writes into AcroForm fields that already exist in the template you upload. It doesn't re-render the page, re-flow layout, or touch anything outside the fields you named. Send the same template and the same field values twice and the response bytes match, down to the last one. That's the whole property a snapshot test needs: hash the output once, commit the hash, and every later run either matches it or tells you something real happened: the template changed, the field values changed, or the request itself is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workflow
&lt;/h2&gt;

&lt;p&gt;Keep a fixture template and a fixed set of field values in the repo, call &lt;code&gt;fill-form&lt;/code&gt; with them, hash the response, and compare against a committed golden hash. This script does the comparing part:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/verify-pdf-snapshot.mjs&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;createHash&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="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;readFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs/promises&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;GOLDEN_HASH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fixtures/invoice.sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FIELDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;customer_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Acme Co&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$1,250.00&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;template&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;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fixtures/invoice-template.pdf&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;form&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;FormData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;'&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;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;template&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invoice-template.pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fields&lt;/span&gt;&lt;span class="dl"&gt;'&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;FIELDS&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://pdfops.dev/api/fill-form&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-API-Key&lt;/span&gt;&lt;span class="dl"&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;PDFOPS_API_KEY&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&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="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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`fill-form failed (&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="s2"&gt;): &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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;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="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;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;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&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="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;GOLDEN_HASH&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="s2"&gt;`snapshot mismatch\n  got:      &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n  expected: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;GOLDEN_HASH&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PDF snapshot matches.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire it into a pull-request workflow the same way you'd wire up any other check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/pdf-snapshot.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pdf-snapshot&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;verify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;22&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node scripts/verify-pdf-snapshot.mjs&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;PDFOPS_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.PDFOPS_API_KEY }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A pull request that touches the invoice template but leaves the field values alone now fails this job with a clear reason, instead of a green check that never looked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Updating the golden hash on purpose
&lt;/h2&gt;

&lt;p&gt;When the template changes for real, regenerate the fixture hash and commit it as its own reviewable line, so the person reviewing the pull request sees exactly what triggered it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node scripts/verify-pdf-snapshot.mjs &lt;span class="nt"&gt;--update&lt;/span&gt;
git diff fixtures/invoice.sha256
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A one-line hash change next to a template edit reads as expected. The same one-line hash change with no template edit in the diff is the exact signal a snapshot test exists to raise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting a key for CI
&lt;/h2&gt;

&lt;p&gt;GitHub-hosted runners draw from a shared pool of Azure IP ranges used by many unrelated jobs at once, and PDFops' anonymous trial meters by IP address. A CI job running keyless can share its 100-requests-per-month bucket with other repositories' traffic on the same address, which makes for a confusing rate-limit failure that has nothing to do with your own test volume. &lt;a href="https://pdfops.dev/docs/signup" rel="noopener noreferrer"&gt;POST /api/signup&lt;/a&gt; with an email gets a free key delivered to your inbox: 250 requests/month, no card.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://pdfops.dev/api/signup &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"you@example.com"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store it as a repository secret (&lt;code&gt;PDFOPS_API_KEY&lt;/code&gt; in the workflow above) and the job's quota is yours alone to watch, with &lt;a href="https://pdfops.dev/docs/usage" rel="noopener noreferrer"&gt;GET /api/usage&lt;/a&gt; available if you want to check it from a script instead of the dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

</description>
      <category>pdf</category>
      <category>webdev</category>
      <category>serverless</category>
      <category>api</category>
    </item>
    <item>
      <title>Migrating off wkhtmltopdf on AWS Lambda</title>
      <dc:creator>PDFops</dc:creator>
      <pubDate>Thu, 23 Jul 2026 13:07:58 +0000</pubDate>
      <link>https://dev.to/pdfops/migrating-off-wkhtmltopdf-on-aws-lambda-3bi8</link>
      <guid>https://dev.to/pdfops/migrating-off-wkhtmltopdf-on-aws-lambda-3bi8</guid>
      <description>&lt;p&gt;wkhtmltopdf's GitHub repo was archived in January 2023, its organization followed in July 2024, and its last release still carries an unpatched, CVSS-9.8 server-side request forgery. None of that has stopped it from running inside a Lambda Layer behind thousands of invoice and receipt endpoints — because "swap out the rendering engine" sounds like a multi-week project. Usually it isn't. If what you're generating is the same layout with different data every time, the fix is smaller than a rendering migration: stop rendering HTML and fill a form instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state of wkhtmltopdf, plainly
&lt;/h2&gt;

&lt;p&gt;Three facts, so you can decide how urgent this is without taking my word for it:&lt;/p&gt;

&lt;p&gt;None of this is a knock on the original project — it did its job for over a decade. But "archived + unpatched critical CVE" is the point where a security review stops asking "is this still fine" and starts asking "why is this still here."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's worse specifically on Lambda
&lt;/h2&gt;

&lt;p&gt;wkhtmltopdf isn't a library — it's a compiled binary wrapping a decade-old fork of QtWebKit, which means running it in a function means shipping the binary alongside your code, not importing a package:&lt;/p&gt;

&lt;h2&gt;
  
  
  When this migration doesn't apply
&lt;/h2&gt;

&lt;p&gt;Be honest about what you're actually rendering before doing this. PDFops fills fields in a PDF template — it doesn't render arbitrary HTML and CSS. If your output genuinely varies in layout — long-form articles, portfolio pages, dashboards with charts, anything where the &lt;em&gt;structure&lt;/em&gt; changes per document, not just the values — you still need a real browser renderer. That's a different migration (Puppeteer or Playwright in a proper sandbox, not a Lambda Layer binary), and this guide isn't it.&lt;/p&gt;

&lt;p&gt;But check what you're actually generating. Most wkhtmltopdf endpoints render the &lt;em&gt;same&lt;/em&gt; HTML template every time — an invoice, a receipt, a monthly statement, a contract — with only the data changing. That's a form problem wearing an HTML renderer's clothes, and it's the case this guide covers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The swap
&lt;/h2&gt;

&lt;p&gt;Re-author the HTML template once as a real PDF with AcroForm fields — where a Liquid/Handlebars variable used to sit, there's now a named form field. Acrobat's Prepare Form tool, LibreOffice Draw's Form Controls toolbar, or &lt;code&gt;pdftk&lt;/code&gt; on an existing PDF all work; it's a one-time job per template, not a per-request cost. Don't have one handy to test against? &lt;a href="https://pdfops.dev/invoice-template.pdf" rel="noopener noreferrer"&gt;Grab the sample invoice-template.pdf&lt;/a&gt; — it ships with &lt;code&gt;customer_name&lt;/code&gt; and &lt;code&gt;total&lt;/code&gt; fields already wired up.&lt;/p&gt;

&lt;p&gt;Once the template has real fields, &lt;a href="https://pdfops.dev/docs/fill-form" rel="noopener noreferrer"&gt;POST /api/fill-form&lt;/a&gt; replaces the entire render step: upload the template plus a JSON object of field values, get back a filled PDF. No HTML string assembly, no CSS, no rendering engine — just data going into boxes that already exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lambda handler
&lt;/h2&gt;

&lt;p&gt;Node.js 22.x runtime, ESM, no Layer. The template ships inside the deployment zip and is read once at cold start — outside the handler — so a warm invocation does zero filesystem work before calling the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// index.mjs — AWS Lambda (Node.js 22.x), Function URL&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;readFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs/promises&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;dirname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;join&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:path&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;fileURLToPath&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:url&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;PdfOps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PdfOpsError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdfops-sdk&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;here&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;fileURLToPath&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;meta&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="c1"&gt;// Bundled in the deployment package — no Layer, no /tmp write.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;template&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;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;here&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invoice-template.pdf&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;pdfops&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;PdfOps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PDFOPS_API_KEY&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;handler&lt;/span&gt; &lt;span class="o"&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;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="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="nx"&gt;requestContext&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;405&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST a JSON body: { customer_name, total }&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;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;filled&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;pdfops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillForm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;template&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;customer_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer_name&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="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&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="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;statusCode&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="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-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="s1"&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;body&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;filled&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;isBase64Encoded&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="k"&gt;catch &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="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;e&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;PdfOpsError&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;statusCode&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="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;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;error&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="nx"&gt;code&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;e&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="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;npm install pdfops-sdk&lt;/code&gt;, zip &lt;code&gt;index.mjs&lt;/code&gt;, &lt;code&gt;node_modules&lt;/code&gt;, and &lt;code&gt;invoice-template.pdf&lt;/code&gt; together, set &lt;code&gt;PDFOPS_API_KEY&lt;/code&gt; as an environment variable, done. Compare that deployment package — a handful of KB of pure JavaScript — to a Lambda Layer carrying a 50-80MB native binary, and the size difference alone is most of why cold starts get faster: there's no subprocess to spin up before your code even runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get beyond removing the CVE
&lt;/h2&gt;

&lt;p&gt;Filling a form is a narrower operation than rendering HTML, and narrower means more predictable. The same input bytes and the same field values produce byte-identical output every time — no font-substitution drift, no headless-renderer version bump silently reflowing last month's invoices. If that determinism matters for hashing, caching, or diffing PDFs in CI, that's the whole argument in &lt;a href="https://pdfops.dev/blog/deterministic-pdf-filling" rel="noopener noreferrer"&gt;the case for deterministic PDF filling&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting a key
&lt;/h2&gt;

&lt;p&gt;The handler above needs a key to run past the anonymous trial. &lt;a href="https://pdfops.dev/docs/signup" rel="noopener noreferrer"&gt;POST /api/signup&lt;/a&gt; with an email gets a free one — 250 requests/month, no card, delivered to your inbox (never in the response body, so inbox possession doubles as ownership proof):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://pdfops.dev/api/signup &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"you@example.com"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anonymous calls (no &lt;code&gt;X-API-Key&lt;/code&gt; header) still work at 100 requests/IP/month — enough to run the example below before deciding whether to wire in a key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Prove the fill step from a terminal before touching Lambda:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://pdfops.dev/api/fill-form &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"pdf=@invoice-template.pdf"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;'fields={"customer_name":"Acme Co","total":"$1,250.00"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; filled.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;filled.pdf&lt;/code&gt; opens with both fields populated — that curl call is exactly what the Lambda handler above does under the hood, just wrapped in the SDK's typed &lt;code&gt;fillForm&lt;/code&gt; method instead of raw multipart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

</description>
      <category>pdf</category>
      <category>webdev</category>
      <category>serverless</category>
      <category>api</category>
    </item>
    <item>
      <title>Assembling a KYC packet on Deno Deploy</title>
      <dc:creator>PDFops</dc:creator>
      <pubDate>Tue, 21 Jul 2026 21:12:02 +0000</pubDate>
      <link>https://dev.to/pdfops/assembling-a-kyc-packet-on-deno-deploy-39go</link>
      <guid>https://dev.to/pdfops/assembling-a-kyc-packet-on-deno-deploy-39go</guid>
      <description>&lt;p&gt;Compliance owns the KYC application template, not engineering — and they revise it without filing a ticket. Hardcode the field names once and the next template swap either breaks silently (a field goes unfilled) or throws &lt;code&gt;unknown_field&lt;/code&gt; in production. The fix is to stop assuming the shape of a PDF you don't control: ask it, at request time, what fields it actually has, fill whatever comes back, then merge the filled application with the applicant's uploaded ID scan into one packet. Here's the whole flow in ~45 lines on Deno Deploy, using the new &lt;a href="https://www.npmjs.com/package/pdfops-sdk" rel="noopener noreferrer"&gt;pdfops-sdk&lt;/a&gt; instead of raw &lt;code&gt;fetch&lt;/code&gt; calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why inspect first
&lt;/h2&gt;

&lt;p&gt;Every other example on this blog hardcodes a JSON object of field names — reasonable when the template PDF is a file you committed to your own repo. A KYC application template isn't that. It's owned by a compliance or ops team who edit it in Acrobat, rename a field, add a new disclosure checkbox, and re-upload it to wherever your app fetches it from — all without a code review on your side. Filling against a hardcoded key list against a template you don't control is a bug waiting for the next revision.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pdfops.dev/docs/inspect" rel="noopener noreferrer"&gt;POST /api/inspect&lt;/a&gt; answers "what fields does &lt;em&gt;this&lt;/em&gt; PDF have right now?" — every AcroForm field's name, type, and (for dropdowns/radios) its options, plus a ready-to-fill &lt;code&gt;fillTemplate&lt;/code&gt; object keyed by name. Call it once per request against the live template, and the fill step only ever writes into fields that actually exist. A template revision changes what gets filled, not whether the request throws.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install the SDK, get a key
&lt;/h2&gt;

&lt;p&gt;Every earlier post here uses &lt;code&gt;fetch&lt;/code&gt; and &lt;code&gt;FormData&lt;/code&gt; directly against the HTTP API — that still works and always will. &lt;code&gt;pdfops-sdk&lt;/code&gt; is a thin typed wrapper around the same endpoints: no dependencies, edge-safe (Workers, Vercel Edge, Deno, Bun, Node 18+, browsers), and it turns the multipart plumbing into three method calls.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;pdfops-sdk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You need a key to run more than the anonymous trial. A free one costs nothing and needs no card — email in, key out (the key arrives by email, not in the response body, so inbox possession is the verification step):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PdfOps&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdfops-sdk&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;trial&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;PdfOps&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// no key: 100 requests/IP/month&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trial&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;you@example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// free key, 250/month, no card — check your inbox&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the key lands, wire it in and every call meters against your own quota instead of your IP's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pdfops&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;PdfOps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Deno&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PDFOPS_API_KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Deno Deploy handler
&lt;/h2&gt;

&lt;p&gt;The endpoint takes a multipart POST — the applicant's data as a JSON field, their ID scan as a file — pulls the current application template, inspects it, fills only the fields it finds, and merges in the ID scan. No browser, no second runtime, ~45 lines.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// main.ts — Deno Deploy&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;PdfOps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PdfOpsError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdfops-sdk&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;pdfops&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;PdfOps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Deno&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PDFOPS_API_KEY&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;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST multipart/form-data: applicant, id_scan&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;405&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;form&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;formData&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;idScan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id_scan&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;applicantRaw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;applicant&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idScan&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;File&lt;/span&gt;&lt;span class="p"&gt;)&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;applicantRaw&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expected fields "applicant" (JSON) and "id_scan" (file)&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;const&lt;/span&gt; &lt;span class="na"&gt;applicant&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="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;applicantRaw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 1. Pull the CURRENT template — compliance swaps this file on their&lt;/span&gt;
  &lt;span class="c1"&gt;//    own schedule, so the fetch (not a bundled asset) is deliberate.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;templateResp&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="nx"&gt;Deno&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TEMPLATE_URL&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="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;template&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;templateResp&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="c1"&gt;// 2. Ask the template what fields it has right now, instead of&lt;/span&gt;
  &lt;span class="c1"&gt;//    assuming a fixed shape.&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;fillTemplate&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;pdfops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inspect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;template&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. Map applicant data onto whatever fields actually exist. A field&lt;/span&gt;
  &lt;span class="c1"&gt;//    the template dropped is silently skipped; a field the applicant&lt;/span&gt;
  &lt;span class="c1"&gt;//    didn't send stays at the template's default.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;values&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="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fillTemplate&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;name&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;applicant&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;values&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;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;applicant&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="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;// 4. Fill the application.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;filled&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;pdfops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillForm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;template&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// 5. Merge the filled application with the uploaded ID scan into&lt;/span&gt;
    &lt;span class="c1"&gt;//    one packet — page order follows array order.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idBytes&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;idScan&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;packet&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;pdfops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;filled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idBytes&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="nx"&gt;packet&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-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="s1"&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="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;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;PdfOpsError&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="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="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;error&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="nx"&gt;code&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&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="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;deployctl deploy main.ts&lt;/code&gt;, set &lt;code&gt;PDFOPS_API_KEY&lt;/code&gt; and &lt;code&gt;TEMPLATE_URL&lt;/code&gt; as environment variables, and you have a live endpoint. The &lt;code&gt;fillForm&lt;/code&gt; and &lt;code&gt;merge&lt;/code&gt; calls both return &lt;code&gt;Uint8Array&lt;/code&gt; PDF bytes — hand them to storage, a &lt;code&gt;Response&lt;/code&gt;, or an email attachment as-is; nothing in this handler buffers more than one document at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why merge goes last, and what else it can hold
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;merge([filled, idBytes])&lt;/code&gt; concatenates pages in array order, so the filled application always lands as page 1 — the reviewer opens the packet and reads the application first, attachments after. The array isn't capped at two: a real KYC packet is usually &lt;code&gt;merge([filled, idFront, idBack, proofOfAddress])&lt;/code&gt; — every attachment the applicant uploaded, in the order you want a reviewer to see them, still one &lt;a href="https://pdfops.dev/docs/merge" rel="noopener noreferrer"&gt;POST /api/merge&lt;/a&gt; call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Errors and watching your quota
&lt;/h2&gt;

&lt;p&gt;Every non-2xx response the SDK sees throws a &lt;code&gt;PdfOpsError&lt;/code&gt; with the API's stable error slug — the &lt;code&gt;catch&lt;/code&gt; block above turns &lt;code&gt;unknown_field&lt;/code&gt;, &lt;code&gt;invalid_pdf&lt;/code&gt;, or a &lt;code&gt;429 rate_limited&lt;/code&gt; straight into the right HTTP status for the caller, instead of a generic 500. And because the free tier has a real ceiling (250/month per key), check it before you're surprised by one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;used&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resets_at&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;pdfops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's &lt;a href="https://pdfops.dev/docs/usage" rel="noopener noreferrer"&gt;GET /api/usage&lt;/a&gt; under the hood — it reads the same counter the 429 fires on, so the number you show a user is never out of sync with the number that blocks them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits a real app
&lt;/h2&gt;

&lt;p&gt;Swap the runtime and the trigger, and the same three-call shape — inspect, fill, merge — covers most "combine a filled form with something a user uploaded" flows:&lt;/p&gt;

&lt;p&gt;The part that's new here is &lt;code&gt;inspect&lt;/code&gt; in the chain — reach for it whenever the template isn't a file you fully control, not just for KYC packets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;All three endpoints are live. Prove the chain from your terminal before writing a line of Deno:&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;# 1. What fields does this template have?&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://pdfops.dev/api/inspect &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"pdf=@application.pdf"&lt;/span&gt;

&lt;span class="c"&gt;# 2. Fill it&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://pdfops.dev/api/fill-form &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"pdf=@application.pdf"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;'fields={"applicant_name":"Ada Lovelace","country":"UK"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; filled.pdf

&lt;span class="c"&gt;# 3. Merge with the ID scan&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://pdfops.dev/api/merge &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"pdf=@filled.pdf"&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"pdf=@id-scan.pdf"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; packet.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get 100 keyless requests per IP per month, or &lt;code&gt;pdfops.signup('you@example.com')&lt;/code&gt; / the form at &lt;a href="https://pdfops.dev/pricing" rel="noopener noreferrer"&gt;/pricing&lt;/a&gt; for a free key (250/mo, no card — delivered by email).&lt;/p&gt;

&lt;p&gt;Building a KYC or onboarding flow and something about &lt;code&gt;inspect&lt;/code&gt;'s field-type coverage doesn't fit yours? &lt;a href="https://pdfops.dev/#waitlist" rel="noopener noreferrer"&gt;Drop a note on the feedback form&lt;/a&gt; — it's the fastest way to influence what ships next.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>webdev</category>
      <category>serverless</category>
      <category>api</category>
    </item>
    <item>
      <title>Fill a PDF form inside a Cloudflare Worker — no Chromium, no Lambda</title>
      <dc:creator>PDFops</dc:creator>
      <pubDate>Tue, 30 Jun 2026 22:50:40 +0000</pubDate>
      <link>https://dev.to/pdfops/fill-a-pdf-form-inside-a-cloudflare-worker-no-chromium-no-lambda-2bpc</link>
      <guid>https://dev.to/pdfops/fill-a-pdf-form-inside-a-cloudflare-worker-no-chromium-no-lambda-2bpc</guid>
      <description>&lt;p&gt;You're building on Cloudflare Workers and you need to write values into a PDF form — an invoice, a contract, a government form. You reach for the thing you used last time: headless Chrome, or a PDF library bundled into a Lambda. On Workers, neither one fits. Chrome won't run in a V8 isolate, and standing up a Lambda just to render a PDF drags a whole second runtime into an app that didn't need one. The fill itself is one HTTP call. Here's the minimal Worker, and the reason the hosting substrate — not the PDF code — is the part that actually matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why headless Chrome doesn't fit Workers
&lt;/h2&gt;

&lt;p&gt;The default way to "make a PDF in JavaScript" for the last decade has been Puppeteer driving headless Chrome: render HTML, call &lt;code&gt;page.pdf()&lt;/code&gt;, done. It works on a normal Node server or a fat Lambda with a Chromium layer. It does not work on Cloudflare Workers, and the reason is architectural, not a missing flag. A Worker runs in a &lt;strong&gt;V8 isolate&lt;/strong&gt; — the same engine Chrome uses, but with no operating system underneath it. There's no filesystem, no ability to spawn a subprocess, and a hard memory and CPU-time budget per request. Headless Chrome is an entire browser binary that expects all of those things. You can't bundle a ~150 MB browser into a Worker, and even if you could, there's nothing to &lt;code&gt;exec()&lt;/code&gt; it.&lt;/p&gt;

&lt;p&gt;AcroForm filling — writing values into a form-enabled PDF's existing fields — doesn't need a browser at all. The fields are already defined in the PDF; you're setting their values and flattening, which is pure byte manipulation. The work is a poor match for a render engine and a perfect match for a stateless function. The only question is &lt;em&gt;where that function runs&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just put it on Lambda
&lt;/h2&gt;

&lt;p&gt;The usual escape hatch is "keep the PDF work on AWS Lambda and call it from the Worker." That works, but look at what it costs you. You're now running two runtimes for one feature: the Worker that owns the request, and a Lambda that exists only to hold a PDF library. You inherit Lambda's cold starts on a path your edge app was specifically built to keep fast. You're routing edge → &lt;code&gt;us-east-1&lt;/code&gt; → edge for every document, so a user in Sydney pays a trans-Pacific round trip to fill a one-page form. And you've split your deploy: two log streams, two IAM surfaces, two things to keep in sync. None of that is about PDFs. It's all substrate drag, bolted onto an app that chose Workers to avoid exactly this.&lt;/p&gt;

&lt;p&gt;The alternative is to treat the fill as what it is — a stateless transform — and call a hosted endpoint that runs on the same kind of globally-distributed substrate your Worker already lives on. The Worker stays the only thing you deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Worker
&lt;/h2&gt;

&lt;p&gt;Here's the whole thing. It takes a JSON body of field values, fetches an AcroForm template from R2, calls &lt;a href="https://pdfops.dev/docs/fill-form" rel="noopener noreferrer"&gt;/api/fill-form&lt;/a&gt;, and returns the filled PDF. No browser, no second runtime, ~35 lines.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/index.ts — Cloudflare Worker (module syntax)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;TEMPLATES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;R2Bucket&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// bucket holding your blank AcroForm PDFs&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;env&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST a JSON body of field values&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;405&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 1. The values to write into the form's named fields.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fields&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="nx"&gt;json&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Pull the blank template from R2 (cached at the edge after first read).&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;obj&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;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TEMPLATES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invoice-template.pdf&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;obj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;template missing&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;500&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;templatePdf&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;obj&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="c1"&gt;// 3. One call to PDFops. Field keys must match the PDF's AcroForm&lt;/span&gt;
    &lt;span class="c1"&gt;//    field names — use /tools/inspect to list them if you're unsure.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fd&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;FormData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;'&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;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;templatePdf&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="s1"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;template.pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fields&lt;/span&gt;&lt;span class="dl"&gt;'&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;fields&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;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://pdfops.dev/api/fill-form&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fd&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;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="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="s2"&gt;`fill failed: &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;resp&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="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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 4. Stream the filled PDF straight back to the caller.&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="nx"&gt;resp&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="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-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="s1"&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="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;That's the production shape. Bind an R2 bucket named &lt;code&gt;TEMPLATES&lt;/code&gt; in &lt;code&gt;wrangler.toml&lt;/code&gt;, drop a form-enabled PDF into it, &lt;code&gt;wrangler deploy&lt;/code&gt;, and POST a JSON object of field values. You get a filled PDF back with no second service in the picture. The &lt;code&gt;resp.body&lt;/code&gt; stream means the Worker never buffers the whole document in memory — it pipes PDFops' response through, which keeps you well inside the isolate's memory budget even for large forms.&lt;/p&gt;

&lt;p&gt;Two things worth knowing. The field &lt;em&gt;keys&lt;/em&gt; have to match the names baked into the PDF's AcroForm — if you're not sure what they are, the free &lt;a href="https://pdfops.dev/tools/inspect" rel="noopener noreferrer"&gt;Form-Field Inspector&lt;/a&gt; lists every field name in any PDF you drop on it. And the R2 &lt;code&gt;get()&lt;/code&gt; is edge-cached after the first read, so you're not re-fetching the template on every request — the steady-state path is just the one &lt;code&gt;fetch&lt;/code&gt; to fill-form.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits a real app
&lt;/h2&gt;

&lt;p&gt;The bare Worker above is the primitive. In practice the trigger is usually a webhook or a queue, and the output goes to storage plus an email. Those are the same pattern with more wiring around the fill step:&lt;/p&gt;

&lt;p&gt;In every one of these the fill is the same single &lt;code&gt;fetch&lt;/code&gt;. What changes is the trigger and the destination — never the PDF substrate.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this pattern doesn't fit
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The endpoint is live and works against any AcroForm PDF. Before you even write the Worker, prove the fill from your terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;curl&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;X&lt;/span&gt; &lt;span class="nx"&gt;POST&lt;/span&gt; &lt;span class="nx"&gt;https&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="c1"&gt;//pdfops.dev/api/fill-form \&lt;/span&gt;
  &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;F&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pdf=@invoice-template.pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;F&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fields={"customer_name":"Acme Corp","invoice_no":"INV-1042","amount_due":"$2,400.00"}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt; &lt;span class="nx"&gt;filled&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pdf&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll get the filled PDF back. From there the Worker above is just that same call wrapped in a &lt;code&gt;fetch&lt;/code&gt; handler. During beta it's 100 requests per IP per month, free, no signup.&lt;/p&gt;

&lt;p&gt;Workers-specific questions, a binding that's fighting you, or an endpoint you wish existed? &lt;a href="https://pdfops.dev/#waitlist" rel="noopener noreferrer"&gt;Drop a note on the waitlist form&lt;/a&gt; — the message field is the fastest way to influence what ships next.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>serverless</category>
      <category>webdev</category>
      <category>pdf</category>
    </item>
    <item>
      <title>Fill a PDF in JavaScript — in the browser or via API</title>
      <dc:creator>PDFops</dc:creator>
      <pubDate>Sat, 13 Jun 2026 18:03:40 +0000</pubDate>
      <link>https://dev.to/pdfops/fill-a-pdf-in-javascript-in-the-browser-or-via-api-4ej</link>
      <guid>https://dev.to/pdfops/fill-a-pdf-in-javascript-in-the-browser-or-via-api-4ej</guid>
      <description>&lt;p&gt;JavaScript gives you a choice no other language quite does: you can fill a PDF &lt;strong&gt;entirely in the browser&lt;/strong&gt;, so the file never leaves the user’s machine, or you can fill it &lt;strong&gt;server-side&lt;/strong&gt; when the output must be authoritative. This page shows both with runnable code, and is honest about when each is the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  In the browser, client-side (the file never uploads)
&lt;/h2&gt;

&lt;p&gt;With &lt;a href="https://pdfops.dev/blog/deterministic-pdf-filling" rel="noopener noreferrer"&gt;pdf-lib&lt;/a&gt; the whole fill runs in the page. Read a chosen file into bytes, set the AcroForm fields, and offer the result as a download — nothing is sent anywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PDFDocument&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;pdf-lib&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;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// &amp;lt;input type="file"&amp;gt;&lt;/span&gt;
&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;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;doc&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;PDFDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getForm&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTextField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;customer_name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;setText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Acme Co&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTextField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice_total&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;setText&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,250.00&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// form.flatten();  // optional: bake values in&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&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;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&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;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="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;application/pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a&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;href&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="na"&gt;download&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;filled.pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the right default for anything sensitive — tax forms, contracts, medical intake — because the document stays on the device. You can see exactly this pattern, fill &lt;em&gt;and&lt;/em&gt; merge, running live in the &lt;a href="https://pdfops.dev/playground" rel="noopener noreferrer"&gt;PDFops playground&lt;/a&gt;, which does all its work client-side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server-side, via one &lt;code&gt;fetch&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;When the filled PDF needs to be authoritative — generated from data the browser doesn’t have, or produced where the client can’t be trusted to make the canonical copy — fill it through the API. The same &lt;code&gt;fetch&lt;/code&gt; works from a Worker, an edge function, or a Node backend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;form&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;FormData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fileBlob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;template.pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// a Blob/File of the template&lt;/span&gt;
&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fields&lt;/span&gt;&lt;span class="dl"&gt;"&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;customer_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Acme Co&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;invoice_total&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,250.00&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;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://pdfops.dev/api/fill-form&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;form&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;filled&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;resp&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="c1"&gt;// the filled PDF bytes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production, call this from &lt;strong&gt;your own backend or edge function&lt;/strong&gt; rather than directly from the browser — that keeps rate limits and keys (post-beta) under your control and out of end-user hands. During beta there’s no key, so a direct browser call is fine for a prototype. Use the &lt;a href="https://pdfops.dev/tools/inspect" rel="noopener noreferrer"&gt;Form-Field Inspector&lt;/a&gt; to get the exact field names for your &lt;code&gt;fields&lt;/code&gt; object.&lt;/p&gt;

&lt;h2&gt;
  
  
  Browser or server: how to choose
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Client-side (pdf-lib in browser)&lt;/th&gt;
&lt;th&gt;Server-side (PDFops API)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;File leaves the device&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt; — stays in the browser&lt;/td&gt;
&lt;td&gt;Yes — sent to the API to fill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authoritative output&lt;/td&gt;
&lt;td&gt;Client-produced (trust the client)&lt;/td&gt;
&lt;td&gt;Server-produced (canonical)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fill from server-only data&lt;/td&gt;
&lt;td&gt;No — only what the page has&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Form internals you manage&lt;/td&gt;
&lt;td&gt;Typed accessors, flatten, appearances&lt;/td&gt;
&lt;td&gt;None — handled server-side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Merge available&lt;/td&gt;
&lt;td&gt;Yes — pdf-lib copyPages&lt;/td&gt;
&lt;td&gt;Yes — &lt;a href="https://pdfops.dev/docs/merge" rel="noopener noreferrer"&gt;/api/merge&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Determinism&lt;/td&gt;
&lt;td&gt;Deterministic (no AI)&lt;/td&gt;
&lt;td&gt;Deterministic, audit-safe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Privacy-first, zero-upload, instant preview&lt;/td&gt;
&lt;td&gt;Canonical records, server data, store/sign/merge&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest summary: if privacy or zero-upload is the point, &lt;strong&gt;fill client-side with pdf-lib&lt;/strong&gt; — it’s free and the file never moves. If the output must be the system-of-record copy, or you fill from data the browser doesn’t hold, fill server-side. A common shape is both: a fast client-side preview, then a server-side canonical fill on submit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Merging PDFs in JavaScript too
&lt;/h2&gt;

&lt;p&gt;Server-side, merge is the same one-call primitive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;form&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;FormData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;pdfBlobs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pdfs&lt;/span&gt;&lt;span class="dl"&gt;"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://pdfops.dev/api/merge&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Client-side, pdf-lib merges with &lt;code&gt;copyPages&lt;/code&gt; into a fresh document. Either way the fill-then-merge flow stays deterministic end to end. Endpoint details: &lt;a href="https://pdfops.dev/docs/fill-form" rel="noopener noreferrer"&gt;fill-form docs&lt;/a&gt;, &lt;a href="https://pdfops.dev/docs/merge" rel="noopener noreferrer"&gt;merge docs&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;How do I fill a PDF form in the browser with JavaScript?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Read the file into an ArrayBuffer, then use pdf-lib client-side: load the bytes, getForm(), set field values, save() back to bytes you offer as a download. Everything runs in the browser, so the PDF never leaves the device. The &lt;a href="https://pdfops.dev/playground" rel="noopener noreferrer"&gt;playground&lt;/a&gt; does exactly this, live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I fill in the browser or on the server?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Browser when privacy or zero-upload matters — the file stays on the device. Server when the output must be authoritative, when you fill from data the browser lacks, or when you also store/sign/merge server-side. Many apps do both: client-side preview, server-side canonical fill.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I call the PDFops API from browser JavaScript?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes — it's a normal fetch with FormData. For production, call it from your own backend or edge function so you control rate limits and keys (post-beta) and don't expose usage to end users. During beta there's no key, so a direct browser call works for prototypes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is pdf-lib enough to fill a PDF in JavaScript?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Often yes — pdf-lib is pure JS, runs in browsers and Node, and fills AcroForm fields well. You own the form internals (typed accessors, flatten, appearance/font edge cases). PDFops is built on a modernized pdf-lib fork and offers that engine as a deterministic hosted API for when you'd rather not own those edge cases or need server-authoritative output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does filling change the bytes unpredictably?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It shouldn't. Both pdf-lib and the PDFops API fill deterministically — same template plus same values yields the same field-level output, no AI in the path. Outputs stay diffable and audit-safe. The argument is in &lt;a href="https://pdfops.dev/blog/deterministic-pdf-filling" rel="noopener noreferrer"&gt;this essay&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it in 30 seconds
&lt;/h2&gt;

&lt;p&gt;Fill and merge a real PDF client-side, right now, in the &lt;a href="https://pdfops.dev/playground" rel="noopener noreferrer"&gt;playground&lt;/a&gt; — no signup. On another stack? See &lt;a href="https://pdfops.dev/fill-pdf-in-python" rel="noopener noreferrer"&gt;fill a PDF in Python&lt;/a&gt; and &lt;a href="https://pdfops.dev/fill-pdf-in-node" rel="noopener noreferrer"&gt;fill a PDF in Node&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If the deterministic fill + merge primitive fits your usage, &lt;a href="https://pdfops.dev/#waitlist" rel="noopener noreferrer"&gt;join the waitlist&lt;/a&gt; and tell me the forms you fill most — that signal is what the pricing tiers and the in-function library get built around.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>pdf</category>
      <category>webdev</category>
      <category>api</category>
    </item>
    <item>
      <title>Fill a PDF in Node.js — one fetch, no headless browser</title>
      <dc:creator>PDFops</dc:creator>
      <pubDate>Sat, 13 Jun 2026 17:57:32 +0000</pubDate>
      <link>https://dev.to/pdfops/fill-a-pdf-in-nodejs-one-fetch-no-headless-browser-5h4d</link>
      <guid>https://dev.to/pdfops/fill-a-pdf-in-nodejs-one-fetch-no-headless-browser-5h4d</guid>
      <description>&lt;p&gt;To fill a PDF form from Node you can run it in-process with &lt;strong&gt;pdf-lib&lt;/strong&gt;, or POST the template to an HTTP API and get the filled PDF back. On &lt;strong&gt;Node 18+&lt;/strong&gt; the HTTP path needs zero dependencies — &lt;code&gt;fetch&lt;/code&gt; and &lt;code&gt;FormData&lt;/code&gt; are global — and the same code runs unchanged on Workers, Lambda, and edge functions. Here is both, with an honest read on which fits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fastest path: one &lt;code&gt;fetch&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;PDFops fills &lt;a href="https://pdfops.dev/tools/inspect" rel="noopener noreferrer"&gt;AcroForm fields&lt;/a&gt; server-side. From modern Node it is a few lines, no packages installed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;writeFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs/promises&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;form&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;FormData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;"&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;Blob&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;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;template.pdf&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;template.pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fields&lt;/span&gt;&lt;span class="dl"&gt;"&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;customer_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Acme Co&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;invoice_total&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,250.00&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;paid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Yes&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;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://pdfops.dev/api/fill-form&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`fill failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;filled.pdf&lt;/span&gt;&lt;span class="dl"&gt;"&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;resp&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The keys in the &lt;code&gt;fields&lt;/code&gt; object must match the AcroForm field names in the template. Run the PDF through the &lt;a href="https://pdfops.dev/tools/inspect" rel="noopener noreferrer"&gt;Form-Field Inspector&lt;/a&gt; to see the exact names and types. No API key or signup during beta.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing it in-process: pdf-lib
&lt;/h2&gt;

&lt;p&gt;The local route is pure JavaScript and keeps everything in-process — no network call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;writeFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs/promises&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;PDFDocument&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;pdf-lib&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;doc&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;PDFDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;template.pdf&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;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getForm&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTextField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;customer_name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;setText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Acme Co&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTextField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice_total&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;setText&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,250.00&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// form.flatten();  // optional: bake values in, drop interactivity&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;filled.pdf&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;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a genuinely good library and for many backends it is the right answer. The work you take on is the form internals: getting the right typed accessor per field (&lt;code&gt;getTextField&lt;/code&gt; vs &lt;code&gt;getCheckBox&lt;/code&gt; vs &lt;code&gt;getRadioGroup&lt;/code&gt;), deciding when to &lt;code&gt;flatten()&lt;/code&gt;, and handling appearance/font cases on unusual templates. PDFops is built on a &lt;a href="https://pdfops.dev/blog/deterministic-pdf-filling" rel="noopener noreferrer"&gt;modernized fork of pdf-lib&lt;/a&gt;, so the API path is essentially that same engine offered as a managed, deterministic service — you trade a network call for not owning the edge cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters on serverless and the edge
&lt;/h2&gt;

&lt;p&gt;The reason the &lt;code&gt;fetch&lt;/code&gt; version is interesting isn’t brevity — it’s portability. Because the call is plain &lt;code&gt;fetch&lt;/code&gt; + &lt;code&gt;FormData&lt;/code&gt;, both Web-standard, &lt;strong&gt;the identical code runs on Cloudflare Workers, Vercel Edge, Deno Deploy, and Bun&lt;/strong&gt;, not only Node. There is no headless Chromium to bundle and no native addon to compile, which is exactly what tends to block PDF tooling on those platforms.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;PDFops (HTTP)&lt;/th&gt;
&lt;th&gt;pdf-lib (in-process)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dependencies on Node 18+&lt;/td&gt;
&lt;td&gt;None — global fetch/FormData&lt;/td&gt;
&lt;td&gt;pdf-lib (pure JS, no native addons)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs on Workers / Edge / Deno / Bun&lt;/td&gt;
&lt;td&gt;Yes — same code, unchanged&lt;/td&gt;
&lt;td&gt;Yes — pure JS, but you bundle it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network call required&lt;/td&gt;
&lt;td&gt;Yes — one POST per fill&lt;/td&gt;
&lt;td&gt;No — fully in-process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Form internals you manage&lt;/td&gt;
&lt;td&gt;None — handled server-side&lt;/td&gt;
&lt;td&gt;Typed accessors, flatten, appearances&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Merge in the same tool&lt;/td&gt;
&lt;td&gt;Yes — &lt;a href="https://pdfops.dev/docs/merge" rel="noopener noreferrer"&gt;/api/merge&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Yes — copyPages + save&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Determinism&lt;/td&gt;
&lt;td&gt;Deterministic, audit-safe&lt;/td&gt;
&lt;td&gt;Deterministic (no AI either)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;No-dependency serverless, fill+merge as a service&lt;/td&gt;
&lt;td&gt;In-process control, no-network constraints&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest summary: if you want the fill fully in-process and don’t mind owning form internals, &lt;strong&gt;pdf-lib is an excellent, free choice&lt;/strong&gt;. If you want one deterministic API for fill and merge, identical behavior from local Node to the edge, and nothing to bundle or compile, the HTTP call is worth the round-trip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Merging PDFs from Node too
&lt;/h2&gt;

&lt;p&gt;The same primitive merges several PDFs into one in a single call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;form&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;FormData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a.pdf&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;b.pdf&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;c.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;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pdfs&lt;/span&gt;&lt;span class="dl"&gt;"&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;Blob&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;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)]),&lt;/span&gt; &lt;span class="nx"&gt;path&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;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://pdfops.dev/api/merge&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;form&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;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;merged.pdf&lt;/span&gt;&lt;span class="dl"&gt;"&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;resp&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A common backend shape is fill-then-merge: fill several templates, then concatenate the results into one document to deliver. Both halves are the same deterministic primitive, so the combined output is reproducible. Endpoint details: &lt;a href="https://pdfops.dev/docs/fill-form" rel="noopener noreferrer"&gt;fill-form docs&lt;/a&gt;, &lt;a href="https://pdfops.dev/docs/merge" rel="noopener noreferrer"&gt;merge docs&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;How do I fill a PDF form in Node.js?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Either run pdf-lib in-process to set AcroForm values, or build a FormData with the template plus a JSON field map and POST it. On Node 18+, fetch/FormData/Blob are global, so the HTTP fill needs no dependencies and runs unchanged on Workers, Lambda, and edge functions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I use pdf-lib or PDFops?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;pdf-lib when you want the fill fully in-process with no network call and are comfortable managing form internals. PDFops when you want one deterministic API for fill and merge, identical behavior across Node and edge, and nothing to bundle. PDFops is built on a modernized pdf-lib fork, so the API gives you that engine as a managed service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I fill a PDF on Cloudflare Workers or Vercel Edge?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes — the call is plain fetch + FormData, both Web-standard, so the same code runs on Workers, Vercel Edge, Deno Deploy, and Bun. No headless browser, no native addon — which is the usual blocker for PDF tooling on those platforms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need a library to call PDFops from Node?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. On Node 18+, fetch/FormData/Blob are global, so a fill is a few lines with zero dependencies. On older Node, add undici or node-fetch plus form-data, or just upgrade the runtime. There is no SDK to install during beta — the API is plain HTTP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the fill deterministic and audit-safe?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes — same template plus same field values yields the same field-level output, no AI inference in the path. Outputs are diffable and reproducible, which matters when a filled PDF is a record you may need to defend. The argument is in &lt;a href="https://pdfops.dev/blog/deterministic-pdf-filling" rel="noopener noreferrer"&gt;this essay&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it in 30 seconds
&lt;/h2&gt;

&lt;p&gt;No API key, no signup during beta. Paste the snippet above into a Node script, or explore fields in the &lt;a href="https://pdfops.dev/playground" rel="noopener noreferrer"&gt;playground&lt;/a&gt;. On another stack? See &lt;a href="https://pdfops.dev/fill-pdf-in-python" rel="noopener noreferrer"&gt;fill a PDF in Python&lt;/a&gt; and &lt;a href="https://pdfops.dev/fill-pdf-in-javascript" rel="noopener noreferrer"&gt;fill a PDF in JavaScript&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If the deterministic fill + merge primitive fits your usage, &lt;a href="https://pdfops.dev/#waitlist" rel="noopener noreferrer"&gt;join the waitlist&lt;/a&gt; and tell me the forms you fill most — that signal is what the pricing tiers and the in-function library get built around.&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>pdf</category>
      <category>api</category>
    </item>
    <item>
      <title>Fill a PDF in Python — one HTTP call, no native deps</title>
      <dc:creator>PDFops</dc:creator>
      <pubDate>Sat, 13 Jun 2026 17:57:12 +0000</pubDate>
      <link>https://dev.to/pdfops/fill-a-pdf-in-python-one-http-call-no-native-deps-3jek</link>
      <guid>https://dev.to/pdfops/fill-a-pdf-in-python-one-http-call-no-native-deps-3jek</guid>
      <description>&lt;p&gt;If you need to fill a PDF form from Python, you have two honest options: drive a native library like &lt;strong&gt;pypdf&lt;/strong&gt; or &lt;strong&gt;fillpdf&lt;/strong&gt;, or POST the template to an HTTP API and get the filled PDF back. This page shows the HTTP path in full, then makes the case for &lt;em&gt;when&lt;/em&gt; the native library is the better call — because sometimes it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fastest path: one &lt;code&gt;requests.post&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;PDFops fills &lt;a href="https://pdfops.dev/tools/inspect" rel="noopener noreferrer"&gt;AcroForm fields&lt;/a&gt; server-side on the edge. From Python it is one request — no &lt;code&gt;pdftk&lt;/code&gt;, no &lt;code&gt;poppler&lt;/code&gt;, nothing to install beyond &lt;code&gt;requests&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;template.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://pdfops.dev/api/fill-form&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fields&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Acme Co&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice_total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$1,250.00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;paid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Yes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;})},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filled.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The field names in the dict must match the AcroForm field names in the template. If you are not sure what those are, drop the PDF into the &lt;a href="https://pdfops.dev/tools/inspect" rel="noopener noreferrer"&gt;Form-Field Inspector&lt;/a&gt; — it lists every field name and type, so your keys line up on the first try. No API key or signup is required during beta.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing it natively: pypdf and fillpdf
&lt;/h2&gt;

&lt;p&gt;The pure-Python route is real and worth knowing. &lt;strong&gt;pypdf&lt;/strong&gt; fills AcroForm fields without any system binaries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pypdf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PdfReader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PdfWriter&lt;/span&gt;

&lt;span class="n"&gt;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PdfReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;template.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;writer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PdfWriter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_page_form_field_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pages&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Acme Co&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice_total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$1,250.00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;auto_regenerate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&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;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filled.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works, and for many templates it is all you need. The friction shows up at the edges: some viewers won’t render filled values until you set the &lt;code&gt;NeedAppearances&lt;/code&gt; flag or regenerate appearance streams; checkboxes and radio groups need the exact on-state name, not &lt;code&gt;True&lt;/code&gt;; and embedded-font edge cases can drop characters. None of these are dealbreakers — they are simply PDF-internals work you now own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;fillpdf&lt;/strong&gt; wraps &lt;code&gt;pdfrw&lt;/code&gt; with a friendlier API and can flatten, but flattening pulls in &lt;code&gt;pdftk&lt;/code&gt; (and often &lt;code&gt;poppler&lt;/code&gt;), which is the usual snag inside containers, Lambda, and other serverless runtimes where shipping system binaries is awkward.&lt;/p&gt;

&lt;h2&gt;
  
  
  When PDFops fits, when a local library fits
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;PDFops (HTTP)&lt;/th&gt;
&lt;th&gt;pypdf / fillpdf (local)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Native dependencies&lt;/td&gt;
&lt;td&gt;None — just &lt;code&gt;requests&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;pypdf: none; fillpdf: pdftk/poppler to flatten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serverless / edge friendly&lt;/td&gt;
&lt;td&gt;Yes — nothing to provision&lt;/td&gt;
&lt;td&gt;pypdf: yes; fillpdf: painful (binaries)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network call required&lt;/td&gt;
&lt;td&gt;Yes — one POST per fill&lt;/td&gt;
&lt;td&gt;No — fully local/offline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Appearance-stream quirks&lt;/td&gt;
&lt;td&gt;Handled server-side&lt;/td&gt;
&lt;td&gt;You own NeedAppearances, fonts, checkboxes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Merge in the same tool&lt;/td&gt;
&lt;td&gt;Yes — &lt;a href="https://pdfops.dev/docs/merge" rel="noopener noreferrer"&gt;/api/merge&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;pypdf can merge; fillpdf cannot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Determinism&lt;/td&gt;
&lt;td&gt;Deterministic, audit-safe&lt;/td&gt;
&lt;td&gt;Deterministic (no AI either)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Serverless backends, no-binary stacks, fill+merge at scale&lt;/td&gt;
&lt;td&gt;Offline jobs, no-network constraints, full local control&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest summary: if your code already runs somewhere you control with no network constraint and you don’t mind owning PDF internals, &lt;strong&gt;pypdf is a fine, free, dependency-light choice&lt;/strong&gt;. If you are on serverless/edge, want fill and merge behind one deterministic API, or simply don’t want to debug appearance streams, the HTTP call earns its keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Merging PDFs in Python too
&lt;/h2&gt;

&lt;p&gt;The same primitive merges. Concatenate several PDFs into one in a single call:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="n"&gt;files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pdfs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;b.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;c.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://pdfops.dev/api/merge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merged.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A common shape is fill-then-merge: fill several AcroForm templates from your Python backend, then merge the results into one document to deliver. Both halves are the same deterministic primitive, so the combined output is reproducible end to end. The endpoint details are in &lt;a href="https://pdfops.dev/docs/fill-form" rel="noopener noreferrer"&gt;the fill-form docs&lt;/a&gt; and &lt;a href="https://pdfops.dev/docs/merge" rel="noopener noreferrer"&gt;the merge docs&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;How do I fill a PDF form in Python?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Either drive a native library (pypdf, fillpdf) that writes AcroForm values from Python, or POST the template plus a JSON map of field values to an HTTP API and get the filled PDF back. The native route is fully local; the HTTP route needs no system binaries, which is why it suits serverless and edge runtimes where installing pdftk or poppler is painful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I use pypdf or PDFops?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;pypdf when you want zero network calls and full local control and are happy handling appearance-stream quirks yourself. PDFops when you are on serverless/edge with no easy way to ship binaries, want one deterministic API for fill &lt;em&gt;and&lt;/em&gt; merge, or would rather not own the PDF-internals edge cases. Many teams prototype with pypdf and move the hot path to the API once the quirks cost time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I fill a PDF in Python without pdftk or poppler?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. pypdf is pure Python and needs no binaries to fill. fillpdf needs pdftk/poppler to flatten, which is the usual container/serverless snag. The PDFops API needs nothing installed locally — the fill runs server-side — so it is a common pick exactly when pdftk and poppler are awkward to provision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the fill deterministic?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes — the same template plus the same field values yields the same field-level output, with no AI inference in the path. Outputs are diffable and audit-safe, which matters for regulated documents where identical inputs must produce identical PDFs. The longer argument is in &lt;a href="https://pdfops.dev/blog/deterministic-pdf-filling" rel="noopener noreferrer"&gt;this essay&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if my PDF has no form fields?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Filling requires AcroForm fields to exist. If a template has none, add them once in Acrobat, Mac Preview, LibreOffice Draw, or pdftk, then fill that template repeatedly. The free &lt;a href="https://pdfops.dev/tools/inspect" rel="noopener noreferrer"&gt;Inspector&lt;/a&gt; lists the field names a PDF exposes so your Python dict keys match them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it in 30 seconds
&lt;/h2&gt;

&lt;p&gt;No API key, no signup during beta. Fill a real template right now with the snippet above, or explore fields visually in the &lt;a href="https://pdfops.dev/playground" rel="noopener noreferrer"&gt;playground&lt;/a&gt;. Filling from another stack? See &lt;a href="https://pdfops.dev/fill-pdf-in-node" rel="noopener noreferrer"&gt;fill a PDF in Node&lt;/a&gt; and &lt;a href="https://pdfops.dev/fill-pdf-in-javascript" rel="noopener noreferrer"&gt;fill a PDF in JavaScript&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If the deterministic fill + merge primitive fits your usage, &lt;a href="https://pdfops.dev/#waitlist" rel="noopener noreferrer"&gt;join the waitlist&lt;/a&gt; and tell me the forms you fill most — that signal is what the pricing tiers and the in-function library get built around.&lt;/p&gt;

</description>
      <category>python</category>
      <category>pdf</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The case for deterministic PDF filling</title>
      <dc:creator>PDFops</dc:creator>
      <pubDate>Sat, 13 Jun 2026 05:28:20 +0000</pubDate>
      <link>https://dev.to/pdfops/the-case-for-deterministic-pdf-filling-2oo0</link>
      <guid>https://dev.to/pdfops/the-case-for-deterministic-pdf-filling-2oo0</guid>
      <description>&lt;p&gt;AI can read almost any document now. The harder question is what&lt;br&gt;
writes the answer back — and for anything an auditor might ever&lt;br&gt;
look at, that write step should not be a language model.&lt;/p&gt;
&lt;h2&gt;
  
  
  A document workflow has two halves
&lt;/h2&gt;

&lt;p&gt;Most real document automation is a loop: &lt;strong&gt;read&lt;/strong&gt; data out of one document, then &lt;strong&gt;write&lt;/strong&gt; it into another. Read a scanned invoice, write the numbers into your ledger. Read an onboarding packet, write the values into a W-9. Read a claim, write an ACORD form.&lt;/p&gt;

&lt;p&gt;The &lt;em&gt;read&lt;/em&gt; half is having its moment. Vision-language models are genuinely good at pulling structured data out of messy, never-before-seen documents, and a wave of strong APIs — Extend, Reducto, LlamaParse, the hyperscalers’ document-AI services — have made it a solved-enough problem. If you need to understand an arbitrary PDF, reach for one of those.&lt;/p&gt;

&lt;p&gt;The &lt;em&gt;write&lt;/em&gt; half is a different problem with a different failure mode — and it’s the half people are quietly bolting an LLM onto because it’s adjacent. That’s the mistake.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why an LLM shouldn’t fill your W-9
&lt;/h2&gt;

&lt;p&gt;A model that fills a form “mostly” right is worse than useless on the documents that matter. It can misread a field label, conflate two values, or put the correct number in the wrong box. On a marketing one-pager, who cares. On a 1099, an insurance ACORD form, a healthcare pre-authorization, a tax filing — that’s not a typo, it’s a compliance incident.&lt;/p&gt;

&lt;p&gt;And here’s the part that doesn’t get said enough: &lt;strong&gt;if a filled value can’t be traced to a deterministic rule, it can’t be defended in an audit.&lt;/strong&gt; “The model was 97% confident” is not an answer when a regulator asks why field 14b says what it says. A probabilistic write step turns every filled form into something you have to &lt;em&gt;trust&lt;/em&gt; rather than &lt;em&gt;verify&lt;/em&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Determinism is a feature, not a limitation
&lt;/h2&gt;

&lt;p&gt;A deterministic fill is boring on purpose: field &lt;code&gt;customer_name&lt;/code&gt; maps to value &lt;code&gt;"Acme Co"&lt;/code&gt;, every single time, and you can point at the exact mapping that produced it. Same input, same output, forever — reviewable, diffable, testable, defensible.&lt;/p&gt;

&lt;p&gt;The tell is that even the AI-fill vendors know this. The same platforms shipping “fill any form with AI” also ship a deterministic, template-based mode — precisely because the instruction/LLM mode isn’t trusted for the forms where being wrong is expensive. When the stakes are real, everyone reaches for the deterministic path.&lt;/p&gt;
&lt;h2&gt;
  
  
  The write step the AI wave actually needs
&lt;/h2&gt;

&lt;p&gt;The clean architecture isn’t “AI does everything.” It’s a division of labor that matches each half to the right tool:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Extract with AI&lt;/strong&gt; — probabilistic, flexible, great for unseen and messy documents. This is where the model earns its keep.&lt;br&gt;
&lt;strong&gt;Fill deterministically&lt;/strong&gt; — a template plus a JSON of &lt;code&gt;field → value&lt;/code&gt;, applied exactly, with no model anywhere in the fill path. The output is auditable by construction.&lt;/p&gt;

&lt;p&gt;That second step is what PDFops is. You hand it an AcroForm template and a JSON object; it fills the fields exactly as specified, merges the result with any other PDFs you need, and returns the bytes — running on the V8 edge, no headless browser, no model in the loop. It’s the deliberately boring write hand that the clever AI read step can hand off to.&lt;/p&gt;
&lt;h2&gt;
  
  
  When you &lt;em&gt;should&lt;/em&gt; reach for AI fill
&lt;/h2&gt;

&lt;p&gt;To be fair to the other side: if you’re filling arbitrary, never-seen forms with no template — a long tail of one-off PDFs you can’t pre-map — a vision model is the only thing that works, and the AI-fill APIs are good at it. The deterministic path assumes you have, or can make, a template for the form.&lt;/p&gt;

&lt;p&gt;But most of what businesses actually fill is &lt;em&gt;not&lt;/em&gt; a long tail. It’s the same few dozen recurring, regulated, high-stakes forms — tax, insurance, HR, healthcare, real estate — over and over. For those, you already have the template, and the right write step is the deterministic one.&lt;/p&gt;
&lt;h2&gt;
  
  
  See it on your own PDF
&lt;/h2&gt;

&lt;p&gt;The fastest way to feel the difference: drop one of your form PDFs into the &lt;a href="https://pdfops.dev/tools/inspect" rel="noopener noreferrer"&gt;Form-Field Inspector&lt;/a&gt;. It lists every AcroForm field — name, type, options — and hands you the exact &lt;code&gt;fields&lt;/code&gt; JSON and API call to fill it. No signup, no model, no guessing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://pdfops.dev/api/fill-form &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"pdf=@w9-template.pdf"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;'fields={"name":"Acme Co","tin":"12-3456789","tax_classification":"C Corporation"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; filled.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same fields in, same PDF out, every run. If that’s the write step your pipeline needs, the &lt;a href="https://pdfops.dev/docs/fill-form" rel="noopener noreferrer"&gt;fill-form docs&lt;/a&gt; are the next stop — and the &lt;a href="https://pdfops.dev/#waitlist" rel="noopener noreferrer"&gt;waitlist&lt;/a&gt; is where to tell me about your volume and the forms you fill most.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pdfops.dev/" rel="noopener noreferrer"&gt;← PDFops home&lt;/a&gt; · &lt;a href="https://pdfops.dev/blog" rel="noopener noreferrer"&gt;Blog&lt;/a&gt; · &lt;a href="https://pdfops.dev/tools/inspect" rel="noopener noreferrer"&gt;Field Inspector&lt;/a&gt;&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>webdev</category>
      <category>api</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
