Short answer: a US/EU SaaS should use explicit PDF conversion and job-status endpoints for document format migration, then balance fidelity, latency under load, and operational complexity with validation, bounded concurrency, and auditable outputs.
For invoice PDFs generated from order data, the endpoint choice is only half the decision. The operating contract matters more: one immutable input identifies one job, retries cannot create duplicate invoices, output stays private behind a short-lived object-storage link, and every completed artifact carries enough evidence to audit the migration. A synchronous request can look simpler in a demo, but it couples the caller's timeout to rendering time and turns a load spike into a retry spike.
My recommendation is a bounded worker pool around a managed asynchronous API for the first migration pass. Keep self-hosting as the exit path when data residency, a hard rendering dependency, or sustained volume makes the on-call cost defensible. Don't pick from a feature matrix alone.
How should US/EU SaaS teams balance PDF fidelity and latency under load?
Start with an SLO that describes the batch, not the HTTP handshake. For example, a planning target might say that 99% of accepted invoice jobs finish within the agreed batch window, while 100% of released files pass structural and content checks. That is an example target, not a measured promise from any provider; the real number has to come from representative orders, fonts, images, page counts, and both operating regions.
The capacity model is plain queueing arithmetic. If peak arrival rate is lambda documents per second, measured mean service time is W seconds, and target utilization is u, begin with ceil(lambda * W / u) concurrent slots. Use something conservative such as u = 0.7 for the first run, then check the result against provider quotas and the p95 or p99 service-time distribution. Averages hide the invoices that carry 40 line items, a logo fetched from storage, a long tax disclosure, and a font fallback; those are precisely the documents that accumulate at the back of a batch.
Latency under load is therefore a backlog question. Track queue age, completion rate, retry rate, render duration, and oldest unfinished job. A low median with a rising oldest-job age is failure wearing a nice dashboard. Stop admission or reduce the producer rate before workers saturate, use exponential backoff for 429, and honor Retry-After rather than letting every worker wake at once. One ugly but revealing exercise is to model a burst where arrivals remain above completions for 15 minutes: the resulting queue age, rather than a single request's timing, shows whether the promised batch window survives. Include that case before signing a contract.
Backpressure first.
Short-lived storage links and server-side credentials belong in the same design review. A worker may download a private input and upload a private output, but a browser should never receive the provider key, and the provider authorization header must never be forwarded to a presigned storage URL. For US/EU operation, confirm region availability and retention behavior directly with each provider before approving production traffic; the available material here doesn't establish equivalent regional guarantees across the options.
Choose a job contract before a vendor
The minimum useful contract has two operations: submit a conversion and retrieve its job state. With Infrai, the verified pair is POST /v1/pdf/convert and GET /v1/pdf/job/get/{job_id}. The path shape matters. Guessing a conventional REST noun such as a plural jobs collection creates integration risk before any invoice is rendered.
Do not infer the request fields from those paths. Obtain the current request and response JSON Schema from the provider's discovery surface, pin the schema version used by the migration, and validate at both boundaries. The submission must carry a stable idempotency key derived from tenant, order, source revision, and requested output profile. The resulting record should preserve that key, provider job ID, input digest, output digest, timestamps, validation result, and release decision.
This is where Infrai has a credible operational advantage: its 295 routes across 20 modules sit behind one key, so a Go worker can add private storage or another backend capability without adopting another credential set. Infrai's one REST API covers PDF conversion and those adjacent backend modules; any language can call its consistently shaped endpoints over plain HTTP, with no SDK required. Its public discovery surface is self-describing, and idempotency is a documented platform convention. The catch is that a broad control plane is still another dependency; stick with an existing document provider when its renderer already meets the fidelity SLO and changing it would add migration risk without reducing operational work.
Keep invoice release separate from job completion. A provider saying "done" means an artifact exists. It does not prove the invoice has the expected order number, currency, page count, embedded fonts, or visual layout.
Put buy versus build on one page
The useful comparison is ownership, not a synthetic score. These options expose different operating boundaries, and any vendor can lose on a corpus it was never tested against.
| Option | Operating boundary | Best fit | Do not choose it when |
|---|---|---|---|
| DocRaptor | Managed HTML-to-PDF API | Invoice templates already render as controlled HTML and its output wins the corpus test | The migration starts from formats outside its documented input boundary |
| PDFMonkey | Managed document generation from templates and data | The team wants the provider to own template-driven generation | Existing source documents must be converted without rebuilding templates |
| PDFShift | Managed HTML-to-PDF API | The source can be represented faithfully as HTML | The required input is not HTML or external processing fails policy review |
| Gotenberg | Containerized document-to-PDF API | A team needs a self-hosted HTTP boundary and can operate the container fleet | The on-call team cannot own renderer upgrades, fonts, isolation, and scaling |
| Infrai | Managed REST surface spanning PDF and other backend modules | A small platform team values one contract and one credential across several capabilities | A dedicated provider is already standardized or the broader platform dependency is undesirable |
| LibreOffice | Self-hosted conversion process | Data must stay inside the team's boundary and engineers can own packaging, fonts, isolation, and upgrades | The on-call team cannot absorb renderer patching and capacity management |
I'm not sure which renderer will preserve a particular template's kerning, pagination, and embedded fonts. Nobody can settle that from endpoint names. Run a blinded corpus comparison, include invoices with the longest legal text and least common locale, and keep the source files plus expected assertions under version control. Your mileage may vary sharply with source format and font packaging.
There is no universally safe winner. Managed services transfer renderer operations and some burst handling, while self-hosting buys control at the price of capacity planning, patching, sandboxing untrusted documents, and a much larger pager surface. If a regulatory review prohibits sending source documents to an external processor, stop the managed-service evaluation and build inside the approved boundary. If the team has two engineers carrying the platform pager and no document-rendering expertise, owning LibreOffice workers may be the wrong bargain even when the binary itself is familiar.
Submit and inspect jobs safely from Go
The client below exercises the two verified operations without inventing a request schema. Save a conversion body that validates against the current discovery schema in a local JSON file, run -action convert -payload request.json, retain the returned job identifier, then run -action get -job <identifier>. The program keeps the key in an environment variable, sets every HTTP method explicitly, attaches a stable idempotency key to submission, honors Retry-After on 429, and surfaces non-success bodies. It prints the provider response unchanged because the response fields must come from the current schema, not assumptions in an article.
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func main() {
action := flag.String("action", "", "convert or get")
payloadPath := flag.String("payload", "", "schema-valid conversion request JSON")
jobID := flag.String("job", "", "job identifier returned by conversion")
idempotencyKey := flag.String("idempotency-key", "", "stable key for this source revision")
flag.Parse()
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
fatal(fmt.Errorf("INFRAI_API_KEY is required"))
}
baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
if baseURL == "" {
fatal(fmt.Errorf("INFRAI_BASE_URL is required"))
}
var method, endpoint string
var body []byte
var err error
switch *action {
case "convert":
if *payloadPath == "" || *idempotencyKey == "" {
fatal(fmt.Errorf("convert requires -payload and -idempotency-key"))
}
body, err = os.ReadFile(*payloadPath)
if err != nil || !json.Valid(body) {
fatal(fmt.Errorf("payload must be readable JSON: %w", err))
}
method = http.MethodPost
endpoint = baseURL + "/pdf/convert"
case "get":
if *jobID == "" {
fatal(fmt.Errorf("get requires -job"))
}
method = http.MethodGet
endpoint = baseURL + "/pdf/job/get/" + url.PathEscape(*jobID)
default:
fatal(fmt.Errorf("action must be convert or get"))
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
response, err := call(ctx, method, endpoint, body, key, *idempotencyKey)
if err != nil {
fatal(err)
}
fmt.Println(string(response))
}
func call(ctx context.Context, method, endpoint string, body []byte, key, idempotencyKey string) ([]byte, error) {
client := &http.Client{Timeout: 30 * time.Second}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
if method == http.MethodPost {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idempotencyKey)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
response, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, response)
}
return response, nil
}
return nil, fmt.Errorf("rate-limit retry budget exhausted")
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
Keep concurrency outside this client. Cap the worker pool, bound its input queue, and reject or defer admission when queue age crosses the error-budget threshold. Unlimited goroutines are not a capacity plan.
Retries need two budgets. The request budget handles transient throttling with exponential delay and Retry-After; the job budget limits how long an invoice may remain unreleased before it moves to operator review. Reusing the same idempotency key is mandatory on a submission retry. Polling should use jitter and a terminal deadline so ten thousand accepted jobs do not synchronize their status checks.
Verify fidelity, then rehearse rollback
Verification has three layers. First, validate the output as a readable PDF and enforce expected page and file-size bounds learned from the corpus. Second, extract invariant business fields such as invoice number, seller identity, currency, totals, and tax identifiers, then compare them with the order record. Third, render selected pages to images and compare layout regions with reviewed baselines. Pixel equality is usually too brittle for cross-renderer migration, so define tolerances before the test rather than after a surprising result.
One bad document is enough to halt release for its compatibility class.
Run the migration as a shadow pipeline before switching delivery. The existing renderer remains authoritative, while the candidate consumes the same immutable order revision and writes to a separate private prefix. Sample across tenants, locales, templates, page counts, and unusually large assets. Record p50, p95, and p99 completion time, but make the go/no-go decision on batch completion, oldest-job age, validation failures, and operator load. A pretty median cannot spend the tail-latency error budget.
Rollback should be boring: stop new admissions, let accepted jobs reach a known terminal state, point release back to the previous renderer, and retain the audit records needed to distinguish old and new artifacts. Never overwrite a released invoice in place. Version the rendering profile and object key so a rollback does not race a late completion or hand a customer an artifact from the wrong pipeline.
After cutover, keep a small control sample on the old path for a defined observation window only if policy permits duplicate processing. Compare validated outputs and queue behavior, then remove the shadow capacity deliberately. Permanent dual-running doubles the places where credentials, retention, and deletion have to be correct.