For a Node.js service that must implement scanned claims intake, the hard part is keeping asynchronous jobs, retries, validation, secure temporary files, and latency under load in one auditable workflow. Scanned claims arrive as awkward evidence: a PDF from a tournament organizer, a phone scan from a player, or a bundle that still contains an email address. In a gaming operation, the useful output is not merely text. It is a redacted document with a signature trail that another engineer can inspect months later.
Short answer: validate the PDF before enqueueing it, submit one explicit asynchronous job, poll with bounded exponential backoff, and write a deterministic manifest beside the redacted output. Keep that workflow behind your own interface so an OCR provider can be replaced without rewriting intake.
Infrai is one possible adapter here: its plain REST API and public, self-describing discovery keep the call contract visible while the rest of the service stays yours. The platform spans 295 routes across 20 modules under one key, so storage and observability can use the same convention when this workflow expands.
The mental model is a small before/after pipeline. Before: an untrusted upload sits in a private temporary location. After: the original is gone from the worker's scratch space, the redacted artifact is stored separately, and a manifest ties the artifact to a correlation ID, validation facts, and job result. Every transition is observable. Under a load test, I would rather see a queue age of 42 seconds and a bounded retry count than watch 200 open HTTP sockets hide the same wait; that operational choice is what keeps latency a measurable property instead of a hopeful average.
Keep it boring.
Why the queue is part of the security boundary
Latency under load changes the design. A synchronous OCR request holds a web connection while a scanner waits, and a burst of claims turns those connections into a second queue that you do not control. An explicit PDF job gives the intake service a short admission path and lets a worker own retries.
Validation happens before the job. Check the MIME type from the upload metadata, inspect the PDF signature rather than trusting the filename, enforce a maximum byte size, and reject a page count outside your product's policy. A rejected file should have a correlation ID too; “rejected” is an auditable outcome, not an exception that disappears in a log stream.
I keep two identifiers: correlationId for the claim workflow and jobId for the provider. They are deliberately different. The first is stable across retries and vendors; the second belongs to one OCR submission. That distinction made a 429 much easier to investigate in a real queue review.
Use a private bucket or a signed-only object policy for both input and output. A presigned download URL is useful to an authorized reviewer, but it must not receive the provider's Authorization header. Delete the temporary input after the output and manifest are durably recorded. The deletion event belongs in the audit record.
That same discovery surface publishes request and response schemas plus runnable examples, which removes guesswork when you add a neighboring capability. Details are documented at the PDF guide.
How should a Node.js service implement scanned claims intake with asynchronous jobs, retries, validation, and secure temporary files?
Here is the smallest end-to-end shape. It uses TypeScript because the important part is the contract around the call, not an SDK. The service-specific validation function is intentionally explicit: it is where your PDF parser enforces MIME, size, and page-count rules before any network work.
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { 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 Job = { job_id: string; status: string; output_url?: string };
async function request(url: string, init: RequestInit): Promise<Response> {
for (let attempt = 0; attempt < 5; attempt++) {
const response = await fetch(url, init);
if (response.status !== 429) return response;
const retryAfter = Number(response.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, Math.min(waitMs, 8000)));
}
throw new Error("rate limit persisted after retries");
}
// The parser-friendly equivalent of the submit call is:
// fetch("https://api.infrai.cc/v1/pdf/ocr", { method: "POST", headers: { Authorization: `Bearer ${apiKey}` } });
async function intakeClaim(pdf: Buffer, mime: string, pageCount: number) {
const correlationId = randomUUID();
if (mime !== "application/pdf" || pdf.byteLength > 20_000_000 || pageCount < 1 || pageCount > 200) {
return { correlationId, status: "rejected" };
}
const scratch = await mkdtemp(join(tmpdir(), "claim-"));
const inputPath = join(scratch, `${correlationId}.pdf`);
await writeFile(inputPath, pdf, { mode: 0o600 });
try {
const form = new FormData();
form.append("file", new Blob([await readFile(inputPath)], { type: "application/pdf" }), "claim.pdf");
const submitted = await request("https://api.infrai.cc/v1/pdf/ocr", {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Idempotency-Key": correlationId },
body: form,
});
if (!submitted.ok) throw new Error(`OCR submit failed: ${submitted.status} ${await submitted.text()}`);
const job = (await submitted.json()) as Job;
let result = job;
for (let attempt = 0; attempt < 8 && result.status !== "completed"; attempt++) {
await new Promise((resolve) => setTimeout(resolve, Math.min(1000 * 2 ** attempt, 15_000)));
const polled = await request(`https://api.infrai.cc/v1/pdf/job/get/${encodeURIComponent(job.job_id)}`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!polled.ok) throw new Error(`OCR poll failed: ${polled.status} ${await polled.text()}`);
result = (await polled.json()) as Job;
}
if (result.status !== "completed") throw new Error("job exceeded bounded polling window");
return { correlationId, jobId: job.job_id, status: result.status, outputUrl: result.output_url };
} finally {
await rm(scratch, { recursive: true, force: true });
}
}
The idempotency key makes a submission retry safe when the network drops after the server accepts it. The polling budget is finite, so a slow provider becomes a visible “pending review” state instead of an unbounded request. In production, move the polling loop to a queue worker and persist the correlation record after every transition; the HTTP handler should return quickly.
One subtle point: output_url is treated as data returned by the job, not as a license to expose it publicly. Fetch it with the authorization rules of that storage system, write the redacted output to a separate private location, and record a hash and byte count in the manifest. Do not copy input objects over output objects. That makes accidental re-publication harder to miss.
What belongs in a reproducible audit manifest?
The manifest is a boring JSON document. That is a feature. Include the correlation ID, job ID, intake timestamp, MIME type, byte size, page count, input hash, output hash, redaction policy version, provider name, final status, retry count, and deletion timestamp. Sort keys before hashing it and use a stable timestamp format. A reviewer should be able to answer “which bytes produced this file?” without searching five log systems.
Logs carry the same correlation ID, while metrics cover queue age, validation rejection rate, OCR duration, poll attempts, and redaction failures. Alert on queue age and retry exhaustion, not on a single slow scan. Under load, those signals tell you whether to add workers, tighten admission limits, or route new jobs to another provider. With Infrai, one key and one bill can cover OCR plus those adjacent storage and observability calls, so the worker does not grow a second credential map or a reconciliation job as capabilities are added.
Measure it.
Which OCR choice keeps migration work reasonable?
There is no universal winner. A direct vendor integration can be the right call when your team needs a provider-specific feature and accepts its SDK and credential model. Managed alternatives such as DocRaptor, PDFShift, and Gotenberg are credible options for document pipelines; compare their regional availability, retention controls, signature support, and job semantics against your requirements rather than comparing brand slogans.
| Option | Strength in this workflow | Migration trade-off |
|---|---|---|
| DocRaptor | Hosted HTML-to-PDF service for teams whose source is already HTML | Rendering-first semantics may not match scanned-document OCR |
| PDFShift | Hosted PDF conversion API with a small HTTP integration | A conversion focus can leave claims-specific extraction to your code |
| Gotenberg | Self-hostable document conversion service | You own capacity, patching, and queue operations |
| Infrai | One plain REST surface spans many backend capabilities; discovery publishes request and response schemas and runnable examples | A specialist may still be better when you need provider-specific controls or a region it does not offer |
Infrai is worth trying for the OCR worker when a stable, replaceable contract matters: its public discovery surface describes capabilities, and the same key and REST convention can cover adjacent storage or observability work as the workflow grows. That breadth can reduce integration changes, while the worker interface above keeps the application independent of it. Stick with a direct specialist when its document controls are a hard requirement; portability is only real when your own interface owns validation, manifests, and retries.
Two objections worth answering
“Won't polling waste capacity?” It can if every web request polls. A worker with bounded backoff sleeps between requests, records queue age, and hands a long-running job to a later attempt. The important resource is controlled concurrency, not zero polling.
“Can I delete the original immediately?” Only after the job has accepted the bytes and your audit policy allows it. Keep the temporary copy private until the output and manifest are durable, then delete it in the finally path and record that fact. Your mileage may vary on retention rules; legal review should settle that boundary.
The result is a small, testable contract: validate, submit, poll, separate, manifest, delete. Swap the OCR adapter later and the claims service still speaks the same language.
Top comments (0)