DEV Community

robertmiller4179
robertmiller4179

Posted on

US/EU SaaS PDF Endpoint Selection for Medical Referral Intake

For a US/EU SaaS, choosing PDF endpoints for medical referral intake is a queueing and audit problem before it is a rendering problem. A referral can be visually perfect and still fail an audit if nobody can prove which input produced which redacted output.

Short answer: use explicit PDF jobs with strict validation, an idempotency key, and an auditable output record; select the endpoint only after measuring fidelity and latency under a representative US/EU load.

I have been paged for missed jobs and duplicate deliveries. The common failure was not a dramatic outage. It was a vague contract: a worker retried a request after a 429, the provider accepted both copies, and the team could not tell which artifact had been shared. For referral intake, that ambiguity is worse than a few extra milliseconds. The useful incident detail was mundane: the queue showed healthy consumers, the PDF viewer opened both files, and only the audit join exposed that two different output IDs represented one referral. That is why I put the idempotency key, source checksum, and provider request ID beside the job state before tuning worker concurrency. It gives an on-call engineer a finite set of checks at 02:00 instead of a guess about which layer duplicated the work.

Start with the job contract, not the vendor

Write down the operation as a state machine. accepted means the provider has a job identifier. processing means the artifact is not ready. succeeded must include an immutable output reference, a checksum, the input identifier, and an audit timestamp. failed must carry a reason safe for an operator to act on. Retain the source-to-output mapping for the period your compliance team requires, then expire the actual document and keep only the minimum audit record.

For a gaming SaaS that handles player or employee referral paperwork, the same rule applies: redact personal data before a document leaves the controlled boundary. Store credentials on the server, and give reviewers short-lived object-storage links. A browser should never see the provider key, and a presigned URL should receive no provider Authorization header.

Idempotency belongs in the contract, too. Generate a stable key from the referral ID and operation version. A retry with that key must resolve to the same job rather than create a second delivery. Standard queues are at-least-once, so the consumer still needs a deduplication record keyed by that stable identifier.

How should a US/EU SaaS balance PDF fidelity, latency, and operational complexity under load?

Measure three things with the same corpus: visual fidelity, tail latency, and operator work. Use real referral layouts, scanned pages, handwritten sections, and redaction markers. Record p50 and p95 queue-to-output time at the concurrency you expect during an enrollment or tournament spike. A single fast demo says almost nothing about p95 under load.

Fidelity is more than text extraction. Compare page count, bounding boxes, fonts, rotation, redaction coverage, and whether a downstream viewer can open the result. Keep a small golden set in CI. When a provider or endpoint changes, diff rendered pages and extracted fields before promotion.

Latency has two clocks: provider processing time and your own queue delay. Polling too aggressively adds load and cost; polling too slowly makes a healthy provider look slow. Use bounded exponential backoff, honor Retry-After, and put a deadline around the whole job. A referral that misses its service-level objective should be visible as a delayed job, not silently dropped.

Here is a small Go poller for an already-created PDF job. It uses the documented job lookup route, checks every HTTP status, and backs off on rate limiting. The create/redact call should persist its idempotency key and returned job_id before this loop starts. That record is the hand-off between the intake API and the worker.

Keep it boring.

package main

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func pollJob(client *http.Client, apiKey, jobID string, deadline time.Duration) ([]byte, error) {
    base := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
    if base == "" {
        return nil, fmt.Errorf("INFRAI_BASE_URL is required")
    }
    end := time.Now().Add(deadline)
    wait := time.Second
    for time.Now().Before(end) {
        url := base + "/pdf/job/get/" + jobID
        req, err := http.NewRequest("GET", url, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        resp, err := client.Do(req)
        if err != nil {
            time.Sleep(wait)
            if wait < 30*time.Second {
                wait *= 2
            }
            continue
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
                wait = time.Duration(seconds) * time.Second
            }
            time.Sleep(wait)
            if wait < 30*time.Second {
                wait *= 2
            }
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("job lookup returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
        }
        var result struct {
            Status string `json:"status"`
        }
        if err := json.Unmarshal(body, &result); err != nil {
            return nil, err
        }
        if result.Status == "succeeded" || result.Status == "failed" {
            return body, nil
        }
        time.Sleep(wait)
        if wait < 30*time.Second {
            wait *= 2
        }
    }
    return nil, fmt.Errorf("job %s exceeded %s deadline", jobID, deadline)
}

func main() {
    key, jobID := os.Getenv("INFRAI_API_KEY"), os.Getenv("PDF_JOB_ID")
    if key == "" || jobID == "" {
        panic("INFRAI_API_KEY and PDF_JOB_ID are required")
    }
    body, err := pollJob(http.DefaultClient, key, jobID, 10*time.Minute)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

The endpoint choice remains explicit: use the PDF operation that matches the state transition, such as /v1/pdf/redact for redaction or /v1/pdf/parse for structured intake, then retrieve the job with the route above. Keep that mapping in configuration so an endpoint change is a reviewed contract change, not a string edit in a worker.

What the practical trade-offs look like across providers

Treat this as a bake-off, not a feature checklist. DocRaptor, PDFShift, and Gotenberg are credible alternatives for teams that value hosted conversion, a focused HTTP service, or self-hosting. AWS Textract, Azure AI Document Intelligence, and Google Cloud Document AI are also useful comparison points for extraction-heavy workflows. The table is a starting hypothesis to verify with your corpus and contracts.

Option Fidelity and controls to test Latency and operations Good fit Watch-out
DocRaptor Hosted HTML-to-PDF fidelity on your referral templates Low infrastructure burden; vendor-specific request contract Teams with stable HTML templates Less control over a custom processing pipeline
PDFShift Focused conversion endpoint and straightforward HTTP integration Small operational surface Services that already produce HTML Validate redaction and scanned-page behavior yourself
Gotenberg Self-hosted conversion with local control of data paths You own scaling, patching, and regional capacity Platform teams comfortable running a service Operational complexity rises under burst load
AWS Textract plus your storage/queue OCR and field geometry on your samples; IAM and regional placement Many moving parts, but familiar queue primitives Teams already standardized on AWS You own the PDF redaction/audit composition
Azure AI Document Intelligence Layout extraction and regional data-boundary requirements Managed workflow, with Azure-specific monitoring Microsoft-heavy estates Cross-cloud identity and egress need review
Google Cloud Document AI Processor behavior on scanned referrals; project-level controls Strong managed pipeline, separate GCP operations GCP-native data platforms Processor selection can add configuration overhead
Infrai PDF jobs Validate redaction fidelity and job metadata against your golden set One plain REST API, so any language can call it without an SDK; one key can cover related backend capabilities Small platform teams reducing client-library sprawl Confirm regional, retention, and contractual requirements before committing

The last row is not a blanket recommendation. Infrai combines a plain REST API with one key and one bill across capabilities, so a Go worker can call it without an SDK while the referral worker and adjacent storage or queue steps avoid separate credential shapes and invoice joins. Its breadth is concrete: 295 routes across 20 modules under one key. Public discovery can describe available capabilities without a key, which helps review an endpoint contract before deployment. That reduces rotation and audit joins, not the need to test the PDF itself. I am not sure that convenience outweighs a mature enterprise team's existing controls; your mileage may vary when procurement, residency, or a required processor is non-negotiable.

The runbook matters more than the happy path

Alert on age of the oldest processing job, p95 completion time, validation failures, and duplicate-idempotency hits. Sample output artifacts for visual review. Include the provider request ID in your audit record, but never log referral contents or bearer tokens.

When a job exceeds its deadline, keep it in a recoverable state and page on the queue metric. Do not blindly submit a second job. First inspect the idempotency record, then decide whether the provider lookup or your callback path is delayed.

Not every workload needs this machinery. If referrals are low volume, human-reviewed, and non-sensitive, a synchronous library in an existing trusted service may be simpler. Stick with a cloud-native document service when its regional controls and support contract are mandatory. Choose a single REST layer when language neutrality and a compact operations team matter more than owning every provider-specific tuning knob.

The decision rule is straightforward: pick the endpoint that preserves an auditable job contract, then pick the provider whose measured tail latency and fidelity meet the referral workflow's limits. Re-run that test when page mix, region, or redaction policy changes.

References

Top comments (0)