Guide

You fill a PDF's line items, and the total field comes back blank

An invoice template has qty_1, price_1, and a total field that Acrobat fills in on its own whenever you type a number into the other two. Fill the same form with pdf-lib or pypdf and send back only qty_1 and price_1, and total comes back exactly as empty as the blank template. No error, no warning header, just a missing number on an invoice someone is about to pay.

A calculated field is a spreadsheet cell, not a label

When a form author in Acrobat marks a field as calculated, what actually gets written into the PDF is a small JavaScript action attached to that field's /AA (additional actions) dictionary, plus an entry in the AcroForm's /CO (calculation order) array that tells every viewer which fields to recompute and in what order. The field looks like plain text in a viewer, but underneath it carries a line like event.value = this.getField("qty_1").value * this.getField("price_1").value;, and Acrobat runs that line every time a dependency changes.

Why pdf-lib and pypdf leave it alone

Running that line needs a JavaScript interpreter wired up to the document's field model, and neither pdf-lib nor pypdf ships one. The fill path in PDFops' own stack is pdf-lib underneath, and it does exactly what the library gives it: write the string you sent into a field's /V entry, then redraw that field's appearance stream from whatever /V now holds. A calculated field you never mentioned has nothing new in /V, so the appearance redraw paints back the same value the template shipped with, usually a blank string or a literal 0.00. Nothing throws, because from the library's point of view every field it was asked to fill was filled correctly.

Finding which fields are calculated before you fill

The /CO array is the fastest way to answer "which fields does this template expect something else to compute." Walking it needs the same low-level dictionary access /api/inspect already uses to detect a hybrid XFA layer:

import { PDFDocument, PDFDict, PDFName, PDFArray } from "pdf-lib";

async function findCalculatedFields(pdfBytes) {
  const pdfDoc = await PDFDocument.load(pdfBytes);
  const acroDict = pdfDoc.catalog.lookup(PDFName.of("AcroForm"));
  if (!(acroDict instanceof PDFDict)) return [];

  const order = acroDict.lookup(PDFName.of("CO"));
  if (!(order instanceof PDFArray)) return []; // no calculated fields at all

  const names = [];
  for (let i = 0; i < order.size(); i++) {
    const fieldDict = pdfDoc.context.lookup(order.get(i), PDFDict);
    const title = fieldDict?.lookup(PDFName.of("T"));
    if (title) names.push(title.decodeText());
  }
  return names; // Acrobat recomputes these; your filler will not
}

Any name this returns is a field your own code needs to compute, because nothing downstream of it will.

Computing the total yourself, before the fill call

Once a field is known to be calculated, the fix is to treat it like any other derived value in application code and send it explicitly alongside the inputs it depends on, rather than leaving it out and hoping something fills it in later.

const items = [
  { qty: 2, price: 450 },
  { qty: 1, price: 900 },
];
const total = items.reduce((sum, item) => sum + item.qty * item.price, 0);

const fields = {
  qty_1: String(items[0].qty),
  price_1: items[0].price.toFixed(2),
  qty_2: String(items[1].qty),
  price_2: items[1].price.toFixed(2),
  total: total.toFixed(2), // computed here; the PDF will not compute it
};

await fetch("https://pdfops.dev/api/fill-form", {
  method: "POST",
  headers: { "X-API-Key": apiKey },
  body: toMultipart(pdfBytes, fields),
});

total now lands in /V the same way qty_1 and price_1 do, so the appearance redraw paints the real number instead of the template's default.

Try it

A free key from /docs/signup includes 250 calls a month, enough to run /api/fill-form against a real invoice template and check the total field by eye before any customer-facing number depends on it.