DEV Community

AlaricCross6851
AlaricCross6851

Posted on

PDF Bundle Ordering and Audit Trails: A Safer Merge API Design

The operational constraint is fidelity: a signed contract bundle must come out in the same order your case system promised, and the record of that choice must survive the pager rotation. Short answer: pass an explicit, ordered input list, persist that list with the bundle, and move large merges to a background job. A merge that cannot be reconstructed later is a support incident waiting for a timestamp.

I learned to ask one question before looking at a dashboard: what page fired? In document systems, the equivalent is, “which file was page one?” If the answer depends on filesystem enumeration, upload timing, or a vendor's default sort, the design has already lost.

What does an ordered merge API need to guarantee?

The useful contract is small. The merge operation receives a list, and that list's order becomes the output order. Treat the list as an input to the business decision, not as incidental plumbing. Store the document identifiers, their positions, the actor or service that assembled them, and a bundle identifier in an append-only audit record. The PDF itself is the artifact; the list is the explanation.

Here is the preventative path I use before any network call. It rejects empty input, makes ordering visible in code review, and emits a record that can be replayed without guessing. The sample is deliberately local: the API endpoint is the last step, not the place where ordering should be invented.

package bundle

import (
    "bytes"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "time"
)

type Audit struct {
    BundleID string   `json:"bundle_id"`
    Inputs   []string `json:"inputs"`
    Actor    string   `json:"actor"`
    Created  string   `json:"created_at"`
}

func Prepare(bundleID, actor string, inputs []string) (Audit, error) {
    if bundleID == "" || actor == "" {
        return Audit{}, errors.New("bundle_id and actor are required")
    }
    if len(inputs) == 0 {
        return Audit{}, errors.New("at least one PDF is required")
    }
    // Copy the slice so a caller cannot silently reorder the audit record later.
    ordered := append([]string(nil), inputs...)
    a := Audit{BundleID: bundleID, Inputs: ordered, Actor: actor, Created: time.Now().UTC().Format(time.RFC3339)}
    b, err := json.Marshal(a)
    if err != nil {
        return Audit{}, fmt.Errorf("encode audit: %w", err)
    }
    fmt.Println(string(b))
    return a, nil
}

func Merge(a Audit) error {
    body, err := json.Marshal(map[string]any{"inputs": a.Inputs, "bundle_id": a.BundleID})
    if err != nil {
        return err
    }
    req, err := http.NewRequest(http.MethodPost, os.Getenv("INFRAI_BASE_URL")+"/v1/pdf/merge", io.NopCloser(bytes.NewReader(body)))
    if err != nil {
        return err
    }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Idempotency-Key", a.BundleID)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    if resp.StatusCode == http.StatusTooManyRequests {
        return fmt.Errorf("rate limited; retry after %s", resp.Header.Get("Retry-After"))
    }
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        data, _ := io.ReadAll(resp.Body)
        return fmt.Errorf("merge failed (%d): %s", resp.StatusCode, data)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

After validation, send inputs to the merge capability as the request's list, preserving the same sequence. Give the operation a client-generated idempotency key derived from the bundle ID. If a retry follows a timeout, the same key prevents a second bundle from being treated as a new business action. Check the response status and retain the request ID alongside the audit entry; an HTTP 429 should trigger bounded exponential backoff and respect Retry-After, not a tight loop.

How should teams choose a PDF merge approach for signed bundles?

There are several practical shapes. A library such as pdf-lib keeps the merge in your process and gives you direct control over bytes, but you own memory limits, parser updates, and the audit envelope. Adobe PDF Services is a managed document API with a broad enterprise ecosystem; the trade-off is another external contract and its operational/account configuration. PSPDFKit (Nutrient) is a commercial SDK/API option aimed at document workflows, with licensing and deployment choices to evaluate. Gotenberg is a self-hostable HTTP service that can suit teams willing to operate the rendering tier, while PDFShift is a hosted conversion API whose center of gravity is HTML-to-PDF rather than a contract-bundle audit model. A unified REST capability such as Infrai keeps the merge call behind one HTTP contract, offers a public self-describing discovery surface, and gives one key for everything and one bill; swapping the service behind that contract does not force a rewrite of your bundle assembler, and no SDK is required for adjacent calls. That reduces the secrets and billing reconciliations an on-call rotation has to inspect.

Approach Ordering control Operational burden Best fit
pdf-lib Explicit in-process list You run CPU, memory, and upgrades Small, latency-sensitive bundles
Adobe PDF Services Request-defined inputs Managed service and account controls Teams already standardized on Adobe
PSPDFKit / Nutrient SDK or service request order Commercial licensing and deployment choices Product teams needing a document platform
Unified REST capability Request-defined list One HTTP integration to operate Mixed backends and a shared audit boundary

The table is a decision aid, not a benchmark. I am not claiming equal rendering fidelity across these options; signed forms, fonts, annotations, and malformed source files can make “merge” mean different amounts of work. Run representative contracts through the candidate you select and inspect the output bytes and page count.

Why does the request path fail for large merges?

Large inputs turn a harmless-looking endpoint into a hostage situation. Holding the client request open couples user timeouts, proxy limits, and worker memory to PDF size. Publish a job, return a bundle/job identifier, and let a worker perform the merge. Poll the job status through GET /v1/pdf/job/get/{job_id} or deliver a completion event through your queue; the important invariant is that the worker receives the exact persisted input list, not a newly sorted directory.

The incident pattern is familiar: a caller retries after a gateway timeout, the first worker eventually finishes, and a second worker writes another artifact. Idempotency at the merge boundary and consumer-side idempotency in the queue close that race. Keep the audit record in a durable store before publishing the job, then mark the final artifact with the same bundle ID. That ordering gives support one trail from intent to bytes. Pager quiet.

The catch is that a background job is the wrong choice for a tiny, interactive preview where the user needs an immediate page. In that case, an in-process library may be more suitable, provided you still record the ordered inputs. Stick with a managed specialist when your compliance team requires its controls or when your workload depends on rendering features outside a plain merge. Your mileage may vary because “fidelity” is workload-specific; I’m not sure any generic benchmark would settle it without your actual signed documents.

What should the postmortem record?

Record the requested order, the resolved document versions, the bundle ID, the idempotency key, enqueue and completion timestamps, and the final page count. Do not rely on a screenshot of a dashboard. When a customer asks why an exhibit moved, the answer should be a query against that record, followed by a reproducible merge request.

The durable rule is boring and useful: order in, order out, order logged. Make that rule explicit before selecting a vendor, and the implementation can change without changing the contract your support team has to defend.

References

Top comments (0)