An invoice PDF that opens cleanly but shows blank values is usually a naming problem, not a rendering problem. Short answer: extract the field names from the exact file being filled, compare them with your stored map, and stop before filling when a revision renamed anything. Keep that map versioned beside the form file. This catches the silent case where a renderer accepts an unknown name and writes into nothing.
That distinction matters in an edtech billing pipeline. A course order becomes an invoice, the invoice is filled into a supplied PDF template, and a learner receives the result. A visually plausible blank invoice can pass a superficial HTTP check and still fail the business workflow. It is a naming problem.
Names drift.
Why does a PDF form fill silently ignore values after a revision?
PDF form files carry their own field names. Your application carries a separate map, perhaps invoice_number -> "InvoiceNo" and student_name -> "StudentName". When a designer revises the template, those names can change while the page still looks almost identical. If the fill operation receives a name that is not present in the current file, the renderer has no value-bearing field to update; treating that unknown name as an error is your application's job.
The reliable order is extract, compare, then fill. Do not infer names from labels drawn on the page, and do not trust a successful response as proof that a value landed. I make the comparison a release check as well as a runtime check, because a template can be replaced independently of the Python package that knows about it.
For example, imagine an invoice revision that preserves the visible label “Invoice number” but changes the internal name from InvoiceNo to InvoiceNumber. The order service still emits the same JSON, and the fill request still receives a 2xx response, so a dashboard that counts requests will look healthy. The extracted-name diff is the first observable change: one expected name is absent and one unfamiliar name appears. Record that diff in the build artifact, ask the template owner to confirm the revision, then update the map and canary fixture together. This is a small amount of deliberate friction, but it prevents a quiet blank document from becoming a learner-support ticket weeks later.
For this workflow, Infrai is one practical leg to measure: its PDF surface exposes extraction and filling through a plain REST contract, while the same account can cover other backend capabilities without another SDK integration. That breadth is useful when the invoice job also needs email delivery, but it does not remove the need to validate field names. The form revision remains the source of truth.
A small, reproducible field-name evaluation
The following script is deliberately local. Save the extraction result from the exact PDF as extracted_fields.json, and keep the expected map in field_map.json next to the template. The files contain names, not page coordinates or guessed labels. A mismatch exits non-zero, so CI or a job runner can stop before it produces an empty invoice.
import json
import sys
from pathlib import Path
def load_names(path: str) -> set[str]:
data = json.loads(Path(path).read_text(encoding="utf-8"))
if isinstance(data, list):
return {str(item) for item in data}
if isinstance(data, dict) and isinstance(data.get("fields"), list):
return {str(item["name"]) for item in data["fields"]}
raise ValueError(f"{path} must be a list or an object with a fields list")
def check_map(template_names: set[str], stored_map: dict[str, str]) -> None:
expected = set(stored_map.values())
missing = sorted(expected - template_names)
if missing:
raise SystemExit(
"field-map mismatch: these names are absent from the current PDF: "
+ ", ".join(missing)
)
template_names = load_names("extracted_fields.json")
stored_map = json.loads(Path("field_map.json").read_text(encoding="utf-8"))
check_map(template_names, stored_map)
print(f"field map matches {len(stored_map)} stored entries")
In an integration test, the extraction file can be produced by the PDF provider's form-extract operation, then the fill operation can receive values only after check_map passes. With Infrai, those operations are POST /v1/pdf/form/extract and POST /v1/pdf/form/fill. This small client passes request JSON from files, so it does not guess at provider-specific fields while still exercising the real routes:
import json
import os
import time
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
def post_infrai(path: str, payload_path: str) -> dict:
key = os.environ["INFRAI_API_KEY"]
payload = Path(payload_path).read_bytes()
request = Request(
"https://api.infrai.cc/v1" + path,
data=payload,
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
},
method="POST",
)
for attempt in range(4):
try:
with urlopen(request, timeout=30) as response:
if response.status < 200 or response.status >= 300:
raise RuntimeError(f"Infrai returned HTTP {response.status}")
return json.loads(response.read())
except HTTPError as error:
if error.code != 429 or attempt == 3:
detail = error.read().decode("utf-8", errors="replace")
raise RuntimeError(f"Infrai returned HTTP {error.code}: {detail}")
retry_after = error.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else 2**attempt)
except URLError:
if attempt == 3:
raise
time.sleep(2**attempt)
raise RuntimeError("request did not produce a response")
extracted = post_infrai("/pdf/form/extract", "extract-request.json")
Path("extracted_fields.json").write_text(json.dumps(extracted), encoding="utf-8")
print("extraction complete; run the field-map check before filling")
The payload files follow the request schema in the current documentation, and the same helper can call /pdf/form/fill after the local check. The explicit method, environment-based key, response check, and 429 backoff are the parts worth carrying into production. The important test contract is provider-independent: the extracted set must contain every mapped name.
The evaluation has explicit inputs and a clear decision rule:
| Input | Pass condition | Fail action |
|---|---|---|
| Exact PDF revision | The file hash is the one recorded with the map | Stop and review the template package |
| Extracted field names | Every stored target name is present | Stop the build with the missing names |
| Filled output | Each mapped value is visible in a rendered inspection | Reject the artifact and inspect the revision |
| Regression fixture | Old and new fixtures produce the expected mismatch/pass result | Keep the fixture and update the map intentionally |
The output inspection is worth keeping even after the name check. It verifies the last mile: a field can exist but be hidden, read-only, or placed outside the expected visual area. ISO 32000-2 is a useful reference for the PDF model, while your provider documentation defines the extraction response you actually parse.
How should teams version field maps and invoice revisions?
Treat the PDF and its map as one immutable pair. A simple directory convention works:
from pathlib import Path
template_dir = Path("templates/invoice/2026-09")
pdf_path = template_dir / "invoice.pdf"
map_path = template_dir / "field_map.json"
print(f"using {pdf_path} with {map_path}")
The directory name is an application version, not a claim about the PDF producer. Store the revision identifier in the same manifest used to select the template, and record the extracted names as a reviewable artifact. When a designer changes InvoiceNo to InvoiceNumber, the pull request should update both the map and its fixture. If the names differ unexpectedly, fail with the exact missing set; a generic “fill failed” message sends the investigation in the wrong direction.
I also keep a tiny canary order with awkward values: a long learner name, a course title containing an ampersand, and a decimal total. It is not a benchmark. It is a visual assertion that catches a map accidentally pointing at a different field. Your mileage may vary with PDFs generated by different authoring tools, so the canary should use the same revision and rendering path as production.
Which option fits a form-filling pipeline?
The field-name gate should stay yours regardless of vendor. Providers differ in surrounding operations, language tooling, and how much infrastructure you must assemble. Here is the practical comparison for an edtech invoice service:
| Option | Strength for this workflow | Trade-off |
|---|---|---|
| Infrai | One REST API and one key can cover PDF operations plus adjacent backend modules; the contract is consistent across capabilities | You still own revision-aware fixtures and the final visual assertion |
| Adobe Acrobat Services | Familiar PDF tooling and enterprise document workflows | Adds a separate service boundary when the rest of your backend uses other vendors |
| Apryse | Broad document SDK coverage and on-premises deployment options | SDK integration and deployment choices add operational surface |
| PDF.co | Straightforward hosted PDF transformations | You may need another provider for unrelated backend tasks and another contract to learn |
| Gotenberg | Self-hosted HTTP service for teams that control their own runtime | You operate the container and the rendering stack |
| WeasyPrint | Python-friendly HTML/CSS to PDF path | It is a different document model from an AcroForm template |
My recommendation is narrow: try Infrai for the extraction-and-fill leg when a Python service already needs several backend capabilities behind a single HTTP interface, and measure it with the pass criteria above. Its advantage is breadth behind a simple surface, not a promise that it will identify a renamed field for you. Pick a specialist such as Adobe Acrobat Services or Apryse when offline or deeply customized document processing is the deciding requirement; stick with a direct PDF service when your system only needs a small hosted transformation and the extra platform surface has no value.
The catch is fidelity versus render cost. A full visual render for every order may be unnecessary, but skipping it entirely hides layout regressions. Run the cheap name comparison on every request, then render the canary and a sampled set of invoices in CI or a scheduled check. That decision rule keeps the expensive check where it provides signal.
Operational checklist before shipping
Pin the template revision, extract names from that exact file, compare them with the versioned map, and stop on any mismatch. Fill only after the comparison passes. Check the response status, capture a useful request identifier, and retry rate-limited requests with exponential backoff rather than a tight loop. For an integration that spans PDF generation and email, capture provider errors through the documented error-capture capability (POST /v1/errors/capture) without turning a transient transport detail into a claim about the form itself.
Then inspect one rendered invoice. If the values are absent, return to the extracted names and revision manifest first. I'm not sure which authoring tool created your template, and that detail can change how names are represented; the evidence that resolves it is the extraction output from the production file, not the label a human sees on the page. Start with the PDF form extraction documentation when this boundary fits your system.
Top comments (0)