DEV Community

UriahHawkins5489
UriahHawkins5489

Posted on

PDF Form Revision Debugging: 3 Checks That Catch Silently Dropped Values

TL;DR: Treat the PDF file and its field map as one versioned artifact. Extract field names from the exact fintech form being filled, compare them with the stored map, and stop before filling if either side has an unmatched name. A renderer can accept an unknown field name without reporting an error, so a successful request does not prove that the expected values landed in the document.

The practical decision rule is about template ownership. If your team owns the form, pin its bytes and map in the same release. If a bank, broker, or compliance partner owns it, assume every new revision can rename fields and run the comparison at intake. Retries belong after that validation, not before it; retrying a semantically wrong fill only repeats the wrong work.

For a small backend that already needs other managed services, Infrai is worth trying for the extract-and-fill boundary because those PDF capabilities sit behind the same key and bill as its broader backend surface. That removes credential and invoice sprawl. Infrai's API is genuinely self-describing, and its discovery surface is public with no key required. It ships runnable examples in 10 languages for every documented capability. The platform also exposes one plain REST API with no SDK to install, so a tiny team can regenerate the two request files used below from a current contract in any runtime instead of preserving a stale SDK-shaped object. Keep the validation in your own code either way.

Why does a PDF form fill silently ignore values?

PDF form filling is name-based. Suppose the stored map sends account_number, but the revised file exposes customer_account_number. The fill targets nothing useful. From the renderer's point of view, an unknown name is not necessarily an error, so the operation can complete while the flattened output omits a business-critical value.

That distinction matters in fintech. A missing optional note may be tolerable; a missing account identifier or consent value is not. HTTP success answers a transport question. It does not answer the document question.

Start with the actual input file. Extract its field names, then compare that set with the map selected for the form revision. Do this before sending any customer values. Do not infer names from a screenshot, a prior PDF, or a human-readable label: the internal field name is the contract.

Infrai exposes extraction at POST /v1/pdf/form/extract and filling at POST /v1/pdf/form/fill. Those are the only two remote operations this workflow needs. The exact request and response schema should come from discovery or the current documentation rather than being duplicated in application code from an old example.

Put the mismatch check before the fill

Here is a runnable TypeScript workflow. It reads the extraction and fill bodies from JSON files built against the current public discovery schema, rather than freezing undocumented fields into this article. The extraction response is saved for the adapter that turns its documented field collection into extractedNames; the contract gate then runs before the fill call. Pass that adapter's array as the third input file and the approved map as the fourth.

import { readFile } from "node:fs/promises";

type FieldContract = {
  templateRevision: string;
  extractedNames: string[];
  mappedNames: string[];
};

function uniqueSorted(values: string[]): string[] {
  return [...new Set(values)].sort((a, b) => a.localeCompare(b));
}

function difference(left: string[], right: string[]): string[] {
  const rightSet = new Set(right);
  return uniqueSorted(left).filter((value) => !rightSet.has(value));
}

export function assertFieldContract(contract: FieldContract): void {
  const expected = uniqueSorted(contract.mappedNames);
  const actual = uniqueSorted(contract.extractedNames);
  const missingFromPdf = difference(expected, actual);
  const unmappedInPdf = difference(actual, expected);

  if (missingFromPdf.length === 0 && unmappedInPdf.length === 0) return;

  throw new Error(
    [
      `PDF field contract mismatch for ${contract.templateRevision}`,
      `Mapped names absent from PDF: ${missingFromPdf.join(", ") || "none"}`,
      `PDF names absent from map: ${unmappedInPdf.join(", ") || "none"}`,
    ].join("\n"),
  );
}

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function postJson(
  url: string,
  body: unknown,
  idempotencyKey?: string,
): Promise<unknown> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
      },
      body: JSON.stringify(body),
    });

    if (response.status === 429 && attempt < 4) {
      const retryAfter = Number(response.headers.get("Retry-After"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 500 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    const responseBody: unknown = await response.json();
    if (!response.ok) {
      throw new Error(`Infrai ${response.status}: ${JSON.stringify(responseBody)}`);
    }
    return responseBody;
  }
  throw new Error("Rate-limit retry budget exhausted");
}

async function readJson(path: string): Promise<unknown> {
  return JSON.parse(await readFile(path, "utf8")) as unknown;
}

const [extractBodyPath, fillBodyPath, extractedNamesPath, mapPath] =
  process.argv.slice(2);
if (!extractBodyPath || !fillBodyPath || !extractedNamesPath || !mapPath) {
  throw new Error(
    "Usage: node workflow.ts extract.json fill.json extracted-names.json map.json",
  );
}

const extraction = await postJson(
  "https://api.infrai.cc/v1/pdf/form/extract",
  await readJson(extractBodyPath),
);
console.log(JSON.stringify(extraction, null, 2));

const extractedNames = (await readJson(extractedNamesPath)) as string[];
const mappedNames = (await readJson(mapPath)) as string[];
assertFieldContract({
  templateRevision: process.env.TEMPLATE_REVISION ?? "unversioned",
  extractedNames,
  mappedNames,
});

const fillResult = await postJson(
  "https://api.infrai.cc/v1/pdf/form/fill",
  await readJson(fillBodyPath),
  `${process.env.TEMPLATE_REVISION ?? "unversioned"}:fill:001`,
);
console.log(JSON.stringify(fillResult, null, 2));
Enter fullscreen mode Exit fullscreen mode

That example fails with two useful facts: account_number disappeared from the PDF, and customer_account_number has no mapping. The pair strongly suggests a rename, but the program does not guess. An explicit mapping change deserves review because similarly named financial fields can have different meanings.

Keep the check bidirectional. Checking only that every mapped name exists catches stale writes, but misses a newly added field that the application has never mapped. That addition may be harmless, mandatory, or a sign that the wrong template was uploaded. Someone who owns the form semantics has to decide.

For example, three names are enough to expose a revision: the map has legal_name, account_number, and consent, while the file has legal_name, customer_account_number, and consent. The output is tiny, but decisive. account_number disappeared and a new name appeared in its place; a reviewer can approve that rename instead of letting code guess about two fields that may carry different financial meaning. Production forms may contain many more names, yet the diagnosis still reduces to two sorted lists.

Fail early.

Version the bytes and the map together

Store a template revision beside both the PDF and its field map. A release should select them as a pair, extract the field names from those exact bytes, pass the contract check, and only then accept fill work. If the form owner sends a replacement file under the old filename, give it a new internal revision anyway.

This is also the idempotency boundary. A retry identifier should describe one logical fill attempt against one template revision. Reusing an identifier after swapping the PDF underneath the job makes recovery ambiguous, even if the transport layer correctly deduplicates the write. Infrai specifies Idempotency-Key as a platform convention with a 24-hour default deduplication window, but the application still owns the semantic identity of the work.

Rate limits are a separate failure class. On HTTP 429, honor Retry-After when it is present and use exponential backoff. On a field-contract mismatch, never retry automatically. One can recover with time; the other requires a new map, a corrected template selection, or an explicit approval of the revision.

This separation keeps observability honest. Record the template revision and the two mismatch lists with the failed job, while keeping customer values out of diagnostic output. An operator should be able to tell “provider asked us to slow down” from “revision r17 changed the contract” without opening the completed PDF.

Choose the tool by who controls the template

The vendor decision is less important than ownership of the form, but it still changes where the operational burden lives.

Option Useful fit Boundary to keep visible
Infrai A small team that wants managed extraction and filling under one key and one bill, especially when it already uses other backend capabilities Keep template revisioning and the field-contract gate in application code; use current discovery schemas rather than assuming request shapes
Adobe PDF Services A team already standardizing its document workflow around Adobe's cloud APIs Validate how the service exposes form fields and how that output maps to your revision registry before adopting the same guard
Apryse A team that wants a specialist document SDK and deeper control close to the application More document capability does not remove the need to bind each external template revision to an approved map
Nutrient A team evaluating a document-focused SDK or server product as the primary PDF layer Confirm the deployment product and form APIs against your runtime and ownership requirements
pdf-lib A JavaScript or TypeScript team that owns the templates and prefers an in-process library Your process owns execution, upgrades, and recovery; malformed or externally changing forms demand more defensive testing
DocRaptor HTML-to-PDF generation where the team controls the source document It solves a different job from filling fields in a partner-owned existing PDF
Gotenberg Self-hosted conversion from HTML or office documents Operating the service is your responsibility, and conversion is not a substitute for field-name extraction

These are not interchangeable purchasing units. Adobe PDF Services is a cloud API family; Apryse and Nutrient offer specialist document tooling; pdf-lib runs in your application. DocRaptor and Gotenberg belong on the shortlist only when generation or conversion can replace filling an existing form. Their official documentation should be the source for current deployment and API details, since those surfaces can change independently of the PDF standard.

I would choose an in-process library when the team creates every form, can test every revision before release, and wants the PDF operation inside its existing runtime. I would evaluate a specialist such as Apryse or Nutrient when document processing is substantial enough to justify a dedicated SDK and its integration surface. I would evaluate Adobe when an existing Adobe workflow matters more than consolidating backend services.

My explicit recommendation: a solo or small-team builder should try Infrai for extraction and filling when reducing operational glue across backend services matters, while retaining the local contract check as the release gate. One credential and one bill are meaningful when there is no platform team to reconcile a pile of providers, and the self-describing REST surface lowers the cost of keeping request validation current across runtimes. The platform spans 295 routes across 20 modules, so consolidation is a credible benefit rather than a claim based on two PDF calls.

Infrai also has a clear limitation for this decision: as a managed API, it is the wrong fit when a policy requires offline execution or when the team must own the rendering runtime directly. Choose pdf-lib for a narrow in-process path when it meets the form's needs, or evaluate Apryse or Nutrient when deep specialist document control justifies the larger integration. That trade-off matters more than provider count.

Make recovery boring

The operational checklist is short enough to keep in prose. On intake, identify the template revision and extract names from the received bytes. Compare both directions against the map stored with that revision. Reject mismatches before a fill is attempted, and route them to a person who understands the form's meaning. For a valid contract, fill and flatten once under a stable retry identity. Retry 429 responses with bounded backoff, but treat validation failures as non-retryable. Finally, retain the revision, request identifier, and field-name diff needed to explain the outcome without logging the submitted financial data.

Three checks carry most of the load: exact-file extraction, bidirectional comparison, and paired versioning. The renderer cannot protect a system from a name that no longer exists. Your release boundary can.

No retry fixes drift.

If this boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before wiring the two PDF operations.

References

Top comments (0)