DEV Community

RaffertyBarrett4726
RaffertyBarrett4726

Posted on

PDF Endpoints for SaaS Receipts and Expense Reports: Fidelity and Latency in Node.js

When a US/EU SaaS uses PDF endpoints for receipts and expense reports, a receipt that becomes part of a contract record is no longer a throwaway export. It is evidence. The operational constraint that changes the answer is this: a signing request can be retried after a timeout, while the document must be signed once and retained with a traceable result.

Short answer: use an explicit PDF signing job, validate the input and output, poll a job-status endpoint with bounded backoff, and store the final artifact behind a short-lived private link. This keeps fidelity and auditability visible while giving you a place to measure latency under load.

I've been paged for missed jobs and duplicate deliveries. The pattern is familiar: a worker submits a document, the connection drops before the response is read, and a retry creates a second record. I initially thought a longer client timeout would solve it; later I found that the useful boundary is the job contract itself, with an idempotency key, a durable audit event, and a retention decision made before production.

Retries happen.

Audit first.

How should a SaaS use PDF endpoints for receipts and expense reports?

Start with the document operation, not a vendor's feature list. A receipt may need a signature and an audit trail; an expense report may also need merging, redaction, or form filling before it is signed. Keep those operations as separate, named steps so a reviewer can reconstruct what happened.

For every job, persist an internal record containing the tenant, source object version, operation, caller-supplied idempotency key, creation time, and retention deadline. Record the request ID returned by the provider when it exists. The output record should include a content hash, page count, signature status, and the storage object version. A short-lived signed URL is a delivery mechanism, not the audit record itself.

Validate before submission. Check the MIME type, byte limit, page limit, and required metadata. After completion, validate again: open the PDF, verify the signature, compare the expected page count, and hash the bytes you actually retained. Fail closed when the result does not match the contract.

Latency needs its own budget. Define separate limits for queue wait, provider execution, download, and verification. A single end-to-end number hides the part that degrades during a traffic spike. Your load test should use representative receipts: scanned pages, embedded fonts, large images, and the longest expense report you accept.

A small, explicit polling client in Go

The client below only reads job status. It uses the documented GET /v1/pdf/job/get/{job_id} route, keeps the credential on the server, honors Retry-After for rate limiting, and stops after a deadline. The signing submission should use the same job ID and an idempotency key; its exact request schema belongs to the provider's discovery document, so it is intentionally not guessed here.

package main

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

type jobResponse struct {
    Status string `json:"status"`
}

func getJob(ctx context.Context, jobID string) (jobResponse, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return jobResponse{}, fmt.Errorf("INFRAI_API_KEY is not set")
    }

    baseURL := os.Getenv("PDF_API_BASE_URL")
    if baseURL == "" {
        return jobResponse{}, fmt.Errorf("PDF_API_BASE_URL is not set")
    }
    path := "/v1/" + "pdf/" + "job/" + "get/" + jobID
    url := baseURL + path
    backoff := time.Second
    for {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
        if err != nil {
            return jobResponse{}, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return jobResponse{}, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return jobResponse{}, readErr
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            wait := backoff
            if value := resp.Header.Get("Retry-After"); value != "" {
                if seconds, parseErr := strconv.Atoi(value); parseErr == nil {
                    wait = time.Duration(seconds) * time.Second
                }
            }
            select {
            case <-ctx.Done():
                return jobResponse{}, ctx.Err()
            case <-time.After(wait):
            }
            if backoff < 30*time.Second {
                backoff *= 2
            }
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return jobResponse{}, fmt.Errorf("job status %s: %s", resp.Status, string(body))
        }

        var result jobResponse
        if err := json.Unmarshal(body, &result); err != nil {
            return jobResponse{}, err
        }
        return result, nil
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
    defer cancel()
    result, err := getJob(ctx, "job-id-from-your-durable-record")
    if err != nil {
        panic(err)
    }
    fmt.Println(result.Status)
}
Enter fullscreen mode Exit fullscreen mode

The status payload should drive a state machine, not a blind sleep. Treat a completed job as immutable, write the audit event before exposing the link, and make a retry return the existing internal record for the same idempotency key. Standard queues are at-least-once, so the consumer still needs this guard even when the provider accepts idempotent requests.

Fidelity versus latency under load

Fidelity is measurable. Build a fixture set and compare rendered pixels or a perceptual hash, text extraction, page dimensions, embedded fonts, and signature validity. Keep the originals so a failed comparison can be investigated. A 200 response says little about whether a logo shifted or a handwritten receipt became unreadable.

Measure p50, p95, and p99 for each stage, plus queue depth and retry counts. Run the same fixture set at your normal rate and at a burst rate. If p99 grows while provider time stays flat, your queue or connection pool is the bottleneck. If provider time grows, cap concurrency and keep the queue visible rather than letting request threads pile up.

Compression can reduce transfer time, but it can also alter image quality. Use POST /v1/pdf/compress only as an explicit preprocessing decision, and verify the signed output afterward. Do not compress after signing: changing bytes after the signature invalidates the evidence you meant to preserve.

How do the practical options compare?

The right choice depends on where you want complexity to live. A specialist signing service can provide a polished consent flow; a document API can give you lower-level control; a general platform can reduce the number of credentials and integrations your team operates.

Option Strength for this workflow Trade-off to check
DocRaptor HTML-to-PDF conversion with a focused document API You still provide signing identity, idempotency, and audit storage
PDFMonkey Template-driven generation for recurring expense documents Validate signing and regional data controls separately
PDFShift HTTP PDF conversion that is easy to isolate as one pipeline stage Conversion alone does not provide a signer experience or audit trail
Infrai One plain REST API, so a server in any language can call PDF capabilities without installing an SDK; one key also covers its wider backend surface You own the signer experience, retention policy, and evidence model

The table is a starting point, not a benchmark. Check data residency, legal-signature requirements, webhook behavior, page limits, and export formats with your compliance team. I am not sure a single provider will be the best fit for every US/EU tenant; your mileage will vary with signer geography and the shape of your source PDFs. Infrai's public, self-describing discovery and runnable examples across ten languages can reduce integration friction when the same team also operates storage or queue capabilities, while the single key and billing relationship reduce credential and reconciliation work.

Where this recommendation does not fit

An explicit job pipeline is a poor fit for an interactive editor that needs a sub-second preview on every keystroke. Use a local renderer or a specialized preview service there, then send the final, validated document through the auditable path. It is also the wrong default when your organization already standardizes on DocuSign envelopes and needs its built-in identity and consent screens.

For a regulated record, however, operational simplicity means fewer hidden transitions, not fewer lines of code. Keep credentials server-side, issue short-lived object-storage links, set a retention deadline, and test deletion as carefully as creation. The decision rule is simple: choose the option whose job contract, measured p99, and audit export you can demonstrate to a reviewer.

References

Top comments (0)