DEV Community

MidnightEcho794261
MidnightEcho794261

Posted on

Invoice PDF Endpoints Explained for US/EU SaaS Processing Under Load

For a property-management SaaS, the right invoice PDF endpoint is the one that preserves a verifiable signature trail while keeping latency predictable under load. Fidelity is necessary, but a pixel-perfect document that arrives after a lease workflow times out is still a failed invoice. Start with an asynchronous job endpoint, immutable input, and a separate download step; reserve synchronous rendering for small, interactive previews.

Short answer: choose an endpoint contract that returns a job ID quickly, records the exact invoice payload and signing event, and lets workers control concurrency. Measure p95 and p99 latency at peak queue depth, not just the median on an idle server.

The audit problem behind an invoice PDF

An invoice is evidence, not a screenshot. A US or EU property operator may need to show which order produced a charge, who approved it, and whether the file changed after approval. The PDF itself should carry a stable invoice ID, issue timestamp, currency, tax breakdown, and a digest of the source order. Store those fields beside the object; do not make a reviewer OCR their own records.

The signature trail is the decision axis. A cryptographic signature or a content hash can prove integrity, while an append-only event log explains authorization. They solve different problems. Keep the original order JSON, the rendering template version, the generated bytes, and the signing result as separate records linked by one invoice ID. Retention and erasure rules still apply: EU personal-data deletion requests do not mean silently rewriting an accounting record. That distinction sounds academic until a tenant disputes a late fee and your support team needs to reproduce the exact page, with the exact tax calculation, months after a template change; keeping both the source and the rendered artifact turns that argument into a traceable comparison instead of a guess.

Keep both sides.

One small detail saves hours later: normalize decimal amounts before rendering. JavaScript's binary floating point can turn 19.99 into a value that rounds differently in two services. Send integer minor units (1999 cents) or a decimal string into the renderer, then assert the displayed total in a test. It is boring. It works.

What should US/EU SaaS endpoints do under load?

Treat endpoint design as a queue contract. The request handler validates the order, stores an idempotency key, and enqueues a render task. A worker claims that task with a bounded concurrency setting. The client polls or receives a webhook, then downloads the immutable PDF. This separates user-facing latency from font loading, template compilation, and signing work.

Here is a minimal TypeScript client. The URL is injected so the same code can target a hosted service, a regional gateway, or a self-hosted renderer without changing the invoice domain model.

type InvoiceInput = {
  invoiceId: string;
  orderId: string;
  currency: "USD" | "EUR";
  totalMinor: number;
  templateVersion: string;
};

type RenderAccepted = { jobId: string; status: "queued" };

export async function submitInvoice(
  endpoint: string,
  invoice: InvoiceInput,
  key: string,
): Promise<RenderAccepted> {
  const response = await fetch(endpoint, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${key}`,
      "idempotency-key": invoice.invoiceId,
    },
    body: JSON.stringify(invoice),
  });

  if (!response.ok) {
    throw new Error(`invoice render rejected: HTTP ${response.status}`);
  }
  return (await response.json()) as RenderAccepted;
}
Enter fullscreen mode Exit fullscreen mode

The idempotency key must survive retries. A client timeout is not proof that the server did nothing; retrying with a new key can create two signed invoices. Persist the response and reconcile by invoice ID. For a download, verify the media type, byte length, and digest before marking the order complete. The browser's Blob API exposes metadata and byte access for this check.

How do fidelity, latency, and operational complexity trade off?

There is no universal winner. The table below is the useful boundary I give a small team before implementation starts.

Endpoint pattern Fidelity and signatures Latency under load Operational cost
Synchronous PDF response Straightforward for a preview; signing can happen before response Tail latency grows with fonts, images, and queue contention Simple handler, harder timeout behavior
Asynchronous job plus download Strong audit trail when each state transition is recorded Fast acceptance; completion depends on queue depth Requires worker, retry policy, and status storage
Batch export window Consistent template and signing policy across many invoices Efficient throughput, poor immediacy Scheduling, partial-failure repair, and customer messaging

For property statements generated overnight, batch is reasonable. For a tenant portal button, asynchronous generation gives a better user experience than holding an HTTP connection open. A synchronous path still earns its place for a low-resolution preview that is explicitly marked unsigned.

The catch is observability. Track queue wait separately from render time and signing time; otherwise one p99 number hides the bottleneck. Emit a trace ID into the job record, log template and font versions, and alert on age of the oldest queued invoice. I use a 30-second warning budget for interactive acceptance, then tune it with real traffic. Your mileage may vary when invoices contain custom fonts or embedded tax attachments.

Failure modes worth testing before launch

Load tests should vary document shape, not only request count. Ten thousand identical one-page invoices exercise caching; a smaller burst of twenty-page statements exercises memory, font parsing, and object-store throughput. Test retries during worker restarts, duplicate webhook delivery, and a signing key rotation while jobs are queued.

Test the ugly data too: a negative credit, a zero-tax jurisdiction, a name containing right-to-left characters, and a currency with three decimal places. Rendered text must remain selectable, totals must match minor-unit arithmetic, and the signature must fail verification if any byte changes. Keep golden PDFs for a few templates, but compare structured fields as well; a harmless timestamp can make byte-for-byte snapshots noisy.

Regional routing adds another trade-off. US and EU tenants may require data residency, yet a single global queue is operationally tempting. Partition queues and object storage by residency boundary, and make the region an explicit property of the job. Do not infer it from a browser locale. When a region is unavailable, the honest behavior is a visible pending state with replayable work, not a second invoice rendered in an unauthorized region.

A practical decision rule for a small team

Pick asynchronous rendering when signatures, auditability, or variable document size matter. Pick synchronous rendering only when the caller needs an immediate preview and can tolerate a strict byte or time limit. Pick batch when humans already expect a scheduled export and throughput matters more than interaction.

Whichever pattern you choose, keep the renderer behind a narrow interface: submit, inspect status, download, verify. That boundary lets you swap a browser-based renderer for a native PDF engine or a regional provider without rewriting billing logic. It also makes operational complexity visible in code review instead of leaking into every API handler.

This approach is not suitable when the product promises legally binding signatures that require a regulated trust service; use a provider and legal review suited to that jurisdiction. Stick with a local renderer when invoices contain data that cannot leave your controlled environment, even if operating the queue takes more engineering. The best endpoint is the one whose failure and evidence model your team can explain to an auditor at 09:00 on a Monday.

References

Top comments (0)