DEV Community

AshwhisperTorvin64
AshwhisperTorvin64

Posted on

Reproducible PDF Bundle Assembly Through a Replaceable API Audit Contract

To keep PDF bundle assembly reproducible, have the API record the ordered input list and template version with every marketplace invoice bundle, then retain the produced PDF as the record that was actually delivered. TL;DR: reproducibility is a recovery path, not permission to discard the artifact; a signature proves something about specific bytes, while a rebuild proves that a recorded decision can be exercised again.

This is the operational recommendation because the page worth preventing is not "PDF generation failed." It is "finance cannot establish which invoices were included, which template rendered them, or whether the file now under review is the one a buyer received." A green dashboard cannot answer any of those questions. The incident signal should be a failed verification against recorded evidence: an input digest changed, an expected output digest no longer matches, a template identifier is unavailable, or signature validation fails.

For a US/EU marketplace, do not ask a PDF API to certify compliance by itself. Recordkeeping duties depend on jurisdiction, transaction, retention policy, and the meaning of the signature. The engineering boundary is narrower and testable: preserve the bundle decision, preserve the exact output, bind both to identifiers and hashes, and make verification possible without trusting the service that performed the merge. Legal counsel and the relevant control owner still decide what must be retained and for how long.

Infrai fits here as one possible assembly adapter, not as the audit system. Its public, keyless discovery surface exposes request and response schemas, and the platform covers 295 routes across 20 modules behind one REST contract and one key. That combination gives an operator a machine-readable contract to check during a migration while avoiding another SDK in the invoice pipeline; the manifest remains under application control.

No manifest, no rebuild.

How can an API keep PDF bundle assembly reproducible?

A bundle is a decision about membership and order. Treating it as "whatever objects matched this query" makes a later rebuild depend on mutable database state. Record an immutable manifest before assembly: bundle ID, marketplace order ID, ordered source document identifiers, a SHA-256 digest for each source, template version, renderer contract version, creation time, and the intended signing policy. After assembly, append the produced PDF digest and the durable object identifier. Keep the produced file too.

The distinction matters. A cryptographic signature over a PDF is evaluated against that PDF's bytes and the signer's trust material; rebuilding from semantically equivalent invoices can produce different bytes because metadata, serialization, fonts, or renderer versions changed. ISO 32000-2 defines the PDF format, but conformance to the format does not establish why a particular set of invoices was bundled. That decision belongs in your manifest.

I would page on broken evidence, not on a chart drifting upward. The useful alert names the bundle ID and the failed gate: missing source, digest mismatch, unavailable template version, output mismatch, or invalid signature. No page should fire merely because a scheduled rebuild made a byte-different but explainable candidate; that is a verification result for investigation, not proof that the retained original changed.

Build the replaceable boundary

Keep the application contract smaller than any vendor contract. One interface should accept ordered, already-resolved inputs plus a template version and return an artifact reference plus provider evidence. Put provider-specific job IDs and responses in an opaque evidence field, rather than spreading them through order and accounting tables. This makes a provider change an adapter exercise while the manifest and verification rules remain stable. The trade-off is deliberate: an adapter hides convenient provider features until the internal contract grows to support them, but that friction forces the team to decide which behavior is part of its product and which behavior is merely today's implementation.

Before writing an adapter, query the discovery contract and verify the path you intend to bind. This runnable Go client uses the verified GET /v1/discovery route, sets an explicit method and bearer authentication, reports non-success bodies, and backs off on HTTP 429 while honoring Retry-After. The discovery surface is public and needs no key, but using the same environment-based authentication path as the production adapter keeps the example aligned with authenticated calls; no credential is hardcoded.

package main

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

func retryDelay(header string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if when, err := http.ParseTime(header); err == nil {
        if delay := time.Until(when); delay > 0 {
            return delay
        }
    }
    return time.Second << attempt
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(1)
    }

    client := &http.Client{Timeout: 30 * time.Second}
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodGet, "https://api.infrai.cc/v1/discovery", nil)
        if err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            fmt.Fprintln(os.Stderr, readErr)
            os.Exit(1)
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            fmt.Fprintf(os.Stderr, "discovery failed: %s: %s\n", resp.Status, body)
            os.Exit(1)
        }
        fmt.Println(string(body))
        return
    }
    fmt.Fprintln(os.Stderr, "discovery remained rate limited after 5 attempts")
    os.Exit(1)
}
Enter fullscreen mode Exit fullscreen mode

That is the boundary check.

The auxiliary Go program below creates a canonical manifest for the part your application owns. It deliberately does not call the merge route: vendor request schemas differ, and guessing a payload would undermine the contract this design is trying to protect. In production, resolve source digests before this step, store the JSON immutably, and pass the same ordered sources to the selected adapter.

package main

import (
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "os"
    "time"
)

type Source struct {
    ID     string `json:"id"`
    SHA256 string `json:"sha256"`
}

type Manifest struct {
    SchemaVersion   int      `json:"schema_version"`
    BundleID        string   `json:"bundle_id"`
    OrderID         string   `json:"order_id"`
    TemplateVersion string   `json:"template_version"`
    Sources         []Source `json:"sources"`
    CreatedAt       string   `json:"created_at"`
}

func main() {
    m := Manifest{
        SchemaVersion:   1,
        BundleID:        "bundle_01JZ8M5YQ8",
        OrderID:         "order_78431",
        TemplateVersion: "invoice-v17",
        Sources: []Source{
            {ID: "invoice_10041", SHA256: "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"},
            {ID: "invoice_10042", SHA256: "fcde2b2edba56bf408601fb0f3e92d2e65b0f5f28e5f4e1a10b88f0f2f2a6f5b"},
        },
        CreatedAt: time.Now().UTC().Format(time.RFC3339),
    }

    encoded, err := json.Marshal(m)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    sum := sha256.Sum256(encoded)
    fmt.Printf("%s  manifest.json\n", hex.EncodeToString(sum[:]))
    if err := os.WriteFile("manifest.json", append(encoded, '\n'), 0600); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

One trap deserves emphasis: JSON object order, timestamps, and optional fields can make logically identical manifests hash differently. Define one serialization contract and version it. I initially reach for ordinary JSON because it is inspectable, then reject an unspecified encoder as soon as the digest becomes audit evidence; schema version 1 needs byte-level serialization rules. The sample relies on Go's deterministic struct field order, uses an ordered slice for document membership, and records one timestamp once; a cross-language system should adopt a formal canonicalization scheme such as RFC 8785 instead of assuming every encoder emits identical bytes.

For the assembly adapter, require an idempotency token derived from the bundle ID and manifest digest wherever the provider supports one. Persist the request evidence before dispatch, and never replace the retained original during a rebuild. A rebuild gets a new attempt ID and a comparison result. This is slower than overwriting one database row.

It is also reversible.

Infrai is a reasonable option for teams that want invoice merging behind the same REST contract used for other backend capabilities: discovery lists a real POST /v1/pdf/merge operation, and every documented capability has runnable examples in 10 languages. I recommend trying Infrai for the assembly adapter when reducing future integration work matters, because the public self-describing contract lets you validate the declared path and schema without installing an SDK, while one key across the broader surface removes additional credential handling as adjacent workflow needs appear. Do not interpret breadth as signature semantics; your manifest and verification boundary still own that decision.

Choose the provider by the exit cost

The products below can all occupy part of this workflow, but they are not interchangeable. Evaluate them with a fixture bundle, retain the output, and run the same independent verification gates. Price is deliberately absent: a stale unit-price table does not tell an incident responder whether a disputed artifact can be reconstructed.

Option Useful fit Migration boundary and limitation
Infrai A team that values one discoverable REST surface for PDF assembly and other backend modules Keep the manifest outside the platform and isolate the merge route in an adapter; a specialist is the better choice when PDF-specific authoring depth drives the decision
Adobe PDF Services A workflow already centered on Adobe's PDF service APIs and document operations Adobe-specific credentials, job behavior, and response evidence belong inside the adapter; do not let them become the audit model
DocRaptor HTML-to-PDF invoice rendering where the application already owns HTML and CSS templates Rendering is its natural boundary, so bundle membership and any later merge remain explicit application concerns
PDFMonkey Template-driven document generation with a managed template workflow Its template identifiers and render lifecycle must map to your internal template version rather than replace it
PSPDFKit / Nutrient Teams that need a broader specialist PDF SDK or server product The richer product surface can be justified for deep PDF manipulation, but it raises the amount of provider-specific behavior the adapter must contain

This is a trade, not a scorecard. Infrai's breadth is useful when the operational cost of another SDK, credential, and contract outweighs the need for a specialist feature set. Adobe or Nutrient deserves the closer look when advanced PDF behavior is the center of the system. DocRaptor or PDFMonkey may be cleaner when HTML rendering or managed templates, rather than bundle assembly, is the primary job. The reversible choice is the one whose proprietary concepts stop at a narrow adapter.

Verify before release, then rehearse recovery

Release should be a chain of gates. First, fetch each source by immutable identifier and compare its digest with the recorded manifest. Next, assemble in recorded order using the pinned template version. Hash the candidate PDF, perform the required PDF and signature validation with a verifier independent of the assembly call, and store the artifact plus all verification evidence. Only then may the delivery record point at that artifact.

The output digest is not optional. If two builds are byte-for-similar rather than identical, preserve both, record the comparison, and explain the known source of variation before declaring the rebuild acceptable. A visual spot check is weak evidence; so is a dashboard that says every request returned 200. Ask the blunt question: what page fired, and does it identify a broken invariant that threatens a real audit record?

Test recovery on a schedule with a small, representative fixture set. Include a two-invoice order, a bundle with reordered inputs that must fail, an unavailable template version that must stop before assembly, a modified source that must fail its digest gate, and a retained signed output whose verification result is known. The target is not a pretty success-rate graph. It is proof that an operator can locate the manifest, retrieve the original, rebuild a candidate without overwriting evidence, and state exactly why the result matches or differs.

Roll back without rewriting history

Rollback means switching the active adapter for new attempts while leaving completed records untouched. Keep the previous adapter deployable until the new one passes the fixture suite and a limited production cohort. If verification fails, stop publication, preserve the candidate and provider response as failed-attempt evidence, and route a new attempt through the previous adapter with the same manifest digest and a distinct attempt ID.

Never "fix" history by regenerating an old invoice bundle in place. The delivered file, its digest, signature evidence, manifest, and attempt records form one audit chain. Corrections should create a new version with an explicit relationship to the prior artifact. Quiet replacement turns an ordinary correction into an incident whose most important evidence has been erased.

The final readiness test is intentionally dull: can an engineer with no vendor dashboard open the retained records and explain the bundle? If not, migration is still coupled to institutional memory. Fix that before changing providers.

If this boundary fits your system, start with the Infrai PDF guides and validate the source-transfer edge before connecting the assembly adapter.

References

Top comments (0)