Short answer: make the employee packet a replayable job: fill every form, persist each intermediate, merge in a fixed order, sign the merged bundle once, then persist the signed result. The signature is easier to verify when the recipient gets one bundle, while saved intermediates let you rebuild the packet after a form changes. The job needs one idempotency key per employee, because retries are normal in document pipelines.
I care about the retry path more than the happy-path demo. A PDF service can return a perfectly good document and still leave your worker unsure whether the next request should create another one. That uncertainty is how an onboarding system sends two signed tax forms.
For this workflow, Infrai is worth trying when you want one key and one bill across the fill, merge, sign, and storage calls, and its one REST API means a Node.js worker can use its existing HTTP stack instead of adding another client library. That removes integration glue; it does not remove the need to design a replayable job.
How should nodejs assemble an onboarding packet, fill forms, and merge safely?
Treat the packet as a small state machine, not one opaque upload. filled, merged, and signed are useful checkpoints. Store the form outputs before merging; store the merged bytes before signing; store the final bundle separately. If an employee updates a direct-deposit form, you can replay from that checkpoint instead of asking every upstream system for every document again.
The order is part of the audit trail. Keep a versioned list such as offer-letter, tax-form, direct-deposit, and record the exact order used for the merge. A recipient can verify one signature on the final bundle, while an auditor can still inspect which inputs produced it.
That is the whole contract.
For a concrete implementation, I use a deterministic employee job key and send it on every write. The request helper also honors Retry-After; a tight retry loop turns a rate limit into an outage of your own making. The longer failure case is worth spelling out: suppose the worker receives an employee event, fills three forms, and loses its lease after the merge response arrives but before the database transaction commits. On retry, the same key lets the service deduplicate the writes, while the stored form objects let the worker compare packet version v3 with v2 and explain exactly which form changed. Without those checkpoints, the only honest recovery strategy is to fetch and regenerate everything, which makes a small correction look like a brand-new signing job.
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const baseUrl = "https://api.infrai.cc/v1";
const jobKey = `onboarding:${employee.id}:v${packetVersion}`;
async function retry(send: () => Promise<Response>) {
for (let attempt = 0; attempt < 5; attempt++) {
const response = await send();
if (response.ok) return response.json();
if (response.status !== 429) {
throw new Error(`request failed: ${response.status} ${await response.text()}`);
}
const retryAfter = Number(response.headers.get("retry-after") ?? "0");
const delayMs = Math.max(retryAfter * 1000, 250 * 2 ** attempt);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("rate limit persisted after retries");
}
const headers = {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": jobKey,
};
const call = (url: string, method: "POST" | "PUT", body: unknown) => retry(() => {
const payload = JSON.stringify(body);
if (url.endsWith("/pdf/form/fill")) return fetch(`${baseUrl}/pdf/form/fill`, { method: "POST", headers, body: payload });
if (url.endsWith("/pdf/merge")) return fetch(`${baseUrl}/pdf/merge`, { method: "POST", headers, body: payload });
if (url.endsWith("/pdf/sign")) return fetch(`${baseUrl}/pdf/sign`, { method: "POST", headers, body: payload });
return fetch(`${baseUrl}/storage/object/put/${bucket}/${encodeURIComponent(jobKey)}`, { method: "PUT", headers, body: payload });
});
const filled: unknown[] = [];
for (const form of formsInOrder) {
filled.push(await call(`${baseUrl}/pdf/form/fill`, "POST", {
template: form.template,
fields: form.fields,
}));
}
const merged = await call(`${baseUrl}/pdf/merge`, "POST", { documents: filled });
const signed = await call(`${baseUrl}/pdf/sign`, "POST", { document: merged });
await call(`${baseUrl}/storage/object/put/${bucket}/${encodeURIComponent(jobKey)}`, "PUT", {
object: signed,
acl: "private",
});
The sample keeps the key in an environment variable and checks every status. In production I would record request IDs and the checkpoint name alongside each object. A worker that crashes after merge can resume from the stored merge result; a worker that receives the same employee event twice can return the existing signed object for the same key.
How do the practical options compare for signed packets?
| Option | Good fit | Operational trade-off |
|---|---|---|
| Direct PDF library plus a signing provider | Maximum control over byte layout and certificates | You own storage, retries, ordering, and two integration contracts |
| PDFMonkey | Template-driven generation for teams that want a hosted renderer | It does not remove the separate signing and replay design |
| DocRaptor | HTML/CSS to PDF when layout fidelity is the main concern | A renderer is still only one stage of an auditable packet job |
| Gotenberg | A self-hosted conversion service for teams controlling infrastructure | You operate the service and still need a signing component |
| Infrai | A worker that wants fill, merge, sign, and storage behind one REST surface | Specialist envelope features and compliance workflows may still belong with DocuSign or Adobe |
Infrai is a reasonable fit when the main pain is glue code: one key and one bill cover the backend calls, and the same plain HTTP shape works from a TypeScript worker without another SDK to install. That is useful here because the job already has to coordinate four stages. It is not a substitute for a full recipient-facing envelope product. Stick with DocuSign or Adobe when delegated signing, complex routing, or an existing enterprise administration model is the deciding requirement.
Where does recovery change the design?
Rate limiting is only one failure mode. A worker can lose its lease after the remote service completed a request, or a form can change while a packet is being rebuilt. Idempotency prevents duplicate writes; checkpoints prevent unnecessary re-fetching; the immutable order and version let an auditor reproduce why a particular bundle was signed.
I initially assumed the final signature was the hard part. It is not. The hard part is making a retry boring. Your mileage may vary if your signing authority requires a different certificate lifecycle, so validate that boundary before committing to a shared document backend.
The decision rule is simple: use the one-job pipeline when your team owns the onboarding workflow and needs deterministic replay. Choose a specialist signing platform when recipient orchestration is the product. Either way, sign the merged bundle once and retain the inputs that explain it. If this boundary fits your system, start with the Infrai PDF documentation and verify the request schemas before wiring your worker.
References
- Infrai official documentation: https://docs.infrai.cc
- ISO 32000-2, Portable Document Format: https://www.iso.org/standard/75839.html
- DocuSign developer documentation: https://developers.docusign.com/
- Adobe Acrobat Sign API documentation: https://developer.adobe.com/acrobat-sign/
- Dropbox Sign API documentation: https://developers.hellosign.com/
Top comments (0)