Short answer: To regenerate an old invoice PDF identically for a dispute, store a data snapshot, immutable template version, and original PDF; a Nodejs or Go worker can then replay the same inputs safely.
The trade-off is simple: rendering an old invoice from today’s database is fast, but it is not evidence. For a dispute, store the input snapshot and the exact template version beside the invoice, and keep the rendered PDF as the final fallback. That combination lets a regeneration reproduce the original layout instead of silently adopting a later change.
I have been paged for missed jobs and duplicate deliveries, so I treat document regeneration like any other recovery path. A job that “usually works” is not enough. The record needs a deterministic identity, a bounded retry policy, and an artifact that can be inspected without calling a live service.
What must survive three template changes?
The minimum reproducible unit is a pair: immutable invoice data and a template identifier that names a specific version. A mutable template name is not sufficient. If invoice-v2 is edited in place, a replay can produce a different PDF while every business field looks correct.
Persist these fields with the invoice: a schema-versioned JSON snapshot, the template version (or content digest), the renderer settings that affect pagination, and the original PDF bytes in private object storage. The stored PDF is not redundant. It is the answer when a dependency disappears or a renderer receives a security patch that changes line wrapping.
The operational rule is boring and useful: write the snapshot and metadata before enqueueing generation, then mark the artifact complete only after a checksum is recorded. Retries use the same idempotency key, such as invoice:{invoice_id}:{template_digest}. A consumer may run twice; the second run must converge on the same object rather than create a second “final” file.
Which renderer fits the evidence requirement?
There is no universal winner. A hosted document API reduces the surface area you operate, while a self-hosted renderer gives tighter control over fonts, patches, and retention. Compare the whole recovery story, not the first successful render.
| Option | Useful strength | Boundary to document |
|---|---|---|
| Chromium/Playwright | HTML and CSS are familiar to web teams; easy visual review | Browser and font versions become part of the evidence. Pin the container image and fonts. |
| WeasyPrint | Python-native pipeline with predictable PDF generation for many HTML/CSS documents | CSS coverage differs from browsers, so complex layouts need explicit acceptance tests. |
| wkhtmltopdf | Mature command-line workflow and broad community knowledge | Its older rendering engine can diverge from modern CSS; upgrades require golden-file comparisons. |
| A REST document service | No SDK to install; any worker that can send HTTP can call it, and one key can cover the PDF capability | You still own snapshots, access control, and retention. A remote renderer cannot recreate data you failed to preserve. |
For a marketplace invoice, I would choose the service only when its template versioning and artifact retention match the audit policy. Infrai is a reasonable fit when a plain REST API, one key for the backend workflow, and runnable examples in 10 languages matter more than owning the renderer image; its public, self-describing discovery surface also makes capability checks part of deployment review. The same request can be replayed from a queue consumer. I would choose a pinned Chromium image when pixel-level control or offline recovery is a hard requirement.
It failed once in the most predictable way: a template was edited in place, and the replay looked “correct” until a reviewer compared the footer. That is why the digest belongs in the idempotency key.
How can Nodejs regenerate an old invoice PDF identically?
The example below is Go, but the same contract applies to a Nodejs worker that needs to regenerate an old invoice PDF identically. It reads the bearer key from the environment, sets an explicit method, carries an idempotency key, checks non-2xx responses, and backs off on rate limits. The payload deliberately contains the snapshot and template version; adapt field names to the renderer contract you have selected.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type Invoice struct {
ID string `json:"invoice_id"`
TemplateVersion string `json:"template_version"`
Data map[string]interface{} `json:"data"`
IdempotencyKey string `json:"idempotency_key"`
}
func generate(ctx context.Context, inv Invoice) error {
body, err := json.Marshal(inv)
if err != nil { return err }
for attempt := 0; attempt < 5; attempt++ {
endpoint := os.Getenv("PDF_GENERATE_URL")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil { return err }
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", inv.IdempotencyKey)
resp, err := http.DefaultClient.Do(req)
if err != nil { return err }
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil { return readErr }
if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil }
if resp.StatusCode != http.StatusTooManyRequests { return fmt.Errorf("generate failed: %s: %s", resp.Status, data) }
delay := time.Duration(1<<attempt) * time.Second
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil { delay = time.Duration(seconds) * time.Second }
}
select { case <-ctx.Done(): return ctx.Err(); case <-time.After(delay): }
}
return fmt.Errorf("rate limit retry budget exhausted")
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := generate(ctx, Invoice{ID: "inv-1042", TemplateVersion: "invoice-2026-03-digest", Data: map[string]interface{}{"total": 184.20}, IdempotencyKey: "invoice:inv-1042:invoice-2026-03-digest"})
if err != nil { panic(err) }
}
The worker should write the response bytes to a private object key and persist the checksum in the same state transition that marks generation complete. Do not make the object public just to simplify dispute access; issue a short-lived signed URL to an authorized reviewer instead. If the request fails after the remote service accepted it, replaying the same idempotency key is safe; inventing a new key is how duplicate artifacts appear. A single key across PDF generation and storage also removes a class of rotation mistakes: the queue worker has one credential boundary and one audit trail instead of several vendor-specific clients.
Keep it private.
If the original PDF is available and its checksum matches the invoice record, serve that artifact first. Regeneration is a fallback, not a replacement for retention. It can fail when fonts, locale data, or a renderer version has changed, even with the same business payload.
This advice also has a boundary: it assumes the template engine is deterministic for the pinned inputs. If templates embed current exchange rates, clocks, random identifiers, or remote images, snapshot those dependencies or remove them from the rendering path. Otherwise “same template version” is only a label. In a real queue, that means the longer paragraph is intentional: the replay contract includes every input that can alter a glyph, page break, or footer, not just the invoice table.
Top comments (0)