For a US/EU gaming SaaS, the hard part of PDF generation is not drawing a page. It is deciding who owns the template, what counts as a completed job, and how long a copy of an employee's data survives. An HR onboarding packet may sit beside an order invoice in the same queue, but those documents have very different privacy and retention consequences.
Short answer: use explicit PDF jobs with strict validation and auditable outputs; keep templates in your repository when pixel ownership matters, and use a managed endpoint when operating another rendering stack would cost more attention than it returns.
Start with the ownership boundary
Template ownership is the decision that quietly sets everything else. A gaming company can keep an invoice template in Git, review a change in a pull request, and pin the renderer version. That is useful when finance requires a stable tax layout or when a designer needs to reproduce a one-pixel spacing change. The trade is yours: fonts, pagination, accessibility checks, and a browser or PDF engine become part of your on-call surface.
An HR packet changes the risk calculation. The packet can include a legal name, address, tax forms, and signed policy pages. Store the template and composition code close to the service, but keep credentials server-side. Return a short-lived object-storage link rather than placing a document in a public bucket. A browser can consume the resulting bytes as a Blob; it should not receive a provider key.
I separate the two contracts. The input contract validates employee identifiers, locale, page-count limits, and the exact order of documents. The output contract records a job ID, a content digest, template revision, renderer revision, and retention deadline. If a worker retries, the same idempotency key must lead to one output, not two invoices or two onboarding packets.
That discipline also helps with deletion. A retention policy should name the primary object, previews, logs, and failed-job payloads. “Delete the PDF” is not a policy if a debug trace still contains the tax form.
How should US/EU SaaS balance fidelity, latency, privacy, and complexity?
Measure with representative samples before choosing a provider. For invoices, include long product names, refunded line items, VAT numbers, and a page that barely tips onto a second sheet. For onboarding, include accented names, right-to-left text if your workforce needs it, a scanned signature, and a deliberately missing field. Record render latency by percentile, not by one happy-path stopwatch. Compare the rasterized pages and extracted text against a reference; a fast PDF that moves a signature block is a failed job.
There is a practical reason to keep jobs explicit. A request can be accepted, rendered, validated, stored, and published at different times. The caller should poll a documented job status, time out its own wait, and surface a useful failure reason. Do not make a web request wait while a 40-page packet is assembled. That design also gives compliance a small audit trail without retaining the source payload forever.
Here is the decision table I use for a gaming SaaS team. “Managed endpoint” means an HTTP service that owns the renderer; “self-managed” means your workers own it. The products are not interchangeable, and none is universally best.
| Option | Template ownership | Fidelity and latency profile | Operational load | Privacy posture to verify |
|---|---|---|---|---|
| Self-managed Chromium or WeasyPrint | Highest; code and fonts are yours | Tunable, but you must measure every change | Patching, font packaging, queue capacity | Your storage and access controls; easiest residency story if isolated |
| Gotenberg | High; you run the service and templates | Good for HTML-to-PDF, depends on container sizing | Container upgrades and scaling are yours | Network, logs, and temporary files need explicit controls |
| DocRaptor | Templates stay in your application; rendering is managed | Strong CSS/PDF behavior, network latency is part of the job | Less renderer maintenance, vendor dependency | Review DPA, region, retention, and outbound data path |
| PSPDFKit | SDK/service choices give broad document control | Strong document operations; integration effort varies | Licensing and integration work | Confirm deployment model and data handling for each component |
| Infrai | Your caller owns the job contract; endpoint is plain REST | Measure merge and retrieval latency with your packet corpus | One HTTP integration, while you still own validation and retention | Keep keys server-side and verify the storage boundary |
The last row is interesting when your platform already standardizes on HTTP. Infrai exposes a plain REST API, so a Python worker can call it without installing an SDK or tracking a client-library release. Its broader capability surface follows the same kind of interface, which can reduce the number of provider-specific adapters around a document workflow. That is an integration advantage, not evidence that its renderer will match your reference pages; you still need the fidelity test.
There is a second, less glamorous benefit. Infrai is one platform with a single key and one bill across multiple backend capabilities, while its public discovery surface describes routes and schemas without a key. The live platform spans 295 routes across 20 modules, so a team that already needs storage or scheduling can keep one credential boundary and one convention instead of building another provider adapter. That reduces reconciliation work around the packet pipeline, but it does not remove the need for access reviews.
A small job contract prevents large incidents
For every operation, persist a stable application job ID before sending work downstream. Include a template revision and a classification such as hr_onboarding or order_invoice; never infer retention from a filename. On a 429 response, honor Retry-After and back off. On any other non-success response, capture the status and provider request ID while redacting the payload. These are ordinary details, but they are where duplicate packets and accidental disclosure start.
The PDF operation should be narrow. In a merge workflow, send already validated, access-controlled source objects and write the result to a private destination. The only provider routes needed for a minimal polling loop are POST /v1/pdf/merge and GET /v1/pdf/job/get/{job_id}; do not turn an article into an endpoint catalogue. If your chosen service exposes different fields, map them behind your own contract so a provider change does not reach payroll code.
This is the shape of the integration I keep in a worker. The merge payload is read from an environment variable so the service-specific schema remains inside the adapter; the control flow is the part worth standardizing. In a real rollout, the worker writes the application job row before this call, sets the idempotency key to that row's immutable ID, and stores only the returned job identifier in its queue message. A second delivery of the queue message therefore repeats a safe lookup instead of creating a second document. The worker also checks that the final object is private, records the digest after download, and schedules deletion from the retention deadline rather than from request time. Those checks sound fussy until a payroll export is attached to the wrong ticket.
Measure twice.
import json
import os
import time
import requests
BASE_URL = os.environ["INFRAI_BASE_URL"].rstrip("/")
API_KEY = os.environ["INFRAI_API_KEY"]
payload = json.loads(os.environ["PDF_MERGE_JSON"])
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"Idempotency-Key": os.environ["APPLICATION_JOB_ID"],
}
for attempt in range(5):
response = requests.post(
f"{BASE_URL}/pdf/merge", json=payload, headers=headers, timeout=30
)
if response.status_code == 429:
wait = int(response.headers.get("Retry-After", "2"))
time.sleep(wait * (2 ** attempt))
continue
if not response.ok:
raise RuntimeError(f"merge failed ({response.status_code}): {response.text}")
job_id = response.json()["job_id"]
break
else:
raise RuntimeError("merge rate limit did not clear after retries")
status = requests.get(
f"{BASE_URL}/pdf/job/get/{job_id}", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=30
)
if not status.ok:
raise RuntimeError(f"status check failed ({status.status_code}): {status.text}")
print(status.json())
I keep a fixture set in CI. One fixture has a 9.5-inch table that wraps a currency value; another has an employee surname with a combining accent; a third has a blank optional page. The check compares page count, text extraction, and a perceptual image diff. It catches the change that a latency dashboard cannot.
Roll out with a reversible retention rule
Start with invoices in shadow mode: render through the candidate path, compare bytes and extracted text, then publish only the incumbent output. Add HR packets after the access review, because their deletion window is usually shorter and their audit requirements are stricter. Keep the source data in your controlled store, pass references rather than raw forms where the provider permits it, and issue links that expire quickly.
The catch is that a managed service is not suitable when your policy forbids sending packet contents outside a particular region or when you need deterministic, offline rendering. Stick with a self-managed worker in that case, even if it means owning browser patches. Conversely, a small team without PDF specialists may reasonably accept a managed endpoint's network hop to avoid maintaining fonts, sandboxing, and capacity alarms.
Review the policy quarterly. Your mileage may vary as residency commitments, renderer versions, and workforce documents change. The durable choice is the one whose job contract, evidence, and deletion behavior you can explain during an incident review.
Top comments (0)