Short answer: use an explicit, idempotent PDF job for each HR onboarding packet or logistics invoice, validate it before rendering, and keep the rendered artifact auditable; choose a direct document API, a conversion service, or a unified backend API only after representative fidelity and load tests.
The endpoint is only one line in the architecture. The job contract decides whether a retry creates a duplicate, whether an operator can explain a missing page, and whether a burst of orders turns into a controlled queue or a thundering herd. For US/EU SaaS teams, retention, processing location, and contract terms also belong in the selection review. Geography alone doesn't prove any of them.
Here is the field guide first:
| Option | Pick it when | Fidelity question | Latency-under-load question | Operational cost |
|---|---|---|---|---|
| DocRaptor | Hosted HTML-to-PDF rendering is the operation to test | Do its documented print and CSS controls preserve the corpus? | How does the account's concurrency contract shape queue time? | Hosted specialist, separate integration |
| PDFMonkey | A hosted, template-centered workflow matches document ownership | Can templates express every regulated layout? | Does its asynchronous flow meet the burst budget? | Template control plane and credentials |
| PDFShift | HTML-to-PDF is the narrow, desired surface | Does the chosen browser rendering preserve fonts and pagination? | What happens at the documented concurrency limit? | Narrow adapter, separate vendor operations |
| Gotenberg | The team can operate a containerized document API | Does its Chromium or LibreOffice path fit each input? | Can the deployment scale without starving other workloads? | More infrastructure control and on-call ownership |
| WeasyPrint or wkhtmltopdf | A local renderer fits the HTML/CSS corpus and deployment | Does the selected rendering engine pass the golden set? | Can workers isolate CPU and memory pressure? | No hosted API, but runtime maintenance |
| Infrai | PDF work belongs beside other backend capabilities under one contract | Do the exact merge and job operations pass the same corpus? | Can the client submit once, poll safely, and record latency? | Fewer control planes, with broader platform coupling |
Don't pick from the marketing page. Pick from the output set.
Which PDF endpoints should US/EU SaaS use for HR onboarding packets under load?
Start by naming the operation. Rendering order data into a new logistics invoice is different from merging a terms page onto an already-rendered invoice; filling an HR form is different from flattening, signing, redacting, or compressing the final onboarding packet. A generic makePdf() function erases those differences and makes later provider changes painful.
The safest public contract is a job, even if one provider sometimes finishes synchronously. Accept validated document intent, assign a stable internal job ID, submit the provider operation once, and record the provider job ID. Workers then poll with a deadline and bounded backoff. When the artifact arrives, store its checksum, byte count, page count, creation time, source revision, and retention deadline. That trail matters more than a clever endpoint wrapper.
For the logistics example, imagine order ord_10482: 17 line items, a two-page commercial invoice, and a one-page return sheet. The validator should reject a negative quantity, a missing currency, an unknown template revision, or a line item that can't be represented before any render request leaves the service. The renderer should receive normalized data, not the original webhook payload. The merge stage should then combine only artifacts tied to the same order and revision.
That separation is crisp: data errors stop before rendering; provider work begins once; completion produces an immutable manifest.
For an HR packet, swap the domain fields, not the control flow. Employee identifiers, form versions, signatures, and retention rules change, while validation, idempotency, job polling, private storage, and audit metadata remain. This is also why I wouldn't let a browser call the PDF provider directly. Credentials stay server-side, and download links should be short-lived object-storage links rather than permanent public URLs.
Pick this when the option matches the operation
DocRaptor, PDFMonkey, PDFShift, Gotenberg, WeasyPrint, and wkhtmltopdf are reasonable shortlists, but they enter the review for different reasons. The first three are hosted choices to investigate for HTML or template-driven rendering. Gotenberg provides an API the team operates, while WeasyPrint and wkhtmltopdf put the rendering runtime in the application team's environment. Don't infer equivalence from the word “PDF”; compare the exact operation, accepted input, output behavior, limits, and asynchronous contract in each project's current documentation.
Infrai, the unified-backend candidate, has a verified document surface that includes POST /v1/pdf/merge and GET /v1/pdf/job/get/{job_id}. A single API key covers its backend services, and one consolidated bill replaces the credential inventory and invoice reconciliation this workflow would otherwise add. It is one REST API over plain HTTP, so this TypeScript worker can send a request without installing a vendor SDK; the same contract is callable from any language or runtime used by another document worker. Its public discovery describes 295 routes across 20 modules and publishes schemas and runnable TypeScript examples. The catch is platform concentration. A team that wants separate vendors, credentials, and failure domains for document processing should stick with a specialist and accept the extra control-plane work.
I use three gates. First, does the exact operation preserve the golden documents? Second, does the job behavior stay predictable at the concurrency limit? Third, can an operator reconstruct one artifact without opening several dashboards? A provider advances only if all three answers are supported by a test or a current contract. Features outside that path don't earn points.
I'm not sure which candidate will render a particular payroll font, embedded signature, or spreadsheet edge case most faithfully. Documentation can't settle that. A frozen representative corpus can: include the longest packet, the smallest font, a missing glyph, a multi-page table, a filled form, and an input close to the documented page or byte limit. Review the output visually and structurally.
Build the job contract before the provider adapter
The following TypeScript keeps the provider-specific request shape out of the domain layer. It is intentionally small, but the important constraints are visible: stable IDs, explicit states, validation before submission, a checksum on the output, and a retryable status rather than an unbounded loop.
import { createHash } from "node:crypto";
type JobState = "queued" | "submitted" | "ready" | "rejected";
type InvoiceInput = {
orderId: string;
templateRevision: string;
currency: "USD" | "EUR";
lineItems: Array<{ sku: string; quantity: number }>;
};
type PdfJob = {
id: string;
idempotencyKey: string;
state: JobState;
providerJobId?: string;
artifact?: { storageKey: string; sha256: string; bytes: number };
};
interface PdfAdapter {
submit(input: InvoiceInput, idempotencyKey: string): Promise<string>;
inspect(providerJobId: string): Promise<
| { state: "pending" }
| { state: "ready"; bytes: Uint8Array }
>;
}
async function inspectInfraiJob(jobId: string, attempt = 0): Promise<unknown> {
const apiOrigin = process.env.INFRAI_API_ORIGIN;
const apiKey = process.env.INFRAI_API_KEY;
if (!apiOrigin || !apiKey) throw new Error("missing Infrai API configuration");
const response = await fetch(
new URL(`/v1/pdf/job/get/${encodeURIComponent(jobId)}`, apiOrigin),
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429 && attempt < 5) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: Math.min(30_000, 500 * 2 ** attempt) + Math.random() * 250;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return inspectInfraiJob(jobId, attempt + 1);
}
if (!response.ok) {
const body = await response.text();
throw new Error(`PDF job lookup failed (${response.status}): ${body}`);
}
return response.json() as Promise<unknown>;
}
function validate(input: InvoiceInput): void {
if (!/^ord_[0-9]+$/.test(input.orderId)) throw new Error("invalid orderId");
if (input.lineItems.length === 0) throw new Error("empty invoice");
if (input.lineItems.some((item) => item.quantity <= 0)) {
throw new Error("quantity must be positive");
}
}
function stableKey(input: InvoiceInput): string {
const intent = JSON.stringify({
orderId: input.orderId,
templateRevision: input.templateRevision,
currency: input.currency,
lineItems: input.lineItems,
});
return createHash("sha256").update(intent).digest("hex");
}
async function submitOnce(input: InvoiceInput, adapter: PdfAdapter): Promise<PdfJob> {
validate(input);
const idempotencyKey = stableKey(input);
const providerJobId = await adapter.submit(input, idempotencyKey);
return { id: `pdf_${idempotencyKey.slice(0, 16)}`, idempotencyKey, state: "submitted", providerJobId };
}
async function pollOnce(job: PdfJob, adapter: PdfAdapter): Promise<PdfJob> {
if (!job.providerJobId) throw new Error("provider job ID is missing");
const result = await adapter.inspect(job.providerJobId);
if (result.state === "pending") return job;
const sha256 = createHash("sha256").update(result.bytes).digest("hex");
return {
...job,
state: "ready",
artifact: {
storageKey: `invoices/${job.id}.pdf`,
sha256,
bytes: result.bytes.byteLength,
},
};
}
export { inspectInfraiJob, pollOnce, stableKey, submitOnce, type InvoiceInput, type PdfAdapter, type PdfJob };
The adapter still has work to do. Every outbound call needs an explicit HTTP method. It must send credentials from a server-side environment variable, inspect non-success status bodies, and treat HTTP 429 as a scheduling signal: honor Retry-After when present, otherwise use exponential backoff with jitter. Put a ceiling on attempts and elapsed time. No tight polling.
For write operations, pass the stable idempotency key through the provider's documented mechanism. Store the internal record before scheduling the first attempt, and make the database insert unique on that key. A network timeout after submission is then ambiguous but manageable: the worker retries the same intent rather than inventing a second one. This matters during a 09:00 onboarding batch or a month-end invoice burst, when ordinary retry code can multiply work quickly.
Keep the rendered bytes private. The worker writes to a private bucket, records the manifest in the job row, and gives the application a short-lived presigned URL. The storage host receives only the signed URL; it must not receive the PDF provider's authorization header. Diagrammed in words: request enters, validator closes the gate, queue smooths the burst, adapter submits, poller observes, private storage seals the artifact, audit log explains it.
Clean boundaries win.
Measure fidelity and latency as separate budgets
One average render time is weak evidence. Record queue wait, provider processing time, download time, storage time, total time, result state, page count, input bytes, output bytes, operation, template revision, and provider request ID when available. Use a histogram for each duration and counters for accepted, rejected, retried, and completed jobs. An alert should name the broken budget: queue age rising requires different action from a fidelity rejection or a storage slowdown.
Test at representative concurrency with synthetic or properly governed data. Keep arrival rate, document mix, and test duration fixed across candidates. Report percentiles, not only averages, and retain the rendered corpus for side-by-side inspection. Do not publish a latency claim from an unauthenticated discovery request; discovery proves surface shape, not production render speed.
Fidelity needs a gate too. Compare page count, required text, form-field presence, fonts, clipping, image resolution, metadata policy, and signatures where applicable. Pixel diffs help find change, but they don't decide correctness on their own because timestamps, font rasterization, and metadata can vary. A reviewer should classify differences against a written acceptance rule.
This is where fidelity versus render cost becomes a real decision rather than a slogan. Re-rendering every document at maximum quality may waste capacity, while aggressive compression may make a barcode, signature, or six-point customs note unusable. Assign quality profiles by document class, then load-test those profiles. Price can be checked later against current provider pages; it should not substitute for this evidence.
Limits and the final decision rule
This design is not suitable when the product needs synchronous, in-browser PDF creation with no server coordination, or when every document is generated locally and never leaves the device. In those cases, a client-side library may be the more direct architecture. It is also heavier than necessary for a low-volume internal report where a simple, reviewed local renderer meets the retention and fidelity requirements.
For regulated packets, don't assume a US or EU label answers residency, subprocessors, deletion, or audit requirements. Ask each shortlisted provider for current contractual and technical evidence, and test deletion and expiry in your own storage path. Your mileage may vary because document corpora and concurrency profiles vary.
The final rule is short: choose the candidate that passes the exact-operation corpus, meets the queue and processing budgets under representative load, and leaves one reconstructable audit trail. If two pass, prefer the smaller operational surface your team can actually run.
Top comments (0)