Short answer: for a US or EU SaaS verifying customers, keep the template in your repository, render through a small asynchronous endpoint, and archive an immutable PDF with an explicit retention deadline. This keeps the evidence reproducible without turning the request path into a document-processing queue.
The deciding constraint is template ownership. If a compliance or product team can change a hosted template without a pull request, you cannot reliably explain why Alice's document looked different from Bob's three months ago. That is a bigger audit problem than a 200 ms rendering difference.
Keep it boring.
The build log: one report, one immutable artifact
The concrete job here is a healthtech monthly identity-verification report. It contains a subject identifier, verification checks, reviewer decisions, and timestamps. The source data is structured JSON. The output is a PDF that a support agent can inspect and an auditor can hash.
I start with an endpoint contract that says what is accepted and what is returned. A 202 means the report is queued; a later GET returns status and, only when ready, a short-lived download URL. The PDF never lives in a public web directory.
type ReportRequest = {
reportId: string;
subjectId: string;
templateVersion: string;
retentionUntil: string;
};
type ReportStatus = {
reportId: string;
state: "queued" | "ready" | "failed";
sha256?: string;
downloadUrl?: string;
};
export async function createReport(input: ReportRequest): Promise<ReportStatus> {
if (!/^v\\d+\\.\\d+$/.test(input.templateVersion)) {
throw new Error("templateVersion must be pinned");
}
const response = await fetch("/api/verification-reports", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(input),
});
if (response.status !== 202) throw new Error(`unexpected status ${response.status}`);
return response.json() as Promise<ReportStatus>;
}
The small detail that saves time later is persisting the template version beside the artifact hash. Store both with the report metadata, along with who requested it and the retention date. A PDF is a binary object; in a browser it can be represented as a Blob, but that does not make it a policy decision or an access-control decision.
How should PDF endpoints balance fidelity, latency, privacy, and retention?
Treat those as separate budgets. Fidelity is whether fonts, page breaks, and localized dates survive rendering. Latency is how long a caller waits. Privacy is who can see source data and intermediate files. Retention is when every copy is deleted.
| Choice | Helps | Costs or failure mode |
|---|---|---|
| Render in the request | Fast feedback for tiny reports | A slow font load or large image consumes request time |
| Queue a worker | Predictable API latency and retries | Requires idempotency, status polling, and worker metrics |
| Repository-owned templates | Reviewable diffs and reproducible versions | Your team owns accessibility and localization testing |
| Hosted templates | Non-engineers can edit layouts | A silent edit can change evidence after approval |
| Private object storage | Clear access boundaries and lifecycle rules | Signed-link expiry and deletion jobs need tests |
For identity evidence, I choose the queued path once rendering can exceed a normal request timeout. The API returns a stable report ID, not a base64 string. The worker writes to a private bucket, computes SHA-256, and emits an audit event. A retry for the same report ID must not create a second legal record.
Privacy is a data-flow problem. Redact logs before serialization, keep renderer workers in the same allowed region as the tenant, and make download authorization check the tenant and subject on every request. EU teams still need a documented purpose and retention rule; US teams need the same operational discipline even when the legal basis differs. Do not assume that expiring a URL deletes the object behind it.
The trap I test before shipping
The first test isn't a screenshot. It's a replay. Take a frozen fixture with a long surname, a right-to-left note, a missing middle name, and a daylight-saving transition. Add a second fixture that pushes the layout instead of flattering it: a two-page review history, a portrait image at the largest accepted dimensions, an address that wraps across three lines, and a check whose explanation lands exactly at a page boundary. Render each fixture twice from the same template commit. Compare extracted text, page count, and the artifact hash; then inspect the visual diff because equal text does not mean equal layout. Change one input field and confirm the report ID stays tied to the original payload rather than silently replacing the archived artifact. Next, expire the download URL and verify that access stops while the authorized archive remains available, then advance the retention clock and verify that the archive and its derivatives are deleted. Finally, open the returned bytes as a Blob, check the MIME type, and make sure the UI doesn't log the signed URL. This is tedious on purpose. A polished happy-path fixture tells you almost nothing about clipping, retries, or deletion, which are the places a monthly identity report becomes operationally expensive.
export function assertPdf(blob: Blob): void {
if (blob.type !== "application/pdf") throw new Error("unexpected media type");
if (blob.size < 1024) throw new Error("artifact is implausibly small");
}
That size check is only a smoke test. It will not prove visual fidelity. Keep a small corpus of golden PDFs and review diffs when the renderer, font package, or template version changes. Your mileage may vary across operating systems because font rasterization is not perfectly identical.
Hashes aren't screenshots.
Operationally, watch queue age, render duration percentiles, retry count, and deletion lag. Alert on a report that remains queued past its service objective and on objects whose retentionUntil is in the past. A dashboard that only shows HTTP 200s is blind to the expensive part.
What I would change at scale
At higher volume, split template compilation from data rendering and cap concurrency per tenant. Put a dead-letter state behind a human review queue, but never ask an agent to regenerate evidence by hand. Add a schema version to the input fixture so a later field rename fails loudly instead of producing a plausible-looking blank cell.
The catch is that repository-owned templates are not suitable when legal reviewers must edit layouts without engineering access. In that case, use a controlled template service with approval snapshots and exportable version history. Stick with synchronous rendering only for bounded, low-sensitivity previews; it is a poor fit for archival identity records.
There is no universal endpoint shape. The useful contract is the one that makes ownership, version, access, and deletion observable. Start there, benchmark the renderer with your actual fixtures, and let latency decide when a worker becomes necessary.
Top comments (0)