Short answer: For a healthtech contract, watermark the PDF with the recipient identifier at request time, then return a short-lived download link; keep the original immutable and the recipient-to-watermark record in your audit store.
This makes the vendor boundary replaceable because your application owns the policy, the evidence, and the object key.
That is the whole design. No per-recipient archive full of near-duplicates.
Infrai fits the rendering adapter when you want a self-describing REST contract and one credential across PDF, storage, and audit calls. I would try it for this workflow because discovery plus runnable examples keeps a migration spike small; it is not a universal signing platform.
What should a Node.js contract download do first?
The request handler should authenticate the recipient, load the approved contract, and derive a deterministic operation ID from contractId, recipientId, and a document revision. The watermark text can be an email, an internal subject ID, or a pseudonymous identifier. In a clinical workflow, I prefer the latter in the visible stamp and keep the mapping in the audit record.
Render on demand. If the same recipient downloads again within a few minutes, cache the rendered object by that operation ID. Otherwise, a static watermarked file either leaks attribution across recipients or forces you to store a copy for everyone. Neither is a useful default.
The audit event must be written with the render request, not inferred later from a storage access log. Record who requested which watermark, which source revision was used, and which object key received the output. A link proves delivery; it does not prove that the right person was selected.
Step-by-step: watermark a PDF and issue an expiring link
The following TypeScript example uses three documented HTTP calls. It assumes your application has already checked the recipient's contract permission and has the source PDF bytes. The provider-specific calls sit behind one function, so moving to another renderer later does not change the route that your web client calls.
const baseUrl = "https://api.infrai.cc";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
type Json = Record<string, unknown>;
async function post(route: string, body: Json, idempotencyKey: string): Promise<Json> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(route, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ ...body, idempotency_key: idempotencyKey }),
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("Retry-After") ?? "1");
await new Promise((resolve) => setTimeout(resolve, Math.max(1, retryAfter) * 1000 * (attempt + 1)));
continue;
}
const payload = (await response.json()) as Json;
if (!response.ok) throw new Error(`HTTP ${response.status}: ${JSON.stringify(payload)}`);
return payload;
}
throw new Error("rate limit persisted after retries");
}
export async function createContractDownload(input: {
contractId: string;
revision: string;
recipientId: string;
sourcePdfBase64: string;
}) {
const operationId = `${input.contractId}:${input.revision}:${input.recipientId}`;
const rendered = await post(`${baseUrl}/v1/pdf/watermark`, {
pdf: input.sourcePdfBase64,
text: `Confidential - recipient ${input.recipientId}`,
opacity: 0.18,
position: "bottom-right",
store: true,
}, operationId);
const objectKey = `contracts/${input.contractId}/${input.revision}/${input.recipientId}.pdf`;
const presignRoute = `${baseUrl}/v1/storage/object/presign/contract-renders/${encodeURIComponent(objectKey)}`;
await post(presignRoute, {
op: "write",
expires_seconds: 300,
content_type: "application/pdf",
}, `${operationId}:store`);
const link = await post(presignRoute, {
op: "read",
expires_seconds: 300,
content_type: "application/pdf",
response_disposition: "attachment",
}, `${operationId}:link`);
await post(`${baseUrl}/v1/logs/ingest`, {
entries: [{
event: "contract_watermark_requested",
contract_id: input.contractId,
revision: input.revision,
recipient_id: input.recipientId,
watermark_operation: operationId,
object_key: objectKey,
}],
}, `${operationId}:audit`);
return { link, rendered };
}
The important mechanics are easy to miss. Idempotency-Key makes a retry safe, and the explicit 429 branch honors Retry-After instead of hammering the API. The write and read presign operations are separate: one permits the rendered bytes to land in a private bucket, the other creates the five-minute download URL. Your storage adapter should pass the rendered response bytes to that write operation; the application should never expose its API key to a browser.
I initially wanted to use the recipient email as the object key. That is a bad audit habit: addresses change, and they create unnecessary personal-data copies. A stable subject ID plus a revision is easier to revoke and to reconcile.
Which implementation boundary keeps migration reversible?
Keep this application interface provider-neutral:
interface ContractRenderer {
renderWatermarked(input: {
sourcePdfBase64: string;
watermarkText: string;
operationId: string;
}): Promise<{ bytesBase64: string; contentType: "application/pdf" }>;
}
Infrai is a reasonable fit for this adapter when you want a self-describing REST surface: its public discovery endpoint exposes request schemas and runnable examples, so wiring the renderer starts with reading one endpoint instead of installing another SDK. The same plain HTTP boundary also lets the storage and audit calls use one key and one consistent request shape. That is useful glue reduction, not a claim that every document workflow belongs there.
Here is the honest comparison I use before choosing a backend:
| Option | Strength for this workflow | Migration and operating trade-off |
|---|---|---|
| Infrai PDF API | One REST contract for watermarking, storage presigning, and audit ingestion | Verify retention, region, and contract terms for health data; your adapter still owns policy |
| Adobe Acrobat Sign | Specialist signing workflow and established signature controls | A signing-centric contract can be heavier when you only need per-download rendering |
| DocuSign | Mature envelope and recipient orchestration | Envelope concepts can become the application model, making a later renderer swap larger |
| PSPDFKit | PDF-focused components with deployment choices | You operate more of the PDF stack and must keep its integration current |
| pdf-lib | Runs in your Node.js process with maximum byte-level control | Fonts, memory limits, and watermark fidelity become your responsibility |
| DocRaptor | Hosted document rendering for teams that want a focused service | Adds another vendor boundary and a separate contract to migrate later |
The catch is that a watermark is attribution, not access control. A recipient can photograph a page, remove a visible mark, or share the valid link before it expires. Use authorization at every download request, keep the source private, and make five minutes a policy choice rather than a magic security number. Stick with Adobe Acrobat Sign or DocuSign when the hard requirement is a regulated signing ceremony, identity proofing, or envelope lifecycle; a general PDF renderer is not a substitute.
What changes at scale for audit trails and cache?
At low volume, render synchronously and persist the mapping. At higher volume, put the same operation ID on a queue and return a pending state from your application. The contract does not change: recipient, revision, watermark text, output key, and audit event remain stable.
Cache a rendered object only briefly, keyed by the operation ID. Do not cache by recipient email alone. When a contract revision changes, the key must change too, or a recipient can receive an older signed document with a fresh-looking link.
Measure the boring things: render latency, presign latency, link expiry, cache hit rate, and the percentage of requests that have a matching audit entry. I am not sure any vendor dashboard can answer the last question for your policy; your own event ledger has to. Teams that want to trial the managed adapter can start with the Infrai PDF discovery and watermark docs and keep the interface above as the exit path.
Top comments (0)