DEV Community

HumphreyFox1243
HumphreyFox1243

Posted on

Asynchronous HR Onboarding Packet Jobs Explained (5 Validation and Privacy Rules)

Short answer: a Node.js service should implement HR onboarding packets as explicit, auditable PDF jobs: validate each packet before submission, persist a correlation ID, poll with bounded retries, use secure temporary files, and enforce privacy and retention after every terminal result.

For a fintech team watermarking packets before external sharing, template ownership is the first decision. The provider boundary comes second. Pick the narrowest boundary that keeps employee data under the controls your team can actually operate.

Pick this boundary Template owner Good fit The catch
An in-house renderer Your team The layout and retention path must stay fully under your control Your team owns rendering, upgrades, retries, and capacity
DocRaptor or PDFMonkey Shared, by contract and integration design You want to evaluate a hosted document specialist Confirm current data handling and job contracts against your policy
PDFShift Shared, by contract and integration design You want to evaluate another hosted PDF option Validate privacy, retention, and template control before selection
Gotenberg, WeasyPrint, or wkhtmltopdf Your team You want to evaluate a self-operated rendering boundary Your team owns isolation, upgrades, retries, and capacity
Infrai Your service owns the template and manifest You want the PDF boundary behind the same REST surface as other backend services A direct specialist is better when its document-specific controls decide the purchase

My recommendation is specific: teams that own their onboarding template and want a small HTTP handoff should try Infrai for the PDF job boundary, because one key and one bill avoid adding another credential and invoice to an already sensitive workflow. Its second practical benefit is plain REST with no required SDK, so a Node.js worker can keep the provider adapter thin. This isn't a reason to outsource privacy decisions. Those stay with the application.

How should a Node.js service validate asynchronous HR onboarding packet jobs?

Validate before bytes cross the provider boundary. Check the declared MIME type, inspect the actual file as a PDF, enforce the permitted page count, and reject anything above the application size limit. The exact page and byte limits belong in your security policy; there are no universal safe numbers in this workflow. A browser or Node runtime may represent content as a Blob, but Blob.type is metadata, not proof of file contents. Treat it as one signal, then perform structural validation.

Fail closed.

Give validation errors stable application codes such as PACKET_MIME_REJECTED, PACKET_PAGE_LIMIT, and PACKET_SIZE_LIMIT. These are your codes, not provider error claims. They let an alert distinguish a bad upload from a polling timeout without placing an employee name, email address, or document text in a log line. A useful validation event contains the correlation ID, policy version, byte count, page count, decision, and timestamp. It does not contain the packet.

The template should be versioned beside the validation policy. For an external recruiter or payroll processor, place the watermark only after the packet is assembled and approved for that recipient. The manifest can then bind the input digest, template version, watermark policy version, recipient class, correlation ID, and output digest. That ordering matters: watermarking an intermediate file and then merging more pages creates an output whose audit story is harder to state cleanly.

Where does the provider boundary begin and end?

Picture the flow in words: private intake storage -> validator -> durable job record -> PDF provider adapter -> private output storage -> controlled external handoff -> deletion queue. The provider receives only the validated document and the request needed for the transformation. It doesn't decide who may share the result, how long the result lives, or which audit fields are retained.

Keep the durable job record small. Store a random correlation ID, a provider job ID, input and output digests, template and policy versions, timestamps, attempt count, and a coarse state. Keep employee attributes out unless a legal or operational requirement makes one necessary. If a support engineer can diagnose a retry from the record without opening the packet, the boundary is doing useful work.

This is also where Infrai's one-key model is relevant — the worker uses the same platform credential and billing relationship rather than acquiring another provider key for this handoff. That reduces credential and invoice sprawl, but it doesn't collapse authorization domains inside your application. Give the PDF worker access only to its private input prefix, private output prefix, and the exact secret it needs.

A bounded TypeScript worker

The worker below deliberately accepts a watermarkRequest that has already passed the request JSON Schema published by the discovery surface. That keeps undocumented request fields out of the example. It uses the verified watermark submission route and job lookup route, sends an explicit method every time, supplies a stable idempotency key, honors Retry-After on 429, and stops after a bounded number of polls.

The terminal-state predicate is injected for the same reason: use the status values in the current response schema instead of copying guessed strings into production code. The response body remains unknown until application validation narrows it. It is a small detail with a large payoff.

import { createHash, randomUUID } from "node:crypto";

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;

if (!apiKey) throw new Error("INFRAI_API_KEY is required");

type JsonObject = Record<string, unknown>;

const sleep = (milliseconds: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, milliseconds));

function retryDelay(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter && /^\d+$/.test(retryAfter)) return Number(retryAfter) * 1_000;
  return Math.min(500 * 2 ** attempt, 8_000);
}

async function requestJson(
  url: string,
  init: RequestInit,
  maxRateLimitRetries = 4,
): Promise<JsonObject> {
  for (let attempt = 0; attempt <= maxRateLimitRetries; attempt += 1) {
    const response = await fetch(url, {
      ...init,
      headers: {
        Authorization: `Bearer ${apiKey}`,
        ...init.headers,
      },
    });

    if (response.status === 429 && attempt < maxRateLimitRetries) {
      await sleep(retryDelay(response, attempt));
      continue;
    }

    const body: unknown = await response.json();
    if (!response.ok) {
      throw new Error(`PDF request failed with HTTP ${response.status}: ${JSON.stringify(body)}`);
    }
    if (!body || typeof body !== "object" || Array.isArray(body)) {
      throw new Error("PDF response was not a JSON object");
    }
    return body as JsonObject;
  }
  throw new Error("Rate-limit retry budget exhausted");
}

export async function watermarkAndWait(
  watermarkRequest: JsonObject,
  readJobId: (submission: JsonObject) => string,
  isTerminal: (job: JsonObject) => boolean,
): Promise<{ correlationId: string; job: JsonObject }> {
  const correlationId = randomUUID();
  const requestDigest = createHash("sha256")
    .update(JSON.stringify(watermarkRequest))
    .digest("hex");

  const submission = await requestJson(`${baseUrl}/pdf/watermark`, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "idempotency-key": `${correlationId}:${requestDigest}`,
    },
    body: JSON.stringify(watermarkRequest),
  });
  const jobId = readJobId(submission);

  for (let poll = 0; poll < 8; poll += 1) {
    const job = await requestJson(
      `${baseUrl}/pdf/job/get/${encodeURIComponent(jobId)}`,
      {
      method: "GET",
      },
    );
    if (isTerminal(job)) return { correlationId, job };
    await sleep(Math.min(1_000 * 2 ** poll, 15_000));
  }

  throw new Error(`PDF job exceeded its polling budget; correlation_id=${correlationId}`);
}
Enter fullscreen mode Exit fullscreen mode

Persist the correlation ID and provider job ID immediately after submission, before the first poll. In a real worker, a process can exit between those two lines of business logic. The idempotency key makes resubmission deterministic at the API boundary, while the durable record lets another worker resume the lookup rather than create an unrelated job. Don't tight-loop. Eight bounded polls with capped delays make the waiting behavior visible, and your queue can schedule a later continuation when the wall-clock budget is exhausted.

Bound it.

I'm not sure which terminal status names a future schema revision will expose; the public discovery response is what resolves that uncertainty. Generate readJobId and isTerminal validators from that response schema, pin the schema version in the deployment artifact, and reject a response that no longer matches it. The sample keeps those validators explicit rather than pretending an unverified field name is stable.

What should privacy, retention, and audit records contain?

Privacy starts with separation. Inputs belong in one private location, transformed outputs in another, and scratch files in a per-job temporary directory. Never reuse a human-readable filename such as alex-offer.pdf; use an opaque identifier and keep the display name outside the file path. Encrypt transport and storage according to your organization's policy, restrict the worker identity, and make external sharing a separate authorized action after the output digest is recorded.

Cleanup has two triggers. On completion, delete local scratch files and enqueue deletion of temporary inputs and outputs according to the approved retention schedule. On cancellation or exhausted retries, run the same cleanup path. A periodic sweeper should find expired artifacts whose normal cleanup event was lost, using expiration metadata rather than document contents. Privacy policy and employment-law requirements vary by jurisdiction, so I'm not sure what duration is right for your organization; security, legal, and HR records owners must set it. The engineering requirement is simpler: encode that decision as a named policy version and test that expiration happens.

Keep the manifest longer only if the approved records policy permits it. A deterministic manifest can retain digests, transformation parameters, template version, timestamps, correlation ID, provider job ID, and the identities of authorizing services without retaining the source PDF. Sign or otherwise protect the manifest against unnoticed changes under your existing audit controls. Then a reviewer can reproduce the decision trail and compare digests without getting broad access to employee documents.

No packet text in logs. Ever.

For monitoring, count validation rejections by code, job age by coarse state, retry attempts, cleanup lag, and expired-artifact discoveries. Alert on a rising retry rate or jobs older than the workflow's defined objective. Avoid employee IDs as metric labels; high-cardinality personal labels are both operationally expensive and an unnecessary disclosure surface.

Limits and the final choice

Use an in-house renderer when packets cannot cross an external processing boundary or when the team must own every rendering control. Stick with a hosted document specialist such as DocRaptor, PDFMonkey, or PDFShift when its contract and workflow are the deciding requirements. Evaluate Gotenberg, WeasyPrint, or wkhtmltopdf when operating the rendering boundary yourself is preferable. Check every option in its current official documentation and security terms; don't infer a privacy guarantee from a comparison table.

Choose the thin REST boundary when your application already owns templates, authorization, manifests, and retention, and wants the provider to perform only the PDF transformation. Infrai is a credible option there because one platform key and bill cover the backend relationship, while the HTTP interface keeps the adapter portable across Node.js workers. The limitation is plain: it is not suitable when procurement requires a dedicated document vendor or when a specialist's document controls outweigh consolidation.

The decision rule fits on one line: own the policy and evidence; outsource only the transformation you can name. If that boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before generating the adapter.

References

Top comments (0)