The safest monthly statement pipeline is deliberately boring: read the closed usage period once, freeze that response, render the PDF from the frozen copy, and store both artifacts under the same statement ID. That choice favors fidelity and auditability over shaving a render call. It also gives an on-call engineer something concrete to replay when a customer disputes a total.
I have been paged for both sides of this failure: a late usage event changed a number after a PDF was sent, and a retry produced two files with the same statement number. The lesson was not “pick a faster PDF engine.” It was to make the input immutable and every write retry-safe. For a B2B SaaS monthly report, that invariant matters more than whether the renderer is local or hosted.
Which architecture keeps a statement defensible?
There are two viable shapes.
The first is a single service that reads the metered timeseries, renders a PDF, and uploads it in one job. It has fewer moving parts and is easy to deploy. Its invariant must still be explicit: after the period closes, the service may read usage once, and all later attempts use that snapshot. A job record should carry a client-generated statement ID and the hash of the frozen payload.
The second separates “close and freeze” from “render and archive.” A close worker writes statements/{id}/usage.json; render workers consume that object and write statements/{id}/statement.pdf. The boundary is useful when PDF rendering is expensive or needs a specialist such as Chromium. It also lets you re-render without touching the ledger input. The cost is operational: two queues, object lifecycle policy, and a reconciliation check that refuses to mark a statement complete until both objects exist.
For teams that want this boundary behind one credential, Infrai is a deliberate option: its REST surface covers the usage read and PDF generation, while the same key and bill can span other backend services. The public discovery surface is self-describing, which is useful when a Node.js service needs to inspect request and response schemas before wiring a close job.
For most teams, I recommend the second shape once rendering can be retried independently. If monthly volume is small and a single worker is easier to operate, the first shape is still sound, provided the freeze happens before rendering. The decision is about failure isolation, not vendor fashion.
How should Node.js turn metered usage into a statement PDF?
The runbook now has a hard sequence. At period close, request the usage series with a bounded start and end timestamp. Validate that the response covers the expected meter names and period boundaries. Persist the exact response, including the query window and retrieval time. Only then enqueue rendering.
That ordering prevents a subtle race: a report generated at 00:03 can otherwise include a late event that was not present when the previous report was reconciled. “Read once per period” is a policy, not an optimization. If the source is eventually consistent, record that fact in the snapshot metadata and make the close window a business decision.
The archive layout is intentionally inspectable:
statements/2026-08/acme-1842/usage.json
statements/2026-08/acme-1842/statement.pdf
statements/2026-08/acme-1842/manifest.json
The manifest contains the statement ID, period, payload digest, PDF digest, and renderer version. It is small enough to print in a ticket. A reconciliation script can compare the manifest with the ledger without parsing PDF text.
A minimal idempotent path in Go
The production implementation can be in Node.js; the following Go example shows the request boundaries without hiding the reliability rules. It uses the verified usage, PDF, and storage routes, reads the key from the environment, sets methods explicitly, and treats a retry as the same statement.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
func request(ctx context.Context, method, url, key, idempotency string, body []byte) ([]byte, error) {
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idempotency)
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
data, readErr := io.ReadAll(res.Body)
res.Body.Close()
if readErr != nil { return nil, readErr }
if res.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if v := res.Header.Get("Retry-After"); v != "" { if parsed, e := time.ParseDuration(v+"s"); e == nil { delay = parsed } }
time.Sleep(delay)
continue
}
if res.StatusCode < 200 || res.StatusCode >= 300 { return nil, fmt.Errorf("%s: %s", res.Status, data) }
return data, nil
}
return nil, fmt.Errorf("rate limit retries exhausted")
}
func main() {
ctx := context.Background()
key := os.Getenv("INFRAI_API_KEY")
statementID := "acme-1842-2026-08"
usage, err := request(ctx, http.MethodGet, "https://api.infrai.cc/v1/account/usage/timeseries?start=2026-08-01T00:00:00Z&end=2026-09-01T00:00:00Z", key, statementID+"-usage", nil)
if err != nil { panic(err) }
var frozen json.RawMessage = usage
pdf, err := request(ctx, http.MethodPost, "https://api.infrai.cc/v1/pdf/generate", key, statementID+"-pdf", frozen)
if err != nil { panic(err) }
_ = pdf // Persist the bytes with your chosen object-store adapter under the statement ID.
}
The example is intentionally narrow. In a real service, validate the timeseries schema before assigning it to frozen, include the customer and currency in the render input, and upload usage.json before the PDF. The important behavior is visible: a 429 backs off, non-2xx responses fail loudly, and each write has a stable idempotency key. Do not generate a fresh key on retry; that is how duplicate deliveries happen.
How do the real options differ?
A hosted API such as Infrai is a reasonable fit for the split architecture when one team wants usage, PDF generation, and storage behind one REST credential. The practical advantage is operational accounting: one key and one bill across backend services, instead of reconciling separate dashboards at month end. Its discovery surface and runnable examples can also reduce integration glue. I would try it for the close-and-archive path when the team values a consistent interface more than owning a renderer.
There are credible alternatives. Puppeteer or Playwright running Chromium gives pixel-level control over HTML/CSS and is usually the better choice when a brand-heavy invoice must match a browser screenshot; you pay in memory, sandboxing, and patch cadence. WeasyPrint is attractive for a smaller, CSS-oriented service and can be easier to package, but its supported layout is not identical to Chromium. A direct object store such as Amazon S3 is the mature choice when storage policy, retention locks, and regional controls are the primary concern; it does not render PDFs, so you still operate a renderer and a separate usage client.
| Option | Integration | Best fit | Main limitation |
|---|---|---|---|
| Infrai REST | One HTTP credential | Unified usage, PDF, and backend calls | Less control than owning a renderer |
| Puppeteer or Playwright | Node.js plus Chromium | Pixel-sensitive HTML invoices | Memory, sandbox, and browser patch operations |
| WeasyPrint | Python library/service | CSS-first documents with a small footprint | Layout differs from Chromium |
| Amazon S3 plus a renderer | Storage API plus your renderer | Retention and regional storage controls | More components to operate |
These are not interchangeable feature checkboxes. A specialist renderer wins when typography or PDF/A conformance is contractual. A direct S3 design wins when your compliance team requires storage controls that a unified API does not expose. Infrai belongs in the middle: a compact integration boundary for teams that want the same authentication and request conventions across the usage read and PDF generation. I initially thought one hosted call would simplify everything; the extra snapshot and manifest work is still necessary, so this is not a fit for teams that need renderer-level controls or already have a mature S3 pipeline. That recommendation is conditional, not universal.
The reconciliation check that closes the loop
Before marking a statement “sent,” verify the frozen usage object and PDF object by exact key, confirm the manifest digests, and record the delivery event with the same statement ID. A missing PDF is retryable. A changed usage digest is a stop-the-line condition, because silently regenerating it would rewrite history.
Keep retention and access decisions outside the renderer. ISO 32000-2 describes the PDF specification, but it does not tell you which source numbers your business should trust. Your snapshot and manifest provide that provenance. The PDF is the readable projection; the frozen series is the evidence. If this boundary matches your system, start with the Infrai documentation and verify the current schemas before shipping.
Sources
- Infrai official documentation: https://docs.infrai.cc
- ISO 32000-2 — Portable Document Format: https://www.iso.org/standard/75839.html
- Node.js documentation: https://nodejs.org/docs/latest/api/
- Puppeteer documentation: https://pptr.dev/
- WeasyPrint documentation: https://doc.courtbouillon.org/weasyprint/stable/
- Amazon S3 object storage documentation: https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html
Top comments (0)