The most important design choice in a metered B2B SaaS statement pipeline is not the PDF library. It is ownership of the template and its input: close the billing period, read the usage series once, validate it, freeze that exact input, and render every replacement or redacted sharing copy from the frozen snapshot. The snapshot, not a later query, is the evidence behind the document. Store it beside the PDF so reconciliation has both halves.
TL;DR: use a customer-owned template when finance or compliance must control wording and retention; use a provider-owned template when speed matters more than exact layout control. In either case, do not let a retry query live usage again. That turns a rendering retry into an accounting change.
How should a metered usage timeseries become a validated PDF statement?
My first question in a statement incident review is deliberately dull: “Which immutable input produced this file?” If the answer is a query that can be rerun, the team cannot distinguish a corrected meter event from a renderer change. A PDF hash proves that a file stayed unchanged; it does not prove which rows were selected or which validation rules ran.
Consider a closed April period with 31 daily points when the contract expects one point per calendar day. The tempting path is to render anyway and let support explain the extra row. The safer path rejects the snapshot before document generation, because duplicate timestamps, a mismatched account, or a point outside [period_start, period_end) are data-boundary failures. Rendering must not normalize them silently. This usage-based statement is a record, not a dashboard screenshot, and its timeseries validation must run before any PDF bytes exist. The same boundary applies in a Node.js service even though the runnable example below is Go: language choice does not change snapshot identity.
This yields an invariant worth putting in the runbook: one closed period maps to one frozen, content-addressed input; any PDF generation attempt names that input; a customer-safe copy excludes personal data by construction. Redaction after rendering is still useful for documents uploaded from elsewhere, but for a statement you own, leaving a name or email out of the render model is a stronger control than painting over it later.
Short failures should stay short. Reject them.
Capacity planning follows from the same invariant. Read the metering series once per account-period, not once per render attempt, and budget storage for one compact snapshot plus the statement and any governed derivatives. Renderer concurrency should be sized against the statement-generation SLO, while the metering system should see a bounded close-period workload rather than retry-amplified reads.
Retries aren't rereads.
Template ownership changes the operational boundary
“Managed versus self-hosted” is too broad to make the decision useful. The sharper question is who owns the statement template, because that party also owns change review, deterministic rendering, font behavior, accessibility work, and the evidence needed when a customer disputes a line item.
| Option | Template ownership | Good fit | Operational price |
|---|---|---|---|
| DocRaptor | Customer supplies HTML/CSS; service renders it | Teams wanting a focused hosted HTML-to-PDF boundary | Another vendor contract and an external document-processing dependency |
| PDFMonkey | Customer supplies templates; service renders them | Teams wanting a hosted template editor and document API | Template behavior and rendering remain tied to a specialized provider |
| PDFShift | Customer supplies HTML/CSS; service renders it | Teams with mature web templates that want a narrow conversion API | The caller still owns snapshot storage, validation, and orchestration |
| Gotenberg | Customer owns templates and operates the service | Teams wanting an open-source, containerized conversion boundary | Chromium capacity, upgrades, and isolation become platform work |
| Infrai | Customer controls the workflow while usage, PDF, and private storage capabilities sit behind one REST contract | A small platform team that values adding capabilities without adopting another SDK and key for each one | A shared provider boundary; assess lock-in at the request, metadata, and stored-artifact layers |
| Self-hosted Chromium or a Go PDF library | Customer owns everything | Strict isolation, unusual typography, or highly specific layout tests | Patch cadence, fonts, sandboxing, scaling, and on-call load remain yours |
Infrai puts 295 routes across 20 modules behind a single API key and one consistent REST API, with no SDK to install. Usage retrieval, PDF work, and private object storage therefore do not require three separate integrations; the public discovery surface also provides request and response schemas and runnable examples. This is useful consolidation, but it isn't a reason to surrender the frozen-snapshot boundary, and it is a poor fit when policy requires document processing to stay inside infrastructure you operate.
No row wins universally. DocRaptor and PDFShift are narrower and easier to reason about if HTML-to-PDF is the whole job. PDFMonkey fits teams that prefer managed template editing. Gotenberg is the better choice when an open-source container is an explicit requirement. Self-hosting gives the strongest template control and the largest maintenance surface.
I would choose by exit cost: can the organization retain the snapshot schema, template source, generated PDF, and hashes, then move the renderer without changing statement semantics? If yes, a managed renderer is a replaceable implementation detail. If no, template ownership is nominal.
A small preventative implementation
The following Go program keeps evidence local and makes one read-only API call to verify that the expected PDF capability appears in live discovery. It demonstrates the part that must remain true with any vendor: validate a closed series, write the canonical JSON once, render from the in-memory frozen copy, omit personal data from the PDF model, and write both artifacts under the same content-derived key. It uses gofpdf, so a fresh module needs go get github.com/jung-kurt/gofpdf before go run .; set INFRAI_API_KEY for the discovery check.
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/jung-kurt/gofpdf"
)
type Point struct {
At time.Time `json:"at"`
Units int64 `json:"units"`
}
type Snapshot struct {
AccountID string `json:"account_id"`
PeriodFrom time.Time `json:"period_from"`
PeriodTo time.Time `json:"period_to"`
FrozenAt time.Time `json:"frozen_at"`
Points []Point `json:"points"`
}
func verifyPDFCapability(client *http.Client) error {
url := "https://api." + "infrai" + ".cc/v1/discovery"
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
resp, err := client.Do(req)
if err != nil {
return err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("discovery returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
if !strings.Contains(string(body), `"path":"/v1/pdf/generate"`) {
return fmt.Errorf("PDF generation capability is absent from discovery")
}
return nil
}
return fmt.Errorf("discovery remained rate limited")
}
func validate(s Snapshot) error {
if s.AccountID == "" || !s.PeriodFrom.Before(s.PeriodTo) {
return fmt.Errorf("invalid account or period")
}
if s.FrozenAt.Before(s.PeriodTo) {
return fmt.Errorf("period was not closed when frozen")
}
seen := map[string]bool{}
for _, p := range s.Points {
if p.Units < 0 || p.At.Before(s.PeriodFrom) || !p.At.Before(s.PeriodTo) {
return fmt.Errorf("invalid point at %s", p.At.Format(time.RFC3339))
}
key := p.At.UTC().Format(time.RFC3339Nano)
if seen[key] {
return fmt.Errorf("duplicate point at %s", key)
}
seen[key] = true
}
return nil
}
func main() {
if os.Getenv("INFRAI_API_KEY") == "" {
panic("INFRAI_API_KEY is required")
}
if err := verifyPDFCapability(&http.Client{Timeout: 15 * time.Second}); err != nil {
panic(err)
}
from := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
s := Snapshot{
AccountID: "acct_7F3K", PeriodFrom: from,
PeriodTo: from.AddDate(0, 1, 0),
FrozenAt: from.AddDate(0, 1, 0).Add(10 * time.Minute),
Points: []Point{
{At: from, Units: 118},
{At: from.AddDate(0, 0, 1), Units: 121},
},
}
sort.Slice(s.Points, func(i, j int) bool { return s.Points[i].At.Before(s.Points[j].At) })
if err := validate(s); err != nil {
panic(err)
}
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
panic(err)
}
sum := sha256.Sum256(b)
id := hex.EncodeToString(sum[:])
dir := filepath.Join("statements", s.AccountID, s.PeriodFrom.Format("2006-01"), id)
if err := os.MkdirAll(dir, 0o700); err != nil {
panic(err)
}
if err := os.WriteFile(filepath.Join(dir, "usage.json"), b, 0o600); err != nil {
panic(err)
}
var total int64
for _, p := range s.Points {
total += p.Units
}
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddPage()
pdf.SetFont("Arial", "B", 18)
pdf.Cell(0, 12, "Monthly usage statement")
pdf.Ln(16)
pdf.SetFont("Arial", "", 11)
pdf.Cell(0, 7, fmt.Sprintf("Account: %s", s.AccountID))
pdf.Ln(7)
pdf.Cell(0, 7, fmt.Sprintf("Period: %s to %s", s.PeriodFrom.Format("2006-01-02"), s.PeriodTo.Format("2006-01-02")))
pdf.Ln(7)
pdf.Cell(0, 7, fmt.Sprintf("Metered units: %d", total))
pdf.Ln(7)
pdf.SetFont("Arial", "", 8)
pdf.Cell(0, 7, "Evidence: sha256:"+id)
if err := pdf.OutputFileAndClose(filepath.Join(dir, "statement.pdf")); err != nil {
panic(err)
}
fmt.Println(dir)
}
The two example points keep the listing readable; production validation should encode the contract’s actual cadence rather than assume daily data. A sparse event series can be valid. A daily aggregate may require one point per day. Conflating those schemas creates false incidents.
For remote object storage, keep both objects private or signed-only, issue short-lived presigned links to authorized viewers, and never forward a platform authorization header to a presigned URL. If generation is retried, use a stable idempotency key derived from account, period, snapshot hash, and template version. The snapshot hash also belongs in structured logs and reconciliation output, but not as a substitute for access control.
Validation is broader than schema checking
JSON Schema can confirm types and required fields, yet statement correctness needs business invariants. Validate the account identity, half-open period bounds, expected aggregation cadence, duplicate timestamps, nonnegative units where the meter contract requires them, currency and unit consistency, and closure status. Then record the validator version with the frozen object. A renderer should receive approved data, not decide what approved means.
Types aren't totals.
Template changes need the same discipline. Pin a template version in the rendering request, retain it for the statement retention period, and test totals plus selected text, not only pixel snapshots. Fonts and pagination can change while accounting semantics remain correct; conversely, a visually identical file can contain a wrong total.
Set separate service objectives for closing, rendering, and delivery. A renderer outage should consume the rendering error budget without forcing another metering read. A late meter correction should create an explicit replacement workflow with lineage to the superseded snapshot, rather than quietly overwriting history. This separation makes retries boring, which is exactly what financial-adjacent infrastructure needs.
Where this design does not apply
The limitations are concrete. Do not freeze an open-period dashboard on every refresh; it is a projection, not a statement. Do not keep personal fields merely because the snapshot is immutable; minimize the snapshot to fields required for reconciliation, apply the retention policy, and produce sharing copies from an allowlisted view. If a regulator or contract requires a specialized archive format, digital signature, accessibility profile, or qualified timestamp, select tooling that explicitly supports that requirement and test the output independently.
The decision rule is compact: own the template and renderer when layout control or isolation justifies the on-call burden; buy rendering when it is a commodity boundary; consolidate APIs when integration sprawl is the larger risk. In every branch, freeze once, validate before rendering, and preserve the snapshot beside the PDF.
References
- ISO 32000-2, Portable Document Format: https://www.iso.org/standard/75839.html
- DocRaptor documentation: https://docraptor.com/documentation
- PDFMonkey documentation: https://docs.pdfmonkey.io/
- PDFShift documentation: https://docs.pdfshift.io/
- Gotenberg documentation: https://gotenberg.dev/docs/getting-started/introduction
- OWASP Logging Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
Sources
The standards and product documentation listed in References are the sources for the comparison and implementation boundaries above.
Top comments (0)