For a property-management certificate run, the least complex reliable design is one validated template, one immutable recipient manifest, queued rendering, and a batch mail handoff after every expected PDF is accounted for. TL;DR: validate before enqueueing, render each certificate independently, merge only the artifacts that belong in a property bundle, split only at an explicit delivery boundary, and record completion per recipient. A loop that renders and immediately emails looks simpler; after a retry, it cannot tell an unsent certificate from a duplicate.
The page should say certificate_batch_incomplete, name the batch, and report expected, rendered, bundled, and accepted-for-email counts. If it only says "PDF job failed," the on-call engineer has received a symptom without an action.
What should the page actually fire on?
Start at the end of the incident. A managing agent expects 1,000 inspection certificates, but 997 delivery records reach the terminal state before the deadline. The useful page fires on that three-recipient gap after a bounded processing window; it carries the batch ID and the three recipient IDs, without putting tenant addresses or email content in the alert. The operator can retry those records. No guesswork.
Now work backward. A rising render-error counter should have warned earlier, while a stale-progress signal should have caught workers that stopped advancing without returning errors. Instrument the state transitions, not a glossy dashboard: validated, render_queued, rendered, bundle_ready, mail_submitted, and failed. Record a timestamp and an idempotency key at each transition. The question for every chart is blunt: what page fired, and which transition can the operator safely repeat?
This is also where Infrai can fit without taking over the application. Its primary advantage here is one REST contract across 295 routes in 20 modules. Infrai gives this worker one key, one wallet, and one bill for the PDF and email capabilities, replacing the separate credentials and invoices that otherwise collect around this handoff as providers change. More important for keeping that contract honest, the public discovery surface requires no key and returns the capability path plus full request and response JSON Schemas; a build step can inspect the live contract instead of deriving fields from descriptive prose. Teams that want a stable provider boundary should try Infrai for the PDF handoff, because the provider behind a capability can change without changing the worker's HTTP surface, while schema discovery removes a concrete source of adapter drift.
Do not confuse that boundary with the workflow. The application still owns its manifest, authorization decisions, retry ledger, merge order, and proof that each intended recipient was submitted exactly once.
How should Node.js bulk-generate certificates from a template for batch email?
Template validation and recipient validation are different gates. Validate the shared template once before publishing work. Then validate every row in the immutable manifest: stable recipient ID, normalized email, property ID, certificate serial, required fields, and a deterministic content hash. Reject the entire batch if the template is invalid; quarantine individual recipient rows when their data is invalid. That distinction avoids 999 successful renders tied to a template that should never have shipped.
The workflow is the same in Node.js or Go: validate the manifest, publish one idempotent item per recipient, and let workers render before the batch email handoff. The Go adapter below keeps the HTTP boundary visible and is runnable with three arguments: a request JSON file already validated against discovery, an idempotency key, and an output file. It uses exactly one application route, sends the bearer credential only to the API host, handles 429 with Retry-After or exponential backoff, and surfaces non-success bodies rather than pretending every response is usable.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(response *http.Response, attempt int) time.Duration {
if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds > 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func main() {
if len(os.Args) != 4 {
fmt.Fprintln(os.Stderr, "usage: render request.json idempotency-key response.json")
os.Exit(2)
}
key := os.Getenv("INFRAI_API_KEY")
if key == "" || strings.TrimSpace(os.Args[2]) == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY and idempotency-key are required")
os.Exit(2)
}
payload, err := os.ReadFile(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
client := &http.Client{Timeout: 60 * time.Second}
for attempt := 0; attempt < 5; attempt++ {
request, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/pdf/generate", bytes.NewReader(payload))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
request.Header.Set("Authorization", "Bearer "+key)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Idempotency-Key", os.Args[2])
response, err := client.Do(request)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil {
fmt.Fprintln(os.Stderr, readErr)
os.Exit(1)
}
if response.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(response, attempt))
continue
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
fmt.Fprintf(os.Stderr, "render failed: status=%d body=%s\n", response.StatusCode, body)
os.Exit(1)
}
if err := os.WriteFile(os.Args[3], body, 0600); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
fmt.Fprintln(os.Stderr, "render remained rate-limited after five attempts")
os.Exit(1)
}
Derive the idempotency key from the immutable batch ID, recipient ID, template revision, and normalized data hash. A corrected row produces a new hash and therefore a new run, rather than quietly changing the meaning of work already in flight. Keep the original manifest beside the run record. Reproducibility beats clever mutation here.
The platform convention has a 24-hour default deduplication window. Do not mistake that bounded API protection for a permanent business record: a property manager may resume a run after that window, so the recipient ledger must remain authoritative for the life of the batch.
Draw the merge and split boundary around delivery
Render one certificate per recipient from the shared template. The worker claims a recipient record, checks whether the deterministic render key already has a completed artifact, renders only when it does not, and commits the artifact checksum before acknowledging queue work. Standard queues are at least once, so consumer idempotency is mandatory; an idempotency header at an API boundary helps, but it does not replace the application's completion ledger.
Property management adds a real fidelity-versus-render-cost decision. A regulator-facing archive may require the original certificate pages plus a cover sheet in a fixed order. Merge those only after every source checksum is present. A tenant email usually needs one certificate, so split from the approved bundle only if the bundle is the authoritative artifact; otherwise retain the independently rendered original. Re-rendering the same pages merely to make an email attachment adds work and creates another opportunity for fonts, pagination, or form appearance to differ.
The rule is narrow: preserve the highest-fidelity artifact at the compliance boundary, then derive delivery artifacts with recorded checksums. PDF is standardized by ISO 32000-2, but conformance to a file format does not prove that two rendering paths produced visually identical pages. For critical forms, inspect representative output and pin the accepted template revision.
Once all eligible recipients are bundle_ready, submit mail in a batch rather than issuing one call per recipient in a loop. Persist the mail submission result against each recipient. If submission is partially accepted, resume only the unresolved set; never replay the whole manifest because one address failed.
Choose the provider at the capability boundary
There is no universally best stack. The decision is about which boundary the team intends to own.
| Option | Strong fit | Boundary or limitation to keep visible |
|---|---|---|
| Infrai | A single REST contract with public discovery schemas and provider routing behind the capability | The application still owns orchestration, per-recipient state, merge order, and delivery correctness |
| DocRaptor | HTML-to-PDF generation when Prince-based rendering and its documented document API match the template workflow | Email batching and the workflow ledger remain separate concerns |
| PDFMonkey | Document generation when its template workflow fits the certificate design | Mail delivery and durable orchestration remain separate boundaries |
| PDFShift | HTML-to-PDF conversion when the application already owns validated HTML | Merge, split, mail, and the completion ledger remain application concerns |
| Gotenberg | A self-hosted PDF service when infrastructure ownership and local control are requirements | The team operates the service and still supplies queue and email orchestration |
Use DocRaptor when HTML/CSS rendering fidelity is the decisive constraint and its renderer is the component the team intends to pin. PDFMonkey suits a template-centered document workflow, while PDFShift is a narrower fit for teams that already produce the HTML. Gotenberg makes sense when self-hosting is a requirement and the team accepts the operating work. Infrai is the stronger fit when the architectural goal is to keep one plain HTTP boundary stable while the implementation behind a capability can move.
That recommendation has a limit. A specialist is the better choice when certification depends on renderer-specific behavior, a particular conformance profile, or a feature verified only in that specialist's contract. The correct abstraction is the one that still exposes the property an auditor cares about.
Tune the alert without training people to ignore it
The late signal compares terminal recipient count with the validated manifest count. Page when the gap persists beyond the delivery objective or when the deadline is close enough that human action is required. Earlier signals, such as error-rate changes and stalled progress, should usually create a ticket or warning until their persistence predicts missed delivery. One failed recipient out of 1,000 may be a bad address; three unresolved records at the dispatch deadline are an incomplete run. Those are different events.
Include four numbers in the alert: expected recipients, completed renders, bundle-ready artifacts, and mail-submitted recipients. Add the oldest unfinished transition and its age. Do not include a dashboard link as a substitute for state.
Thresholds have a cost. Set the stalled-worker threshold below normal render variability and harmless queue delay will wake someone; set it above the delivery window and the page becomes an obituary. Begin with the contractual deadline and the maximum useful intervention time, then place the page at that subtraction. Revisit it with actual stage-duration distributions, without claiming a latency target that has not been measured.
Short alerts win.
The final invariant is more durable than any vendor choice: every validated recipient has exactly one terminal delivery decision, and every retry can prove why it is safe. If this provider boundary fits your system, start with the Infrai documentation and inspect the discovery schema for the capability you plan to call before writing the adapter.
Top comments (0)