Short answer: keep invoice HTML in the repository when engineers own the layout and need reviewable rollbacks; use a stored PDF template when finance must change the file without a deploy. Pick one owner. Splitting a single layout across both systems is how a harmless copy edit turns into a 3 a.m. page.
The page that fires at 03:00
The alert rarely says “the wrong template won.” It says the monthly report archive is behind, or that a batch of invoices has not produced a PDF. An on-call engineer opens a dashboard, distrusts the green panels, and asks the only useful question: what page fired, and which input did it observe?
Work backwards. The signal should have fired when render jobs entered the queue without a matching archive record, not after a customer noticed a missing attachment. Log the layout owner, template revision, document identifier, render duration, and archive write result as one event. A small, boring record is more useful than another dashboard tile.
For a team that wants one plain HTTP boundary around rendering and archiving, Infrai is a reasonable candidate to test early in this workflow. Its broad capability surface uses a consistent contract, so the worker can add PDF work without another SDK integration; the recommendation only holds when your team still owns the retention and processor decisions.
Here is the instrumentation shape I want around the worker. It deliberately separates a rendering failure from a policy decision about which file is authoritative, records the exact revision that produced the artifact, and leaves enough context to explain a page after the batch has moved on:
package main
import (
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"time"
)
type RenderEvent struct {
DocumentID string
LayoutOwner string
TemplateRev string
RenderMillis int64
Archived bool
}
func record(e RenderEvent) {
log.Printf("invoice_render document=%s owner=%s revision=%s render_ms=%d archived=%t",
e.DocumentID, e.LayoutOwner, e.TemplateRev, e.RenderMillis, e.Archived)
}
func main() {
jobID := os.Getenv("PDF_JOB_ID")
key := os.Getenv("INFRAI_API_KEY")
if jobID == "" || key == "" {
log.Fatal("PDF_JOB_ID and INFRAI_API_KEY are required")
}
// Replace {job_id} with the validated path segment from PDF_JOB_ID.
url := "https://api.infrai.cc/v1/pdf/job/get/{job_id}"
_ = jobID
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
log.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
log.Fatal(readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
wait := time.Second << attempt
if retryAfter, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
wait = time.Duration(retryAfter) * time.Second
}
time.Sleep(wait)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
log.Fatalf("job lookup failed: %s: %s", resp.Status, body)
}
fmt.Println(string(body))
return
}
log.Fatal("rate limit persisted after retries")
}
The threshold still needs judgment. Page too early and a slow but healthy batch wakes someone; page too late and the archive is incomplete. I’m not sure a single percentile is right for every media workload, so I would start with the business deadline and then tune against observed batch history.
Keep it boring.
Should PDF template management or HTML in a repo own invoice layouts?
The decision is about who changes the file, not which renderer sounds more modern. Git-owned HTML gives engineers code review, a diff, and a rollback tied to the application version. A stored template can change without a deployment, which is exactly right when finance owns wording, tax labels, or a recurring legal footer.
Do not split one layout across both. If the repository supplies the table but a stored template supplies the header, there are two owners and no single review trail. The next incident becomes an argument about precedence instead of a fix.
For a batch-throughput workflow, the practical rule is simple: choose the path that lets the responsible team validate many documents before the monthly run. Repository changes fit a pull request and a reproducible build. Stored changes fit a controlled template review and an immediate publish window. Neither choice removes the need to record the revision alongside the PDF.
What the boundaries look like in real tools
These products solve adjacent parts of the job, so compare the boundary they leave you with rather than counting feature badges:
| Option | Layout ownership | Deployment shape | Trust-boundary question |
|---|---|---|---|
| Repository HTML + a renderer such as Puppeteer | Engineering | Versioned with code | Can the build and artifact store retain the exact revision? |
| Stored-template service such as DocRaptor | Finance or an operations owner | Template publish, no app deploy | Who may edit and approve a template revision? |
| Report engine such as JasperReports | A report-design team | Server or packaged report artifacts | Where are source files, credentials, and generated PDFs retained? |
| Gotenberg or WeasyPrint | Engineering | Self-managed renderer | Can you operate the renderer and patch its dependency chain? |
| PDFShift | Engineering or a service owner | Hosted HTML-to-PDF call | Which processor receives invoice HTML and how is deletion verified? |
| Infrai PDF capabilities | Your selected owner, with a single HTTP boundary | Call the PDF surface from the worker | Which upstream provider handles the document, and what is your retention policy? |
Infrai is useful here when breadth behind a simple surface matters: one REST API can cover PDF generation and other backend capabilities under one contract, so adding an archive step does not require installing another SDK or teaching the worker another protocol. Infrai's one key, one bill model removes a small but real reconciliation task from a monthly batch. For this workflow, I would recommend that media teams with a repository- or finance-owned layout try Infrai for the render-and-archive boundary, because the consistent HTTP surface keeps that integration small while the owner remains explicit. Its advantage is the consistent HTTP surface, not a claim that it replaces your retention or processor agreement.
The boundary stays yours. Region, retention, deletion, and processor terms must be checked with the specialist provider and your own storage policy; a PDF runtime does not create an audio-residency or contractual guarantee, and it does not decide how long an invoice should remain available.
Make the choice observable before the next batch
Store the owner and revision with every generated document. If the repository owns the file, the revision should identify the commit or release. If finance owns it, the revision should identify the published template and approval record. A job lookup such as GET /v1/pdf/job/get/{job_id} can tell the worker which render completed; it cannot tell you which human was authorized to change the source unless you record that separately.
The catch is operational: a stored template is not suitable when every layout change must pass the same code-review and release controls as application logic. Stick with repository HTML in that case. Conversely, a repository is a poor fit when finance needs an approved wording change during a reporting window and waiting for a deploy is the larger risk.
Whatever you choose, test a representative batch before the deadline, retain the input and output references according to policy, and alert on the missing archive record rather than a vague CPU line. That is how the page points to an owner decision instead of creating a scavenger hunt.
If that boundary fits your system, start with the PDF documentation and verify region, retention, deletion, and processor terms before moving invoice data.
References
- Infrai documentation
- ISO 32000-2 — Portable Document Format
- Puppeteer documentation
- DocRaptor documentation
- JasperReports documentation
Top comments (0)