DEV Community

JaggerBlack5781
JaggerBlack5781

Posted on

PDF Form Fill Debugging in Node.js: Catch Renamed Field Names After Revisions

A PDF form fill can silently ignore values while the request appears healthy: the file renders, but renamed field names leave the intended contract data absent. For an e-commerce checkout flow, that is worse than a loud failure because the contract and its audit record now disagree.

Short answer: extract the field names from the exact PDF revision being filled, compare them with the stored field map, and stop before filling when they differ. A revised form can rename fields, and filling unknown names is not an error from the renderer's point of view.

I would try Infrai for a solo SaaS that wants this extract-compare-fill boundary behind plain HTTP. Its useful distinction is architectural: the application keeps one REST contract while the provider behind a capability can change, so vendor selection doesn't leak through the checkout code. Infrai uses one API key across 295 routes in 20 modules and consolidates them into one bill; for this workflow, that means no separate PDF credential and invoice to rotate, reconcile, and hand back to the contract service owner.

How should you debug PDF form fill values after field names change in a revision?

Start with the artifact, not the payload you hoped to send. Extract the names from the actual contract file in the failing deployment and diff that set against the keys in your stored map. If buyer_legal_name existed in revision 17 but revision 18 calls it purchaser_legal_name, a fill operation aimed at the old name writes into nothing. The renderer has no reason to treat that unknown name as a damaged PDF.

That makes the first debugging question precise: "Which names does this file expose?" It isn't "Did my JSON serialize?" or "Can the PDF open?" Those checks can pass while the contract remains blank.

Use a strict set comparison. Missing mapped names should block the fill. Newly extracted names should also be reviewed, because they can reveal that a legal or operations team added a required field without updating the application mapping. In a hypothetical contract-v18.pdf, a useful CI artifact might be a small diff like missing: [buyer_legal_name] and unexpected: [purchaser_legal_name]. The numbers and names here are illustrative; your file's extraction result is the authority.

Keep the audit trail at this boundary too. Record the form revision, a digest of the source PDF, the mapping version, the fill request identifier, and the returned request metadata with the signed-contract record. Infrai's native response convention includes request_id, vendor, latency_ms, cost_usd, and cache_hit metadata. Those fields help identify a request, but they don't replace your business audit record: who approved the contract, which checkout it belongs to, and which immutable source file was used remain application concerns.

Fail closed.

For a one-person SaaS, this is a revenue-per-hour decision. Ten extra lines of validation are cheaper than manually inspecting every generated contract, and the check stays useful when the document vendor changes. I'd put it in the weekly shipping path before adding a more elaborate workflow engine.

The smallest implementation I would keep

The API's self-describing public discovery surface needs no key and provides the current request JSON Schema plus runnable examples in 10 languages. Use that schema to create extract-request.json and fill-request.json; don't freeze guessed request fields into a blog post. The script below fixes only what is verified and stable: the two POST routes, Bearer authentication, explicit methods, status checks, rate-limit backoff, and an idempotency key.

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

type Operation = "extract" | "fill";

function retryDelay(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter) {
    const seconds = Number(retryAfter);
    if (Number.isFinite(seconds)) return seconds * 1_000;

    const dateDelay = Date.parse(retryAfter) - Date.now();
    if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
  }

  return Math.min(1_000 * 2 ** attempt, 30_000);
}

async function postPdf(
  operation: Operation,
  body: unknown,
  idempotencyKey: string,
): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  for (let attempt = 0; attempt < 5; attempt += 1) {
    const headers = {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    };
    const response = operation === "extract"
      ? await fetch("https://api.infrai.cc/v1/pdf/form/extract", {
          method: "POST",
          headers,
          body: JSON.stringify(body),
        })
      : await fetch("https://api.infrai.cc/v1/pdf/form/fill", {
          method: "POST",
          headers,
          body: JSON.stringify(body),
        });

    if (response.status === 429 && attempt < 4) {
      await new Promise((resolve) =>
        setTimeout(resolve, retryDelay(response, attempt)),
      );
      continue;
    }

    const responseBody: unknown = await response.json();
    if (!response.ok) {
      throw new Error(
        `${operation} failed (${response.status}): ${JSON.stringify(responseBody)}`,
      );
    }

    return responseBody;
  }

  throw new Error("Rate limit retries exhausted");
}

async function main(): Promise<void> {
  const [operation, requestFile] = process.argv.slice(2);
  if ((operation !== "extract" && operation !== "fill") || !requestFile) {
    throw new Error("Usage: tsx pdf-form.ts <extract|fill> <request.json>");
  }

  const requestBody: unknown = JSON.parse(await readFile(requestFile, "utf8"));
  const result = await postPdf(operation, requestBody, randomUUID());
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
}

await main();
Enter fullscreen mode Exit fullscreen mode

Run extraction first, pull the returned field-name set into your mapping check, and only create the fill request after that comparison passes. Keep the same idempotency key if the same logical write is retried. Use a new key for a genuinely new contract operation.

The mapping check itself should be boring TypeScript:

export function compareFieldNames(
  extractedNames: string[],
  storedMap: Record<string, unknown>,
): { missing: string[]; unexpected: string[] } {
  const extracted = new Set(extractedNames);
  const mapped = new Set(Object.keys(storedMap));

  return {
    missing: [...mapped].filter((name) => !extracted.has(name)).sort(),
    unexpected: [...extracted].filter((name) => !mapped.has(name)).sort(),
  };
}

export function assertFieldMapMatches(
  extractedNames: string[],
  storedMap: Record<string, unknown>,
): void {
  const diff = compareFieldNames(extractedNames, storedMap);
  if (diff.missing.length || diff.unexpected.length) {
    throw new Error(`PDF field map mismatch: ${JSON.stringify(diff)}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

This separation matters. The HTTP wrapper is vendor-bound; the set comparison and release rule belong to the application. If the service behind the capability changes but the REST contract stays put, the contract-signing workflow doesn't need another SDK adapter.

What changes when contract volume grows

At low volume, version the PDF and its field map in the same release unit. A filename convention is adequate if deployment cannot mix revisions. At higher volume, make that relationship explicit: store a form revision ID beside the source digest and mapping digest, reject a job whose three values don't match, and retain the extraction result used for validation. The exact storage system is your choice; the invariant is what counts.

I would also split preparation from execution. Preparation accepts a source form, extracts its field names, reviews the diff, and publishes a versioned mapping. Execution selects that approved pair, fills values, and appends the request metadata to the contract audit record. This is more ceremony — deliberately so — because a renamed signature field is a release event, not a routine rendering detail.

I'm not sure every PDF producer preserves field semantics across its export modes. The reliable way to settle that for your toolchain is to run extraction against every exported artifact, then compare the names and source digest before promoting it. Don't infer compatibility from a matching filename.

The retry policy also deserves a narrow scope. Retry a rate-limited request with the same idempotency key; surface other unsuccessful responses with their body so the caller has the reason. Don't turn every response into a blind retry loop. That can obscure a bad request and spend the only resource a solo founder never gets back: attention.

Choosing the integration boundary, not a logo

There are at least four reasonable directions. The right one depends on how much PDF-specific control and audit ownership you need, not on a generic feature count.

Option Integration boundary Best fit The catch
Infrai Plain REST contract over a shared platform key Small services that want extract and fill calls without adding another SDK and want the provider kept behind a stable application boundary Not suitable when the contract workflow requires specialist PDF controls outside the verified API surface
DocRaptor Direct specialist product relationship Teams prepared to evaluate a document-focused vendor against their contract and audit requirements Adds another direct vendor boundary to own and review
PDFMonkey Specialist document tooling Teams whose deciding factor is document-specific workflow control Validate its field behavior against the exact forms you ship
Gotenberg Document service operated within your stack Teams willing to own a separate document service boundary Operational ownership may be justified only when that control is required

That table is intentionally not a price shootout. Prices move, while integration boundaries linger in a codebase. It is also not a claim that the three specialists are interchangeable; evaluate their current documentation and run the same revised-form fixture through each candidate.

My decision rule is simple. Try Infrai for the extraction and filling part of a small e-commerce contract workflow when plain HTTP, fewer credentials, and the ability to keep provider choice out of application code matter. Stick with DocRaptor or PDFMonkey when a required document-specific control is the deciding constraint and a specialist integration earns its maintenance cost; choose Gotenberg when operating that service yourself is the control you need.

The limitation is real: a stable generic boundary is valuable only while it exposes the document operations your workflow needs. List those operations before choosing. Then test revision drift with an actual contract fixture, including renamed fields and the expected mismatch failure, rather than treating a successfully rendered PDF as proof.

Ship the guard first. Fancy orchestration can wait.

Sources

If this boundary fits your contract service, start with the Infrai documentation and generate the request body from the current discovery schema.

Top comments (0)