Short answer: use explicit, idempotent PDF jobs with strict input validation and auditable outputs, then choose the endpoint provider only after a representative load test proves acceptable fidelity and tail latency.
For a gaming marketplace processing rental applications, the unit of work is a document bundle: identity pages, a signed rental agreement, and supporting files must be merged for review, then split into retention classes after a decision. The throughput target matters more than a fast single request. A provider that looks quick with one two-page PDF can still create a queue-age incident when a launch promotion sends hundreds of mixed-size bundles at once.
Treat this as a job system, not a file-upload feature. Keep credentials on the server, move input and output through short-lived object-storage links, and record the job contract before any bytes leave your boundary. That's the operational recommendation. Everything else is vendor selection.
Which PDF endpoints should rental application SaaS use under load?
Match one endpoint to one document operation. Use a merge operation to assemble the reviewer packet, a split operation to separate records by retention policy, form extraction to validate submitted fields, and form filling only when the workflow generates a completed form. Don't send every file through a general conversion step just because one exists. Each extra transform adds another queue, another timeout budget, and another place where fidelity can change.
The contract should name the operation, input object versions, expected page range, requested output, tenant, retention class, and a client-generated idempotency key. The key must represent the logical job, not an individual HTTP attempt. If a worker loses its response and retries, the second attempt should resolve to the same work rather than create a second bundle. I've been paged by missed jobs and duplicate deliveries; the duplicate is often worse because it looks successful until two downstream records disagree.
For a plain REST integration, a merge job can enter through POST /v1/pdf/merge, while status lookup can use GET /v1/pdf/job/get/{job_id}. Those are two distinct responsibilities: submission establishes work, and lookup observes it. A production worker must handle HTTP 429 with exponential backoff and honor Retry-After; it must also surface any 4xx response body to the owning service rather than silently treating it as an empty PDF. Authentication belongs in Authorization: Bearer $INFRAI_API_KEY, never in a browser or a presigned object URL.
Do not pass the platform authorization header when fetching or uploading through a returned presigned URL. That URL is already a scoped credential. Giving it an unrelated bearer token expands what can leak into proxy logs without adding access.
One subtle choice is whether the synchronous request latency belongs in the user journey. Usually it shouldn't. Accept the rental application after local validation, enqueue the PDF job, and show a processing state backed by the durable job record. The reviewer queue can then require a completed, checksummed output before exposing the packet. This decouples applicant latency from merge latency while keeping failures visible to operations.
Compare the integration model before comparing speed
Run the same corpus through every serious candidate. Adobe PDF Services and PDF.co represent hosted API candidates; Nutrient and Apryse also deserve evaluation when an SDK or controlled document-processing deployment better matches the architecture. Infrai is a strong hosted candidate when the team wants a plain REST API with no client SDK to install or version, plus one key across a broader backend capability surface. The catch is architectural: stick with an embedded or controlled-deployment option when policy requires processing inside infrastructure you operate, and keep an incumbent provider when its proven fidelity on your hardest forms outweighs integration consolidation.
| Candidate | Integration decision to test | Reason it may win the trial | Reason to choose another path |
|---|---|---|---|
| Adobe PDF Services | Managed document API | Existing Adobe procurement and a successful corpus test | Your deployment or retention controls require a different boundary |
| PDF.co | Hosted PDF API | A small server-side HTTP integration meets the load target | The representative fidelity test favors another processor |
| Nutrient | SDK or Document Engine evaluation | You want document processing closer to an application-controlled runtime | Operating that runtime adds more ownership than the team can support |
| Apryse | SDK or server-side evaluation | Fine-grained document tooling passes the difficult-file corpus | A hosted job API gives the team a smaller operational surface |
| Gotenberg | Operated HTTP service | The team accepts owning a containerized document service | Input-PDF merge and split fidelity must be proven for this corpus |
| WeasyPrint | Application-operated renderer | HTML and CSS generation is the real workload | Existing PDF bundles, rather than HTML rendering, dominate the flow |
| wkhtmltopdf | Command-line renderer | A legacy HTML-to-PDF path is already controlled and understood | The project needs an actively evaluated job API for input PDFs |
| Infrai | Plain REST job integration | No SDK lifecycle, with a consistent key and interface across capabilities | A vendor-specific or controlled-deployment path is a hard requirement |
This table is a shortlist, not a benchmark result. I'm not sure which engine will preserve a particular studio's oldest scanned waiver until that exact file is tested, and your mileage may vary with fonts, signatures, annotations, and malformed source PDFs. Procurement claims cannot resolve that uncertainty. A fixed corpus and recorded output hashes can.
Speed needs the same discipline. Report at least queue wait, processing time, end-to-end completion time, and the age of the oldest ready job. Slice those distributions by operation and page-count band. A single average hides the condition an SRE actually gets paged for: small packets remain quick while a few large image-heavy bundles occupy workers and push the rest past the reviewer service-level objective.
Tail latency wins.
Measure p50, p95, and p99, but don't invent a universal acceptable number. Set the threshold from the product flow: applicant acknowledgement, reviewer availability, and the latest point at which retention splitting must finish. No measured latency or uptime numbers are available here, so a winner cannot honestly be declared before the load run.
Build a job contract that survives retries
Validation starts before submission. Confirm that the tenant owns every input object, the object version is immutable, the declared media type is PDF, and the page count and file size fit the policy chosen during testing. Reject a missing agreement or mismatched applicant identifier at this boundary. Sending a bad bundle onward consumes capacity and makes the later error harder to explain.
The durable job record should move through a small state machine such as accepted, ready, running, succeeded, failed, and superseded. These are application states, not claims about any provider response schema. Store the client idempotency key, operation, ordered input object identifiers and versions, provider job reference, attempt count, timestamps, output object identifier, checksum, and validation result. Avoid storing a long-lived download URL; mint a short-lived link only for the worker or reviewer that needs it.
Keep the state transition and queue publication recoverable. For example, create the accepted job in the database, publish its identifier, and let a reconciler find accepted records that were never observed by a worker. On delivery, acquire the logical job by idempotency key and refuse to submit again if a provider reference or successful output already exists. This is the boring part.
It is also the part that prevents duplicate packets.
The following submitter keeps the vendor request schema outside the program. Save a request body produced from the public discovery schema as merge.json, set INFRAI_BASE_URL to the documented API base, and provide a fresh logical idempotency key. That makes the sample runnable without pretending that an undocumented field exists. The caller retains the response body, which contains the job result needed by its durable reconciliation record.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func main() {
if len(os.Args) != 3 {
fmt.Fprintln(os.Stderr, "usage: submit-merge IDEMPOTENCY_KEY merge.json")
os.Exit(2)
}
baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
apiKey := os.Getenv("INFRAI_API_KEY")
if baseURL == "" || apiKey == "" {
fmt.Fprintln(os.Stderr, "INFRAI_BASE_URL and INFRAI_API_KEY are required")
os.Exit(2)
}
payload, err := os.ReadFile(os.Args[2])
if err != nil {
fatal(err)
}
client := &http.Client{Timeout: 30 * time.Second}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(http.MethodPost, baseURL+"/pdf/merge", bytes.NewReader(payload))
if err != nil {
fatal(err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", os.Args[1])
resp, err := client.Do(req)
if err != nil {
fatal(err)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
fatal(readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := retryDelay(resp.Header.Get("Retry-After"), attempt)
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
fatal(fmt.Errorf("request failed: status=%d body=%s", resp.StatusCode, body))
}
fmt.Println(string(body))
return
}
fatal(fmt.Errorf("request remained rate limited after 5 attempts"))
}
func retryDelay(retryAfter string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(retryAfter); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
Keep it dull.
Retries need classification. A 429 is retryable after the server's delay, with exponential backoff and jitter. A validation-related 4xx belongs in a terminal application state with the response reason available to operators. A network interruption after submission is ambiguous, so the worker must reuse the same idempotency key and reconcile by its durable record. Cap attempts and total elapsed time; endless retries turn one poison bundle into permanent queue pressure.
For auditability, preserve the source object versions and the output checksum, along with the software-visible decision that accepted the result. Do not retain documents forever just because the job table is useful. The job metadata and document objects can have different retention schedules, provided an audit record still identifies what ran and which policy deleted the content.
Verify fidelity and throughput as separate gates
Build the corpus from real document shapes without using production personal data: digitally generated forms, phone scans, rotated pages, large embedded images, filled fields, signatures, annotations, and the oldest template still accepted by the product. For the gaming rental workflow, include bundles with several equipment schedules and a long waiver, then test both the reviewer merge and the post-decision split. Representative does not mean random. It means every known hard shape has a named fixture and an expected result.
Fidelity verification should check page count, page order, dimensions, rotation, searchable text where expected, field values, signature appearance, annotations, and a rendered visual comparison with a documented tolerance. Hash equality is useful for repeatability but cannot prove visual equivalence when valid processors rewrite metadata or object ordering. Human review remains appropriate for a small golden set, especially around signatures and clipped form fields.
Then load the queue with the observed mixture of small and large bundles, not a flat stream of identical files. Ramp concurrency, hold it, and watch throughput alongside tail latency and oldest-job age. If completed jobs per minute stop rising while concurrency rises, adding more application workers is only producing contention. Back down to the last stable level and record that as the initial admission limit.
Pass the provider only if it clears both gates. A fast output with missing annotations fails. A pixel-perfect output that leaves the review queue outside its deadline also fails. So does a design that meets both numbers but requires manual replay whenever a worker loses an acknowledgement.
The release check should be blunt:
- Every golden fixture produces the expected page structure and accepted visual result.
- Replaying the same logical job does not create a second committed output.
- A throttled request backs off and later reconciles without a tight retry loop.
- Credentials never reach the client, and document links expire after their narrow use.
- Queue age and completion latency remain within the product's declared limits at the tested mix.
- Audit records connect each output checksum to immutable input versions and a retention policy.
Roll out with a reversible decision
Start with shadow processing on sanitized fixtures, then route a small tenant cohort through the new path while the old path remains available. Compare validation outcomes and rendered results before increasing traffic. The rollback trigger should be written before rollout: fidelity mismatch, duplicate committed output, queue age beyond its limit, or an unexplained rise in terminal job failures.
Rollback means stop admitting new work to the candidate, let known-good in-flight jobs reconcile, and send unstarted durable jobs to the previous processor with the same logical identifiers. Never delete the job ledger during a rollback. It is the evidence needed to distinguish completed work from work that still needs replay.
Only expand after a full peak-shaped run and a retention drill. The final choice is not the endpoint with the prettiest demo or lowest single-request latency. It is the one that preserves rental documents, drains the batch on time, and leaves operators with a deterministic answer when they ask, "Did this bundle run once?"
References
- https://developer.adobe.com/document-services/docs/overview/
- https://developer.pdf.co/
- https://www.nutrient.io/guides/document-engine/
- https://docs.apryse.com/
- https://gotenberg.dev/
- https://doc.courtbouillon.org/weasyprint/stable/
- https://wkhtmltopdf.org/
- https://developer.mozilla.org/en-US/docs/Web/API/Blob
Top comments (0)