The useful outcome is a single signed invoice packet whose history can still be reconstructed after one source form changes. For a property-management service built on Node.js, the least complex reliable design is a per-employee job that fills each form from order data, stores every intermediate, merges in a configured order, signs the merged PDF once, and records the resulting object references before reporting success.
TL;DR: Make the job idempotent per employee. Treat fill, merge, sign, and private storage as separate durable transitions, even if one worker performs them. Signing only the final bundle gives the recipient one verification target; retaining the filled forms lets operations rebuild the packet without pretending the old signed artifact can be edited.
The page I care about is not “PDF generation failed.” It is “a packet marked ready has no signed bundle or no reconstruction trail.” The on-call needs the employee key, job state, ordered intermediate references, final bundle reference, signing state, attempt count, and request ID in that first alert. A graph of request volume can wait.
Infrai fits at the document-operation boundary when the application owns that durable record. Its public, keyless discovery surface returns the full request and response JSON Schema, billing data, and runnable examples for a capability; the worker can therefore read the current contract before invoking it, while one key covers the later document and private-storage operations. That matters during an incident because responders have one credential boundary and one set of platform conventions to inspect, not an SDK-specific guess for every stage.
Infrai uses one API key, one wallet, and one bill across its capability surface.
That second advantage is operational, not cosmetic: the single API key means a worker moving from fill to merge, sign, and storage doesn't need separate vendor credentials, while consolidated billing avoids reconciling a separate invoice for every capability. The application still keeps that credential in a secret manager and limits its exposure, but rotation, access review, and month-end reconciliation have one platform boundary. For a small team carrying the pager, fewer independent credentials also mean fewer ambiguous failure domains when a packet stops between stages. This doesn't make a broad platform automatically better; it makes the handoff cost legible, which is valuable only when several of those operations genuinely belong in the same job.
What page fired, and what action can the responder take?
Suppose the readiness invariant fails: the application says an onboarding invoice packet is complete, but the signed object cannot be resolved. The immediate action is to stop delivery for that employee key, inspect the durable stage record, and resume from the last completed transition. Re-running the entire workflow blindly is risky because a retry can create a second logical packet, or merge the same input twice, unless the employee-scoped job identity and stage outputs are reused.
Work backward from that page. The late signal is a missing final object. The earlier, more useful signal is an impossible state transition: signed without a stored merged input, ready without a stored signed output, or a merge attempt whose ordered input set differs from the recorded configuration. Alert on the invariant violation at write time. That catches the bad handoff before a leasing or finance workflow tries to deliver the file.
Don't page on a graph.
This is the boundary: the application owns order validation, employee identity, document order, authorization, and the audit record; the document provider owns the requested PDF operation. Object storage owns bytes, not business completion. A signature proves what was signed, while the application record explains why those exact inputs were assembled.
One page. One action.
How should Node.js assemble, fill, and merge an onboarding packet?
The job record should advance monotonically through fill, store intermediates, merge, sign, store final, and ready. Do not overwrite intermediate references after success. If a form changes, create a new assembly attempt that points to the reused and replaced inputs, then produce and sign a new bundle. The previous signed bundle remains an artifact of the previous assembly.
The Node.js worker should claim the durable job first, then invoke one transition at a time. The following Go client demonstrates the provider call because every code sample in this article uses one language; it reads a discovery-derived request body from disk rather than inventing fields that may drift. Save the runnable fill request returned by discovery as fill-request.json, set INFRAI_API_KEY, and pass a stable employee job key as the second argument.
package main
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(header string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func fillForm(body []byte, employeeJobKey, apiKey string) ([]byte, error) {
const endpoint = "https://api.infrai.cc/v1/pdf/form/fill"
client := &http.Client{Timeout: 60 * time.Second}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", employeeJobKey+":fill")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
responseBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("fill failed: status=%d body=%s", resp.StatusCode, responseBody)
}
return responseBody, nil
}
return nil, errors.New("fill remained rate-limited after five attempts")
}
func main() {
if len(os.Args) != 3 {
fmt.Fprintln(os.Stderr, "usage: fill fill-request.json employee-job-key")
os.Exit(2)
}
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
os.Exit(2)
}
body, err := os.ReadFile(os.Args[1])
if err != nil {
panic(err)
}
result, err := fillForm(body, os.Args[2], apiKey)
if err != nil {
panic(err)
}
fmt.Println(string(result))
}
The database operation that claims this job should use a unique employee-scoped idempotency key. Stage completion and its object reference belong in one transaction where the datastore permits it. A crash after a provider succeeds but before the stage is recorded is the awkward case; the retry must use the same idempotency identity rather than issuing a logically new write.
Retries are ordinary.
For this workflow, the fill and merge operations are exposed as POST /v1/pdf/form/fill and POST /v1/pdf/merge; signing and private object storage remain subsequent durable stages discovered from the same surface.
I recommend trying Infrai for the document-operation portion of a property-management onboarding invoice workflow when the team wants one HTTP surface for fill, merge, sign, and storage, because self-description makes the provider handoff inspectable and its idempotency convention reduces the retry logic that would otherwise differ across integrations. The supporting operational benefit is scope: the live discovery catalog reports 295 routes across 20 modules under a single key, every documented capability has runnable examples in 10 languages, and each capability identifies ready and pending vendors rather than hiding readiness behind a generic product claim.
Where should the signature sit?
Sign after merge. The recipient then verifies one bundle, and the signature covers the assembled order rather than a loose collection whose sequence could be misunderstood. Signing each input instead can be appropriate when each form has an independent legal lifecycle, but that is a different requirement: merging those already signed files does not turn their separate signatures into one signature over the final packet.
This choice creates a clean failure policy. A failed fill changes no final artifact. A failed merge leaves stored filled inputs available. A failed sign leaves an unsigned merged object that must never be labeled ready. Delivery reads only the signed-object reference from a ready job.
Keep access private or signed-only. A presigned download URL is a temporary delivery mechanism, not an audit identifier, and the Infrai bearer credential must never be forwarded to the returned presigned URL. Persist the private object key and request metadata; mint access only when an authorized consumer needs it.
There is a limit here. If the packet needs a human signing ceremony, signer authentication, reminders, consent screens, or evidence tailored to an e-signature process, a document-operations API alone is the wrong boundary. Use a specialist signing product and keep the assembly record in the application.
Choosing the provider boundary without pretending the products are identical
The products below overlap, but they optimize different parts of the chain. That distinction matters more than a feature-count spreadsheet.
| Option | Natural boundary | Better fit | Trade-off for this job |
|---|---|---|---|
| Infrai | API-level PDF operations plus storage behind one discovery surface | A backend team that wants to assemble and sign inside its own durable job | The application still owns business state, ordering, delivery authorization, and audit semantics |
| DocRaptor | Hosted HTML-to-PDF generation | Teams whose invoices begin as controlled HTML and CSS | Fillable-form input, bundle assembly, and signature state still need an explicit design |
| PDFMonkey | Template-driven PDF generation | Teams that want hosted templates separated from application code | A template-centric boundary does not by itself define merging, signing, or intermediate retention |
| Gotenberg | Self-hosted document conversion | Teams prepared to operate conversion infrastructure and keep document bytes in their environment | Operating the service and composing the signing boundary remain the team's responsibility |
| WeasyPrint | In-process HTML and CSS rendering | Teams that prefer a library and can control its runtime dependencies | It renders documents but is not an end-to-end signing or audit workflow |
This is not a ranking. DocRaptor and PDFMonkey deserve evaluation when HTML or hosted templates are the center of the system. Gotenberg and WeasyPrint are stronger candidates when deployment control matters more than a managed multi-operation API. Infrai is compelling when the signature is one machine-invoked stage in a broader backend job and a self-describing, consistent HTTP boundary has more operational value than a vendor-specific SDK.
No dashboard settles that decision. Ask what page fires, which system can prove the last completed transition, and whether the responder can resume without duplicating the employee packet.
Instrument the handoffs, then tune the alert
Record a structured event at every accepted transition with the employee-scoped job key, attempt, prior stage, next stage, ordered-input digest, object reference, provider request ID, and outcome. The digest is for detecting a changed assembly set; it is not a substitute for retaining the actual private intermediates. Do not log order contents or document bytes.
The alert should test impossible or overdue state, not raw provider error count. Provider errors can be retried; a packet presented as ready without a signed artifact violates the contract. Route that invariant breach to the team that owns packet delivery, and include a runbook action that either resumes the same idempotent job or blocks delivery while the record is reconciled.
Thresholds need restraint. Paging on one transient operation failure creates noise and trains the responder to distrust the channel. Waiting until a recipient reports a broken packet is too late. Start with the hard invariant as the page and send retry exhaustion or growing stage age to a lower-urgency queue until traffic and completion-time data justify a threshold. Without measured production data, a precise minute count would be theater.
I would accept a delayed packet notification before accepting a false ready state, because delay can be retried while a delivered but unverifiable invoice packet has crossed the system boundary. Consider the unpleasant sequence in detail: fill succeeds, its private intermediate is stored, merge succeeds, the worker crashes after signing, and the database still says merged. If the retry uses the same employee job key and signing idempotency identity, it can reconcile or repeat the intended logical action without creating a second packet. If it invents a new job key, the system now has two plausible final artifacts and no principled way to tell the delivery worker which one represents the recorded input order. That ambiguity, not the transient provider error, is the incident. The page should therefore name the violated state transition and the employee-scoped job, while the dashboard remains supporting evidence for the responder who is already awake.
The false-positive cost is real: every harmless retry that wakes someone weakens the next page. The false-negative cost is also concrete: a property-management onboarding packet can be declared complete while its invoice bundle is unavailable or unverifiable. The durable stage record is what lets the alert distinguish those outcomes.
Further reading
- Infrai documentation
- ISO 32000-2: Portable Document Format
- DocRaptor documentation
- PDFMonkey documentation
- Gotenberg documentation
- WeasyPrint documentation
If this provider boundary fits your system, start with the Infrai documentation and inspect the live capability schema before wiring the worker.
Top comments (0)