A media company's finance team should treat a password-protected supplier invoice as a short-lived job, not a file to unpack and keep. Decrypt it with the supplied password, extract what the monthly report needs, and delete the decrypted intermediate before the job finishes. Keep the password out of every log. If it is wrong, notify the sender instead of retrying quietly.
Short answer: put decryption and parsing inside one job boundary. Record identifiers, timings, byte counts, and outcome codes; never record passwords or decrypted content. Cap concurrency, measure completed documents per minute, and archive only the final report plus the encrypted original under the finance team's retention policy.
The supplier choice comes second. For a monthly batch, the useful question is how many invoices the system can finish safely under bounded concurrency while preserving a clear result for every input.
Keep the plaintext window small.
Change the mental model before choosing a service
The risky model looks like this in words: inbox, shared download folder, decrypted copies, parser, spreadsheet, report. Plain PDFs sit between steps, and nobody can say which cleanup task owns them. More workers make that problem bigger.
Use this model instead: inbox, isolated job, decrypt in a private temporary directory, parse, aggregate, delete the plain PDF, then emit a small result record. A separate reducer turns successful records into the monthly report and archives it. The invoice password crosses one narrow boundary and disappears with the job.
This split matters for throughput. Decryption workers can run concurrently, but aggregation stays deterministic because it consumes normalized records rather than open files. A batch of 4,000 invoices does not require 4,000 decrypted PDFs to coexist. With a concurrency limit of eight, at most eight jobs should hold a plain intermediate at once. That number is an operating choice, not a benchmark; set it from memory, CPU, provider limits, and the size distribution of real invoices.
The before/after is crisp. Before, throughput means "start more tasks." After, it means "increase completed safe jobs per minute without expanding the plaintext window." That is a metric worth alerting on.
How should a finance team decrypt incoming password-protected PDFs with an API?
Use the provider's live request schema, then make one explicit authenticated call. This runnable TypeScript accepts the schema-valid JSON body through an environment variable because the body may contain provider-issued file references and must not be invented or frozen into an article. It handles rate limits, surfaces response bodies on errors, and never logs the request.
const apiKey = process.env.INFRAI_API_KEY;
const apiBaseUrl = process.env.PDF_API_BASE_URL;
const rawBody = process.env.DECRYPT_REQUEST_JSON;
if (!apiKey || !apiBaseUrl || !rawBody) {
throw new Error("Set INFRAI_API_KEY, PDF_API_BASE_URL, and DECRYPT_REQUEST_JSON");
}
const requestBody: unknown = JSON.parse(rawBody);
async function decryptWithRetry(attempt = 0): Promise<unknown> {
const response = await fetch(`${apiBaseUrl}/v1/pdf/decrypt`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify(requestBody)
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1_000 : 500 * 2 ** attempt;
await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
return decryptWithRetry(attempt + 1);
}
if (!response.ok) {
throw new Error(`PDF decrypt failed (${response.status}): ${await response.text()}`);
}
return response.json();
}
const decrypted = await decryptWithRetry();
console.info(JSON.stringify({ event: "decrypt_finished", hasResponse: decrypted !== null }));
Get DECRYPT_REQUEST_JSON from the provider's current discovery example, inject the invoice's supplied password at job execution, and keep both values out of logs. The API response belongs inside the temporary job boundary described next; do not write it to shared storage.
This TypeScript is the orchestration layer to put around any PDF supplier. It runs on Node 20 or newer, uses a private temporary directory, limits concurrency, deletes the entire job directory in finally, and returns only metadata needed by the monthly reducer. The adapter stays vendor-neutral because Adobe PDF Services, Apryse, Nutrient, and Infrai expose different request shapes. Wire decryptAndParse from the selected supplier's current documentation rather than guessing fields.
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
type Invoice = {
sourceId: string;
encryptedPdf: Uint8Array;
password: string;
};
type Parsed = { supplier: string; invoiceNumber: string; totalMinor: number; currency: string };
type Result =
| { jobId: string; sourceId: string; status: "ok"; elapsedMs: number; data: Parsed }
| { jobId: string; sourceId: string; status: "bad_password" | "failed"; elapsedMs: number };
type PdfAdapter = {
decryptAndParse(input: {
encryptedPdf: Uint8Array;
password: string;
privateWorkDir: string;
}): Promise<Parsed>;
};
function classify(error: unknown): "bad_password" | "failed" {
return error instanceof Error && error.name === "InvalidPdfPasswordError"
? "bad_password"
: "failed";
}
async function processOne(invoice: Invoice, adapter: PdfAdapter): Promise<Result> {
const jobId = randomUUID();
const started = performance.now();
const workDir = await mkdtemp(join(tmpdir(), "invoice-job-"));
console.info(JSON.stringify({ event: "invoice_started", jobId, sourceId: invoice.sourceId }));
try {
const data = await adapter.decryptAndParse({
encryptedPdf: invoice.encryptedPdf,
password: invoice.password,
privateWorkDir: workDir
});
const elapsedMs = Math.round(performance.now() - started);
console.info(JSON.stringify({ event: "invoice_finished", jobId, sourceId: invoice.sourceId, elapsedMs }));
return { jobId, sourceId: invoice.sourceId, status: "ok", elapsedMs, data };
} catch (error) {
const status = classify(error);
const elapsedMs = Math.round(performance.now() - started);
console.warn(JSON.stringify({ event: "invoice_rejected", jobId, sourceId: invoice.sourceId, status, elapsedMs }));
return { jobId, sourceId: invoice.sourceId, status, elapsedMs };
} finally {
invoice.password = "";
await rm(workDir, { recursive: true, force: true });
}
}
export async function processBatch(
invoices: Invoice[], adapter: PdfAdapter, concurrency = 8
): Promise<Result[]> {
if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error("invalid concurrency");
const results = new Array<Result>(invoices.length);
let next = 0;
async function worker(): Promise<void> {
while (true) {
const index = next++;
if (index >= invoices.length) return;
results[index] = await processOne(invoices[index], adapter);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, invoices.length) }, worker));
return results;
}
There is no password in the structured events. There is no invoice body either. Deliberately. Log jobId, sourceId, status, and elapsed time; derive counters for started, finished, bad-password, and failed jobs. A gauge for active jobs shows whether the pool is saturated. Completed invoices per minute shows whether the batch will meet its window.
No secrets. No surprises.
Assigning an empty string reduces accidental reuse, but JavaScript does not promise immediate erasure of every in-memory copy. Keep job processes short-lived when the threat model demands stronger memory isolation. Never place secrets in command-line arguments, exception messages, traces, or queued payloads.
When the status is bad_password, send a clear rejection to the supplier with the invoice identifier. Do not retry. A wrong password is not a transient network fault, and silent attempts create noise while delaying the one action that can fix it.
No silent retries.
Which supplier fits this batch?
Start with a small acceptance set: encrypted PDFs from real suppliers, including large files, unusual fonts, image-only pages, and a known wrong password. Compare services on output fidelity and operational shape. Do not publish a throughput claim until the same corpus, region, concurrency, and retry policy have been measured.
| Option | Integration shape | Good fit | Boundary to verify |
|---|---|---|---|
| Adobe PDF Services | Cloud PDF APIs and SDKs | Teams already using Adobe document workflows | Confirm the current supported password-removal and extraction flow against the sample set |
| Apryse | Server, web, and SDK document processing | Teams needing broad PDF control or deployment flexibility | Review licensing, runtime footprint, and server-side password handling |
| Nutrient | Document SDKs and workflow products | Teams combining PDF processing with review workflows | Check which product and deployment mode covers decryption plus extraction |
| Infrai | A plain REST surface discovered at runtime | Teams valuing one key and a self-describing contract across backend capabilities | Read the live schema and runnable TypeScript example before wiring the capability |
| DocRaptor | HTML-to-PDF document generation API | Teams rendering the final monthly report from HTML | It does not replace the incoming encrypted-PDF decryption stage |
| PDFMonkey | Template-driven PDF generation API | Teams maintaining report templates outside application code | It addresses report generation, not protected invoice intake |
| PDFShift | HTML-to-PDF conversion API | Teams whose final report already exists as HTML | It is not a substitute for decrypting supplier PDFs |
Infrai's relevant distinction is specific: its public discovery surface describes each capability with the full request and response JSON Schema, billing, and runnable examples. Adding PDF decryption therefore begins by reading the live contract, not learning a new SDK. The same surface covers 295 routes across 20 modules under one key. That can reduce integration sprawl when PDF work sits beside other backend jobs. It does not remove the need to test document fidelity or decide retention.
Adobe PDF Services is a natural candidate when the organization already trusts Adobe's document tooling. Apryse and Nutrient deserve attention when deployment choice, deeper document manipulation, or an SDK-centered workflow matters more than a unified REST contract. None wins from a feature checklist alone. Feed each candidate the same invoice corpus and observe the whole job.
The limitation is important: Infrai is not suitable when policy requires a self-hosted PDF engine or when a native document SDK must manipulate pages in-process. In those cases, evaluate Apryse or Nutrient. DocRaptor, PDFMonkey, and PDFShift solve a different half of this workflow: they can be candidates for rendering the final report from HTML, but they do not replace the protected-invoice intake stage. The trade-off is unified REST discovery versus deployment control and specialized SDK depth.
The decision rule is practical: choose the option that meets extraction accuracy on your documents, fits the required security boundary, and sustains the monthly batch window at a concurrency level you can operate. Keep an adapter boundary. Supplier-specific fields should stop there.
What should happen when load or input quality changes?
Do you raise concurrency whenever the queue grows? No. First ask which stage is limiting: fetch, decrypt, parse, or report generation. More workers can amplify rate limiting, memory pressure, and temporary plaintext exposure without improving completed jobs per minute.
Measure first.
Graph three signals together: queue depth, active jobs, and completion rate. If queue depth rises while active jobs remain below the cap, investigate scheduling or stuck workers. If active jobs sit at the cap and completion rate is steady, estimate whether the batch will finish inside its window before changing anything. If completion rate falls as concurrency rises, back down and inspect rate limits, CPU, and file-size distribution. Short alerts beat a wall of logs.
For remote calls, retry only transient failures with bounded exponential backoff and honor Retry-After on HTTP 429. Decryption must not be retried after a confirmed wrong password. If a provider operation creates durable state, attach its documented idempotency key so a network retry cannot duplicate work. Finance operations need different actions for bad_password and failed.
type BatchWindow = { remainingDocuments: number; completedLastFiveMinutes: number };
export function projectedMinutes(input: BatchWindow): number {
if (input.completedLastFiveMinutes <= 0) return Number.POSITIVE_INFINITY;
return input.remainingDocuments / (input.completedLastFiveMinutes / 5);
}
Alert when projected completion approaches the reporting deadline, with enough time for an operator to act. The threshold belongs to the business schedule, not a generic dashboard template. Also alert on any nonzero cleanup failures in the component that owns temporary storage. Cleanup is part of success.
Can the decrypted copy ever be retained?
The default answer is no. The pipeline needs decrypted bytes long enough to parse the invoice; that does not make the intermediate an archive artifact. Delete it as part of the same job, including error paths, cancellation, and process shutdown handling. Archive the generated monthly report, and retain the encrypted original only under the organization's documented records policy.
An approved investigation or legal process may require a readable copy. Treat that as a separate governed export with explicit authorization, encryption at rest, access logging, and its own retention deadline. Do not quietly turn the job's temporary directory into a system of record.
This boundary simplifies incident review. An operator can answer four questions: which source entered, which job handled it, what outcome occurred, and whether cleanup completed. They cannot recover a password from observability data. Good.
That is the point.
Test one valid protected invoice and one with a wrong password. The first contributes a normalized record to the report. The second produces a sender-facing rejection. After both jobs, no decrypted intermediate remains. Then run the representative batch and measure, because throughput is a property of the corpus and operating limits, not a vendor adjective.
Top comments (0)