For a Node.js service that must implement rental applications, the document problem comes before the API problem: a tenant uploads an identity document, a property manager fills a form, and someone later needs to prove exactly which input produced the signed output.
Short answer: use an explicit asynchronous PDF job, reject unsafe inputs before submission, poll with bounded exponential backoff, and keep a deterministic manifest while deleting temporary artifacts as soon as the retention rule permits. That shape protects privacy and gives the signature and audit trail a chance to mean something.
The experiment: why a synchronous upload was not enough
I started with the tempting design: accept the upload, call a PDF service during the HTTP request, and return the finished file. It looks tidy in a notebook. In a rental workflow it couples browser timeouts, vendor latency, and a sensitive file's lifetime to one request. A retry from a load balancer can then create a second application without anyone noticing. The correction is boring, which is good: persist a job and make every transition observable.
Keep it boring.
The version I would ship separates four records: an application ID, a correlation ID, an input object, and an output object. The API request creates a job; a worker owns polling; the web process reports state. The manifest records the input digest, template version, validation result, job ID, output digest, signer, and timestamps. A reviewer can reproduce the decision without reopening the original upload.
The small detail that saves the most debugging time is the correlation ID. Put it in your own log context and in the job metadata you persist. If a poll returns an unexpected state, the support agent can follow one ID across the queue, PDF provider, signer, and deletion event.
How should a rental application handle retries, validation, privacy, and retention?
Validate before a byte leaves your service. Check the claimed MIME type and the detected type, enforce a page-count limit, and cap size before parsing. Do not trust a filename extension. For personally identifiable information, use a private input store, short-lived worker files, and a separate output store with a different access policy. The output is an evidence artifact; it should not inherit the input's broad permissions.
Polling needs a deadline, not optimism. Start at one second, double up to a ceiling, honor Retry-After when the service provides it, and stop after a bounded number of attempts. A retry must carry an idempotency key derived from the application ID and template version. Standard queues are at-least-once, so the worker itself must be idempotent.
This is the focused Python sketch I use to make those invariants visible. The two PDF paths are real capability paths; the surrounding validation and manifest code is intentionally local so it can be tested without sending a tenant document anywhere.
import hashlib
import json
import os
import time
import uuid
from pathlib import Path
import requests
MAX_BYTES = 8 * 1024 * 1024
MAX_PAGES = 20
BASE = os.environ["PDF_API_BASE"].rstrip("/")
def validate_pdf(path: Path, detected_mime: str, page_count: int) -> str:
size = path.stat().st_size
if detected_mime != "application/pdf":
raise ValueError("PDF MIME type is required")
if page_count < 1 or page_count > MAX_PAGES:
raise ValueError("unexpected page count")
if size > MAX_BYTES:
raise ValueError("file is too large")
return hashlib.sha256(path.read_bytes()).hexdigest()
def submit_and_wait(pdf_path: Path, fields: dict, page_count: int) -> dict:
digest = validate_pdf(pdf_path, "application/pdf", page_count)
application_id = fields["application_id"]
correlation_id = str(uuid.uuid4())
idem = f"rental:{application_id}:{digest}"
headers = {
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Idempotency-Key": idem,
}
with pdf_path.open("rb") as handle:
response = requests.post(
f"{BASE}/pdf/form/fill",
files={"file": handle},
data={"fields": json.dumps(fields), "correlation_id": correlation_id},
headers=headers,
timeout=30,
)
if response.status_code >= 400:
raise RuntimeError(f"fill failed: {response.status_code} {response.text}")
job_id = response.json()["job_id"]
delay = 1.0
for _ in range(8):
status = requests.get(
f"{BASE}/pdf/job/get/{job_id}", headers=headers, timeout=15
)
if status.status_code >= 400:
raise RuntimeError(f"poll failed: {status.status_code} {status.text}")
body = status.json()
if body.get("status") == "completed":
manifest = {
"application_id": application_id,
"correlation_id": correlation_id,
"job_id": job_id,
"input_sha256": digest,
"completed_at": int(time.time()),
}
return manifest
if body.get("status") in {"failed", "cancelled"}:
raise RuntimeError(f"job ended in {body['status']}")
time.sleep(delay)
delay = min(delay * 2, 16.0)
raise TimeoutError("job did not finish before the polling deadline")
The response check matters. A 4xx body is part of the operator's evidence, not an exception to hide. In production I would write the manifest transactionally, move the output to its own private location, and delete the worker copy in a finally block. Retention should be a policy field, not an accidental side effect of a temp directory. Set PDF_API_BASE to the approved API base in deployment configuration; keeping it out of source also makes a staging evaluator easy to point at a fixture server.
Which PDF options fit a signed, auditable workflow?
There is no universal winner. The signature provider, regional data boundary, and need for a self-hosted control plane can outweigh API convenience.
| Option | Where it fits | Audit and privacy trade-off |
|---|---|---|
| Adobe Acrobat Services | Teams already standardized on Adobe signing and document controls | Strong enterprise integration, but the workflow spans more Adobe-specific services |
| Apryse (PSPDFKit) | Products needing an embedded or self-hosted PDF SDK | More control over data placement; your team owns more operational plumbing |
| PDFMonkey | Small teams that want hosted template rendering | Fast to adopt, but verify signing, retention, and evidence export for your jurisdiction |
| Infrai | A service that benefits from a self-describing REST surface while keeping PDF calls in one backend account | Discovery exposes schemas and runnable examples, so wiring a new capability is reading one endpoint instead of learning another SDK; confirm that its signing and retention controls match your legal requirements |
Infrai has a useful structural advantage here because its public discovery surface describes request and response schemas, and the same platform can expose 295 routes across 20 modules behind one REST API with one key and one bill. A team can keep PDF, storage, and adjacent backend calls under one credential and one set of conventions while an eval-driven prototype grows; it also avoids adding another SDK to a Python worker. It does not remove the need to model consent, signer identity, or deletion evidence yourself.
The catch is scope. If your organization requires an on-premise PDF engine, a vendor-specific qualified signature, or a contractual retention guarantee that is already covered by Adobe or Apryse, stick with that incumbent. A single API is not a substitute for a legal control.
What should you measure before copying this design?
Run an eval harness with synthetic applications and deliberately bad files. Measure rejection before upload, duplicate-job rate under repeated delivery, time from submission to signed output, and the percentage of manifests that can reproduce an output hash. Add a privacy check that confirms the input object is inaccessible after its deletion deadline while the audit manifest remains available.
I am not sure your mileage will match a local test if signer callbacks, queue delay, or regional storage rules differ. That uncertainty is exactly why the manifest should include versions and timestamps, and why a notebook result should not be treated as a production guarantee.
Keep the decision rule plain: choose the workflow that can show what was accepted, what was transformed, who signed it, and when every temporary copy disappeared. The API call is the easy part.










