At month-end, a US/EU SaaS should use explicit PDF job endpoints for receipts and expense reports because an endpoint that looks quick for one document can still miss the reporting window once every tenant closes its books together.
Short answer: use explicit PDF jobs, reject invalid inputs before submission, poll an auditable job record, and retain the output for a declared period; choose a provider only after representative receipt and expense-report samples meet the required fidelity and batch-throughput budget.
This is less about finding a magical PDF API than controlling a queue. A useful capacity worksheet might assume 12,000 receipts averaging four pages, 600 consolidated reports averaging 30 pages, and a six-hour completion window. Those are planning inputs, not benchmark results. They imply 30,000 pages and a sustained requirement of about 1.39 pages per second before retries, bursts, and safety margin. If the vendor contract is expressed only as a hopeful synchronous request timeout, the design has already lost the information needed to manage that load.
The invariant is simple.
Every accepted document needs a stable job identity, a validated operation, an immutable input reference, and a recorded output or terminal reason. That contract lets an operator answer the two questions that matter during close: "How much work remains?" and "Can I retry this item without producing a second report?"
How should a US/EU SaaS balance PDF endpoint fidelity, latency, privacy, and retention?
Start with an SLO that describes the batch, not the median call. For example, define the completion objective as a percentage of the monthly batch finished within its reporting window, then give the remaining documents a separate recovery objective. I would also track queue age, pages waiting, attempts per job, and terminal validation failures. A provider's single-document response time doesn't tell you whether its concurrency policy, page limits, or asynchronous job behavior can carry the close-day peak.
Fidelity needs a test corpus because receipts are hostile little documents: narrow paper, embedded fonts, rotated scans, transparent logos, tables near page breaks, and long merchant names all expose different rendering failures. The corpus should contain representative inputs from both US and EU tenants, but geography alone is not a test. Compare the produced PDF visually and structurally, check page count and required text, and keep the exact same corpus for every candidate. I'm not sure which service will win for a given template set until that test runs; your mileage may vary because the templates, fonts, and source formats are the workload.
Latency is a queueing problem. Record submission time, start time, completion time, page count, operation, provider request ID, and attempt number. Separate waiting time from processing time so a capacity shortage doesn't masquerade as slow rendering. Then test at the intended concurrency plus headroom, using the monthly mix rather than 10 copies of the smallest receipt. Don't extrapolate from one warm request.
Queues accumulate.
Privacy and retention belong in the job contract as well. Credentials stay on the server, input and output objects remain private, and download access uses short-lived object-storage links. Never put a provider key in a browser or mobile client. For US/EU operation, verify the candidate's region behavior, subprocessors, deletion semantics, and contract terms with your legal and security teams; the available evidence here doesn't settle those organization-specific requirements. Set a deletion deadline on input, output, logs, and backups, then make deletion auditable. "We usually clean it up" isn't a retention policy.
Deletion needs evidence.
The incident pattern: synchronous success hides batch failure
Consider a bounded failure exercise rather than an invented war story. The first 11,500 documents complete, the reporting window has 30 minutes left, and 500 large reports remain. A synchronous integration knows which requests timed out, but it may not know which timed-out requests continued processing, which outputs were committed, or which retries created duplicates. Operators are left counting objects and guessing. A job-based integration can stop admission, inspect the outstanding set, retry by stable idempotency identity, and preserve evidence for every decision.
This is where strict validation earns its keep. Validate the intended operation, media type, page policy, output policy, tenant, region choice, retention deadline, and idempotency identity before work enters the paid or rate-limited portion of the system. A malformed 900-page scan should not sit ahead of valid four-page receipts merely because both arrived as blobs. Browser Blob objects are convenient client-side containers, but they are not durable job identities and they do not move credentials out of the browser.
The preventative control is admission math. With 30,000 pages in six hours, 1.39 pages per second is only the no-headroom floor. Let P be queued pages, R the demonstrated sustained pages per second for the representative corpus, and W the seconds left in the objective. Admit the batch only when P / R fits inside W after applying the platform team's safety factor. Since no authenticated runtime benchmark is available here, R must come from your load test. Capacity claims without that number are decoration.
Keep the raw source separate from derived PDFs. The job record should point to immutable input and output objects, while the access links expire quickly. That separation permits a report to be regenerated under a new template without pretending that the old output never existed, and it gives deletion workers a finite set of objects to prove they removed. It also makes a late vendor switch much less dramatic — the queue owns business identity; the PDF service owns an execution attempt.
A buy-versus-build table for the monthly close
The comparison should be run with the same corpus and concurrency plan. Product categories matter because they move different work onto the platform team's pager.
| Option | Operational fit | What to verify | When to avoid it |
|---|---|---|---|
| DocRaptor | Managed document service for a team that wants a direct vendor relationship | Receipt fidelity, asynchronous behavior, regional processing, retention terms, and close-day quotas | Avoid when another dedicated integration and credential set outweigh its document-specific value |
| PDFShift | Managed API candidate for teams comparing document rendering behind a service boundary | Rendering fidelity, job semantics, region requirements, object deletion, and throughput limits | Avoid when policy requires the rendering plane to run inside your own environment |
| PDFMonkey | Managed API candidate for teams comparing templated document operations | Exact operation contract, concurrency, file limits, retention controls, and representative output | Avoid when the security review cannot accept an additional document processor |
| Gotenberg | Self-hosted rendering boundary for teams prepared to own the runtime | CPU and memory sizing, browser lifecycle, patching, queue isolation, and failure recovery | Avoid when the on-call team cannot absorb renderer capacity and maintenance |
| Infrai | One REST API and one key cover 295 routes across 20 modules, so adding a backend capability does not require another SDK integration; public discovery supplies its schemas | Confirm the discovered PDF operation schema and ready vendors, then test the same corpus and SLO | Avoid when procurement requires a direct PDF-vendor contract or policy requires self-hosting |
The catch is ownership. Self-hosting gives the team direct placement and tuning control, but renderer upgrades, sandboxing, font packages, saturation, and recovery become its work. A dedicated managed provider narrows the service relationship, which can make accountability clearer, while an aggregated API reduces integration sprawl when the same product also needs storage, scheduling, notifications, or observability. Breadth behind one plain HTTP contract is a real operational advantage; it doesn't remove the need to qualify the PDF path.
Stick with Gotenberg when execution must remain inside infrastructure you operate and you have enough volume and staffing to justify owning it. Prefer a direct managed specialist such as DocRaptor, PDFShift, or PDFMonkey when its tested document behavior or commercial boundary is the deciding factor. The aggregated option fits when reducing keys, SDKs, and per-capability integrations matters across the wider backend. No row wins before the corpus and retention review are complete.
A minimal polling path that respects the job contract
The submission path should create an idempotent job; the polling path below deliberately does one smaller thing and does it completely. Given an existing job ID, it calls the verified GET /v1/pdf/job/get/{job_id} route, keeps the API key server-side, checks every response, and backs off on HTTP 429 while honoring Retry-After. Set PDF_API_BASE_URL to the service base URL and INFRAI_API_KEY in the server environment. For an already rendered file whose archive size must be reduced, the matching verified write operation is POST /v1/pdf/compress; its request body should be generated from the discovery schema rather than guessed.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: poll-job JOB_ID")
os.Exit(2)
}
baseURL := strings.TrimRight(os.Getenv("PDF_API_BASE_URL"), "/")
apiKey := os.Getenv("INFRAI_API_KEY")
if baseURL == "" || apiKey == "" {
fmt.Fprintln(os.Stderr, "PDF_API_BASE_URL and INFRAI_API_KEY are required")
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
body, err := getJob(ctx, http.DefaultClient, baseURL, apiKey, os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
func getJob(ctx context.Context, client *http.Client, baseURL, apiKey, jobID string) ([]byte, error) {
delay := time.Second
for attempt := 0; attempt < 6; attempt++ {
path := strings.Replace("/v1/pdf/job/get/{job_id}", "{job_id}", url.PathEscape(jobID), 1)
endpoint := baseURL + path
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return nil, fmt.Errorf("job lookup failed: status=%d body=%s", resp.StatusCode, body)
}
wait := delay
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
wait = time.Duration(seconds) * time.Second
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
delay *= 2
}
return nil, fmt.Errorf("job lookup remained rate-limited after 6 attempts")
}
This code does not infer undocumented response fields. Production code should decode the response schema exposed by discovery, persist each observed transition with the provider request ID, and stop polling when the documented terminal state arrives. Submission retries need an idempotency key tied to the tenant, report period, operation, and immutable input digest; a random value per attempt defeats deduplication.
Archive only after validation. Confirm that the output is a PDF, apply the expected page and content checks, store it privately, issue a short-lived link to the authorized caller, and record the deletion deadline. Don't send the service Authorization header to a returned presigned storage URL. The object store authorizes that URL on its own terms.
The final decision rule is blunt: buy the option that passes the representative corpus, sustains the required batch rate with headroom, exposes a retry-safe job contract, and can prove the required privacy and deletion behavior. Build or self-host when control of execution placement outweighs the resulting on-call load. Revisit the worksheet when templates, tenant count, or close windows change, because yesterday's comfortable queue can become next quarter's missed SLO.
Top comments (0)