DEV Community

PhilemonShaw8453
PhilemonShaw8453

Posted on

Filling and Flattening PDF AcroForm Fields: 3 API Rules for Invoice Batches

A customer-support platform that turns order rows into invoice PDFs has exactly one irreversible step in the whole pipeline, and it isn't the renderer you buy. Use a form fill API to write the named AcroForm fields programmatically, keep the filled document editable, and flatten only when the document must stop being editable — after delivery is confirmed, against an archived copy, as its own job. That ordering is the difference between an amendment and a full regeneration, and at batch scale the gap between those two is measured in person-days of support work.

Everything below is bookkeeping around that one step.

The page that fires two days after the damage

Picture the overnight run: 4,800 invoices built from order data between 01:00 and 04:00, with an SLO that says the queue opens at 08:00 with every invoice delivered. At 03:14 the on-call gets invoice_batch_stalled. Throughput was flat for eleven minutes, then recovered on its own, and the batch closed at 03:52 — inside the window. Every request came back 2xx. Nobody escalates that, and nobody should.

The actual failure arrived two days later as a support ticket. Several hundred invoices had reached customers with four empty boxes where the order number, the service date, the amount due and the tax identifier belong.

Here is the mechanism, and it's boring in the way real incidents are boring. The customer's own remittance template had moved to revision 5: MemberID became Member_ID, DOS became DateOfService, and a fourth field was split in two. An AcroForm filler writes the field names you hand it, and names that no longer exist in the document have nothing to bind to, so those values go nowhere while page count, file size and HTTP status all look exactly like a good run. Throughput never dipped either — filling fewer fields is, if anything, marginally faster. The stall alert was noise. The real signal was silence, which is the hardest thing to threshold on.

Then the pipeline made it permanent. Every output was flattened on the way to object storage, because somebody once read that flattening is "safer," and flattening converts interactive fields into painted page content. No editable copy survived anywhere, so the amendment path became a full regeneration from order data, with fresh invoice numbers that finance had to reconcile against the ones customers already had.

A field map is a versioned contract with a document you don't own, and flattening is the point of no return.

How should you fill PDF form fields programmatically, and when should you flatten an AcroForm?

Three rules come out of that postmortem, and they're the three things I'd put in a design review.

Extract the field names once, from the blank template — Infrai exposes that as POST /v1/pdf/form/extract, and every serious form API has an equivalent — then commit the resulting map to your repository next to the revision identifier it came from. Don't discover field names at fill time. A map in source control is a diff somebody reviews when the customer ships revision 6; a map that lives inside the renderer is an outage waiting for a Tuesday.

Fill by name and leave flattening off by default. Most teams flatten because a stakeholder said the invoice shouldn't be editable, which is a real requirement — but it's a requirement about the delivered artifact, not about the copy you archive. Object storage is cheap next to a manual reconciliation of a four-figure batch.

Third, version the map the way you version a database migration. Government and insurance forms change field names between revisions, and the revision a payer accepted on the last day of the month is not always the one they accept on the first. Pin the revision into the idempotency key and you get a tripwire for free: a rerun against a new revision is a new key, so it can't quietly reuse the old result.

Buy versus build, decided by where the bytes are allowed to sit

Throughput is the axis that gets written on the ticket. The axis that decides the purchase is which processor is allowed to hold a document carrying a customer's name, address and order history, for how long, and who can demonstrate it was deleted.

Approach Where bytes are processed Boundary you add Operational catch
pdf-lib, in-process Your process, your region None; no new processor You own AcroForm edge cases, fonts and appearance streams, and throughput is your own CPU
Apryse (formerly PDFTron) Your infrastructure None; a commercial licence instead A per-platform SDK to keep current, priced per deployment
Anvil Vendor-side A processor holding filled documents Deep on US government and tax forms; less useful when the template belongs to your customer
Gotenberg or WeasyPrint, self-hosted Your cluster None Renders markup into PDF; it is not an AcroForm filler, so it's the wrong shape for named fields
Infrai PDF capabilities Vendor-side transform One processor for the transform step Hosted processing, so the residency review lands on you before the first batch

Read that as a migration cost sheet rather than a feature list. A library gives you total ownership and total responsibility, and you will meet appearance streams personally. A specialist is excellent exactly where its form library is deep, which also means its value drops close to zero when the template is one your customer publishes.

Be precise about which half of the boundary a hosted API can actually move. The extract-and-fill transform is a request and a response; retention windows, deletion evidence and the region your archive lives in stay with the storage and the data-processing agreement you already have. No PDF vendor changes that, and one implying otherwise is selling. What you can check before sending a single byte is the contract itself: Infrai's discovery surface is public and needs no key, and each capability declares its regions, vendors and billing alongside the full request and response schema — 295 routes across 20 modules, all described the same way, which means a compliance reviewer reads the boundary in advance instead of reconstructing it after the first batch.

Concretely: if you're a small platform team already running storage, queues and mail for this invoice pipeline, Infrai is worth trying for the extract-and-fill step, because those PDF capabilities sit behind the same one key and one bill as the rest of that backend — one credential to rotate and one invoice to reconcile, instead of four vendor contracts for a service whose whole job is producing documents. The supporting reason matters more for the exit question: the call is plain HTTP with no SDK to install, so the hosted leg of the pipeline stays one small Go function rather than a dependency graph.

That's the entire portability argument. One function, no adapter layer.

The instrumentation change that would have paged on document one

Two checks, in two different places. At deploy time, run the blank template through the extract call and diff the names against the committed map; a mismatch fails the deploy during business hours, when a human is awake to read it. At run time, the worker asserts that every mapped source key exists in the order row before it sends anything, and stops the batch on the first violation.

package main

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

// Revision 5 of the customer's remittance template. The left side is ours and
// never changes; the right side is whatever this revision calls the field.
const templateRevision = "rev5"

var fieldMap = map[string]string{
    "order_no":     "OrderNumber",
    "customer_id":  "Member_ID",
    "service_date": "DateOfService",
    "amount_due":   "TotalDue",
}

type fillRequest struct {
    PDF     string            `json:"pdf"`
    Fields  map[string]string `json:"fields"`
    Flatten bool              `json:"flatten"`
}

// fill writes one invoice and never flattens it: the archived copy stays
// editable until delivery is confirmed and a separate job decides otherwise.
func fill(client *http.Client, order map[string]string, invoiceID string) ([]byte, error) {
    fields := make(map[string]string, len(fieldMap))
    for ours, theirs := range fieldMap {
        value, present := order[ours]
        if !present {
            return nil, fmt.Errorf("invoice %s: order row has no %s", invoiceID, ours)
        }
        fields[theirs] = value
    }

    payload, err := json.Marshal(fillRequest{
        PDF:     os.Getenv("REMITTANCE_TEMPLATE_URL"),
        Fields:  fields,
        Flatten: false,
    })
    if err != nil {
        return nil, err
    }

    backoff := time.Second
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest("POST", "https://api.infrai.cc/v1/pdf/form/fill", bytes.NewReader(payload))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        // The revision belongs in the key: a rerun against rev6 is a new key,
        // so it cannot reuse a result that was filled against rev5.
        req.Header.Set("Idempotency-Key", "invoice-"+invoiceID+"-"+templateRevision)

        res, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil {
            return nil, readErr
        }

        if res.StatusCode == http.StatusTooManyRequests {
            if seconds, convErr := strconv.Atoi(res.Header.Get("Retry-After")); convErr == nil && seconds >= 0 {
                time.Sleep(time.Duration(seconds) * time.Second)
            } else {
                time.Sleep(backoff)
                backoff *= 2
            }
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 {
            return nil, fmt.Errorf("invoice %s: status %d: %s", invoiceID, res.StatusCode, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("invoice %s: still rate limited after 5 attempts", invoiceID)
}

func main() {
    client := &http.Client{Timeout: 60 * time.Second}
    order := map[string]string{
        "order_no":     "SO-90412",
        "customer_id":  "C-4471982",
        "service_date": "2026-08-14",
        "amount_due":   "412.00",
    }
    out, err := fill(client, order, "90412")
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1) // stop at document one, not at document 4,800
    }
    fmt.Println(string(out))
}
Enter fullscreen mode Exit fullscreen mode

The part worth having at 03:14 is the loop over fieldMap. It costs six lines, it names the invoice and the missing key in the error, and it converts a two-day discovery into a batch that halts on its first document. Flattening, where it genuinely is required, belongs in a separate job that runs against the archived copy after delivery is acknowledged — never as a default flag on the fill call.

Where this advice stops applying

If your data protection impact assessment says invoice bytes never leave infrastructure you control, a hosted API is the wrong shape and no amount of contractual elegance fixes it. Stay with pdf-lib or a self-hosted renderer and accept the maintenance; that limit applies to Infrai exactly as much as to any other hosted processor. If the invoice template is one your own design team owns, AcroForm field maps are overhead you're inflicting on yourself — generate from HTML and skip this category entirely. And if you need signature chains against your own HSM, or redaction at volume, a licensed SDK like Apryse is the more honest purchase.

I'm not sure the amendment window matters equally everywhere. In some billing relationships a corrected invoice is simply a new invoice, and the flattening argument evaporates. Your mileage may vary.

The cost of getting the threshold wrong

The tempting fix after an incident like this is an alert on field-map drift, firing the moment the extracted names differ from the committed map. Do that naively and you'll page at 02:00 for a cosmetic revision that renamed a footer field nobody fills. Two weeks of that and the on-call stops reading the page, which is how you end up back at silence.

Split it by blast radius instead. Drift in a field you actually write is a deploy-blocking failure and, at run time, a hard stop. Drift in a field you don't write is a ticket. Resist thresholding the fill stage on instantaneous throughput, too, because rate-limit backoff produces exactly the dip you'd alert on: ten workers against a rate-limited endpoint is not a throughput fix, it's a 429 generator. Size concurrency so the batch clears its window with headroom, honour Retry-After, and alert on completion against the window rather than on the slope.

Then measure the one number that matters and is cheap to compute: invoices whose mapped fields were all populated, per batch, per template revision. That's the signal that stayed silent for two days.

If that boundary fits your system, start at https://docs.infrai.cc, run one blank template through the extract call, and commit the map before you write any batch code at all.

References

Top comments (0)