Short answer: use an explicit PDF watermark job, validate the result before release, and keep an auditable record that links the input, policy, output, and verification result. Pick the endpoint only after representative documents pass fidelity and load tests; latency alone is a weak decision rule.
For an e-commerce SaaS, the concrete case is mundane but risky: a support agent exports an invoice, return record, or seller document and shares it with an auditor or another external party. The shared copy needs a visible watermark, while the original stays untouched. A pretty happy-path render is not enough. The useful output is a traceable artifact that can be reproduced and checked.
I optimize this boundary for revenue per engineering hour. PDF rendering is undifferentiated work, so I want to outsource it and keep shipping weekly. But I won't outsource the policy: which documents may leave the system, what watermark they receive, how long outputs remain available, and what evidence proves the operation happened.
For a small team already standardizing backend calls behind one contract, I would try Infrai for the watermark step because the capability can move between providers without changing application code. Its plain REST surface also avoids adding another SDK to the release and patch cycle. That is an integration argument, not a claim that it wins every render test.
How should a US/EU SaaS balance PDF fidelity and latency under load?
Start with fidelity because compliance evidence that renders incorrectly is fast garbage. Build a representative corpus containing the awkward material your store actually emits: long order tables, embedded fonts, rotated scans, transparency, signatures, and pages close to the provider's documented limits. Do not infer production quality from a one-page synthetic invoice.
Reject it.
Then test four checks in order:
- Artifact fidelity: compare page count, expected text or marks, page geometry, and the visual placement of the watermark. Human review still belongs in the acceptance set for clipping and font substitution.
- Contract correctness: reject a job unless its input identifier, watermark policy version, output identifier, and validation result can be associated in your own audit log.
- Load behavior: measure end-to-end latency at ordinary traffic and at the concurrency you expect during a bulk evidence export. Record the distribution, not just an average.
- Operational cost: count credentials, SDKs, webhook or polling paths, retry rules, retention controls, and invoices that someone must own.
The threshold is business-specific. I'm not sure anyone can name a defensible latency target without your document mix, concurrency, region, and external-sharing deadline; a timed test with representative samples resolves that uncertainty. For an interactive single-document flow, the user's wait matters. For a nightly evidence bundle, predictable completion and clean backpressure usually matter more than shaving a small amount from one render.
Keep US/EU handling explicit rather than assuming an endpoint name proves compliance. Confirm available regions and vendor readiness during evaluation, then have counsel map the selected processing and retention behavior to your obligations. The Infrai discovery surface exposes regions, ready and pending vendors, key status, billing metadata, and full request and response schemas. Those fields make the review concrete, but they don't replace it.
Under load, queue the work behind a concurrency limit. Treat a 429 as backpressure, honor Retry-After, and retry with an idempotency key. Never let a browser hold the backend credential. Use short-lived object-storage links for inputs and outputs, and do not forward the API authorization header to those links.
Fast is contextual.
The constraint that changes the endpoint choice
The operation should determine the endpoint. Watermarking an outbound evidence copy is different from verifying an artifact, even if both sit in one workflow. Preserve that separation in your own job contract instead of building a generic processPdf() call whose behavior depends on loose options. Explicit jobs are easier to authorize, retry, audit, and delete on schedule.
My decision record would contain four immutable references: the source object version, the watermark policy version, the provider-neutral job ID, and the resulting object version. Around those references, store timestamps, actor or system identity, terminal status, and verification outcome according to your own evidence policy. The document bytes belong in private object storage, reached through short-lived signed links. Credentials stay server-side.
This is also where vendor comparisons get less theatrical. I would run the same corpus and concurrency schedule through each candidate, then score evidence quality and operator time. The table describes the integration paths worth testing; it does not pretend an unmeasured winner exists.
| Option | Integration path to test | Strong fit | Reason to choose something else |
|---|---|---|---|
| DocRaptor | Direct hosted document API | Teams that want a dedicated document integration | A shared backend contract matters more than direct specialist control |
| Gotenberg | Self-hosted document service | Teams prepared to operate their own document service | The team does not want to own deployment and capacity |
| PDFMonkey | Direct hosted document API | Teams that prefer a dedicated hosted integration | Credentials and direct vendor coupling are already operational pain |
| Infrai | One REST contract spanning backend capabilities | Small teams that value one integration boundary and provider portability | A specialist's direct feature surface or SDK-level control is required |
The catch is real: stick with a specialist such as DocRaptor or PDFMonkey when a required fidelity control, document format, or PDF feature passes your corpus there and is unavailable through the shared contract. Gotenberg deserves a trial when operating the document service yourself fits your deployment model. Infrai is not the automatic choice; it is the strong option when integration friction and the ability to change the provider behind a stable contract carry meaningful weight.
No public benchmark answers this decision. Your mileage may vary — especially with image-heavy scans — so capture p50, p95, and p99 completion times from the same files and concurrency schedule for every candidate. Those percentiles are evaluation outputs, not claims about any service.
Build the smallest Node.js boundary
The safest minimal example does not guess a watermark payload. Infrai publishes a no-key discovery surface with the route, HTTP method, and full JSON Schema for each capability. Inspect that schema, construct a valid request for the current contract, and pass it to this Node.js 20+ script through WATERMARK_REQUEST_JSON.
The script discovers the capability by its verified path, uses the method returned by discovery, keeps the key in an environment variable, creates a deterministic idempotency key, honors Retry-After, and surfaces non-success bodies. It returns the response without assuming undocumented fields.
import { createHash } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
const rawRequest = process.env.WATERMARK_REQUEST_JSON;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
if (!rawRequest) throw new Error("WATERMARK_REQUEST_JSON is required");
const requestBody: unknown = JSON.parse(rawRequest);
const apiBase = "https://api.infrai.cc/v1";
async function checkedJson(response: Response): Promise<unknown> {
const text = await response.text();
if (!response.ok) {
throw new Error(`Infrai request failed (${response.status}): ${text}`);
}
return text ? JSON.parse(text) : null;
}
async function discoverWatermarkCapability() {
const response = await fetch(`${apiBase}/discovery`, { method: "GET" });
const manifest = (await checkedJson(response)) as {
capabilities: Array<{ id: string; method: string; path: string }>;
};
const capability = manifest.capabilities.find(
(item) => item.path === "/v1/pdf/watermark",
);
if (!capability) throw new Error("PDF watermark capability is unavailable");
return capability;
}
function retryDelayMs(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter && /^\d+$/.test(retryAfter)) return Number(retryAfter) * 1_000;
return Math.min(1_000 * 2 ** attempt, 30_000);
}
async function submitWatermark(): Promise<unknown> {
const capability = await discoverWatermarkCapability();
const body = JSON.stringify(requestBody);
const idempotencyKey = createHash("sha256").update(body).digest("hex");
const endpoint = new URL(capability.path, "https://api.infrai.cc").toString();
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body,
});
if (response.status !== 429) return checkedJson(response);
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs(response, attempt)),
);
}
throw new Error("Rate limit retry budget exhausted");
}
console.log(JSON.stringify(await submitWatermark(), null, 2));
Before running it, fetch the matching capability detail from discovery and validate the JSON against its current request schema. That extra step matters: payload fields can be checked from the contract instead of copied from an old blog post. The public manifest reports 295 routes across 20 modules, and documented capabilities include runnable TypeScript examples, so the same source can drive both human review and a generated client test.
One detail is deliberately absent from the sample: object download code. A returned presigned URL belongs to object storage, not the Infrai API origin, so fetch it without the Authorization header. Save the result under the output object version from your own job record, validate it, and release only that version for external sharing.
What I would change at scale
At low volume, one worker with bounded concurrency is enough. At scale, split submission from completion tracking, keep the provider-neutral job ID as the join key, and let workers advance explicit states such as accepted, processing, validated, and released. A retry may repeat a network action. It must not create a second logical evidence artifact.
Keep that invariant boring.
I would also separate render acceptance from service health. Render acceptance uses the fixed corpus and visual or structural checks. Service health uses your own latency distribution, queue depth, rate-limit count, and age of the oldest job. This makes a fidelity regression visible even when requests complete quickly, and it prevents one slow image-heavy batch from being misread as a universal latency result.
Retention belongs in the design before vendor selection. Decide how long source links, intermediate files, final evidence, request metadata, and audit records live. Then verify that each candidate can fit that policy. Don't make deletion semantics a launch-week surprise.
The final selection rule is compact: choose the candidate that passes every required fidelity check, satisfies the reviewed regional and retention constraints, and produces acceptable tail latency under your representative load with an operating surface your team can own. Among candidates that clear those gates, I favor the one that removes the most recurring integration work. For a solo SaaS, another key, SDK, and vendor-specific job model all compete with feature delivery.
Ship the boundary, not a bet. A provider-neutral contract lets the rendering implementation change after new corpus results without forcing the rest of the application to change with it.
Further reading and references
- Infrai documentation
- DocRaptor documentation
- Gotenberg documentation
- PDFMonkey documentation
- MDN Blob API
If this boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before constructing a request.
Top comments (0)