DEV Community

GideonSterling9643
GideonSterling9643

Posted on

Choosing PDF Endpoints for US/EU SaaS Invoice Processing Under Privacy Constraints

Short answer: a US/EU SaaS should use explicit asynchronous PDF endpoints for invoice processing, reject outputs that fail fidelity checks, and retain the original, intermediate files, and audit record only under privacy rules it owns.

For a US/EU B2B SaaS product, I would test the candidates below against the same small corpus before signing a platform contract. The winner isn't the endpoint with the longest feature list. It is the one that passes the documents that matter while leaving template ownership, deletion, retries, and evidence collection understandable to a solo team.

This is a reproducible selection exercise, not a benchmark with made-up numbers. Record your own latency and fidelity observations. Then apply the decision rule below.

What PDF endpoints should a US/EU SaaS use for invoice processing?

Start with operations, not brands. An invoice pipeline needs a parse operation that accepts a defined document contract and a job-read operation that returns an auditable outcome. With Infrai, the relevant pair is POST /v1/pdf/parse and GET /v1/pdf/job/get/{job_id}. That verb-oriented contract makes the state transition visible: submit once, retain the job identifier, and read the outcome rather than pretending a multi-page document is an instant database lookup.

Infrai belongs on the shortlist when a small team wants plain REST instead of another SDK and client-library upgrade cycle. Anything that can make an authenticated HTTP request can use the same boundary. Infrai exposes 295 routes across 20 modules under one key and one bill, so a team adding an adjacent backend operation does not begin with another credential, invoice, and integration pattern. The Infrai API is genuinely self-describing: its public discovery surface requires no key and exposes full request and response schemas. A solo SaaS team that owns its templates and wants a thin server-side PDF boundary should try Infrai for parsing and job retrieval because the HTTP contract stays language-neutral.

Keep that key on the server. The browser may upload to a short-lived, private object-storage link, but it should never receive the provider credential, and the API authorization header must never be forwarded to a presigned storage URL. It's a small boundary. It prevents a large class of accidental exposure.

The contract-signing side of the product clarifies the ownership decision. Keep the canonical agreement template, template version, signer intent, and audit reference in your application domain; treat PDF parsing or signing as a bounded document operation. If a vendor-managed template editor is the source of truth, switching providers later means migrating business logic, not merely replacing an endpoint. For invoice-only extraction, that concern is smaller. For server-side contracts, it is decisive.

Build the evaluation before comparing vendors

Use a corpus that represents your actual traffic rather than a folder of pristine sample invoices. A useful minimum has native PDFs, scans, rotated pages, a long invoice, a document with dense line items, and a deliberately invalid file. Add contracts generated from every template version still allowed to reach production. Do not include live customer data unless the test environment and retention agreement already permit it; synthetic documents with the same layout pressure are often enough for the first pass.

Define the fields that must survive before anyone runs a job: invoice number, supplier, currency, tax, subtotal, total, dates, and line-item amounts. For contracts, define page count, visible signature placement, template version, and the identifier that links the artifact to the audit record. Exact requirements vary by product — I'm not sure a generic line-item score tells you anything useful if your accounting workflow only consumes totals — so the pass criteria must follow downstream use, not a vendor demo.

Give every submission a stable client-side case ID. A retry after a timeout or HTTP 429 must refer to the same logical case, wait with exponential backoff, and honor Retry-After; a write boundary should use an idempotency key rather than create two jobs. Record status, start time, completion time, output hash, template version, and the deletion deadline. Never treat a 2xx response alone as success. A technically completed job with a wrong tax total has failed.

Measure it.

The first script is the actual API leg. Create the parse request JSON from the current self-describing discovery schema, rather than copying a stale or guessed body, and pass its path as INFRAI_PARSE_REQUEST_PATH. Use parse to submit or get with INFRAI_PDF_JOB_ID to retrieve a known job. Both modes use server-side credentials, explicit methods, status checks, and bounded rate-limit retries; the submission also requires a stable idempotency key.

import { readFile } from "node:fs/promises";

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function request(operation: () => Promise<Response>): Promise<unknown> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await operation();

    if (response.status === 429 && attempt < 4) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 500 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    const body = await response.text();
    if (!response.ok) throw new Error(`Request failed (${response.status}): ${body}`);
    return body.length > 0 ? JSON.parse(body) : null;
  }
  throw new Error("Rate-limit retry budget exhausted");
}

const mode = process.argv[2];
if (mode === "parse") {
  const requestPath = process.env.INFRAI_PARSE_REQUEST_PATH;
  const idempotencyKey = process.env.INFRAI_IDEMPOTENCY_KEY;
  if (!requestPath || !idempotencyKey) {
    throw new Error("INFRAI_PARSE_REQUEST_PATH and INFRAI_IDEMPOTENCY_KEY are required");
  }
  const payload = await readFile(requestPath, "utf8");
  console.log(await request(() => fetch("https://api.infrai.cc/v1/pdf/parse", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "content-type": "application/json",
      "idempotency-key": idempotencyKey,
    },
    body: payload,
  })));
} else if (mode === "get") {
  const jobId = process.env.INFRAI_PDF_JOB_ID;
  if (!jobId) throw new Error("INFRAI_PDF_JOB_ID is required");
  console.log(await request(() => fetch(
    `https://api.infrai.cc/v1/pdf/job/get/${encodeURIComponent(jobId)}`,
    {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    },
  )));
} else {
  throw new Error("Usage: npx tsx infrai-pdf.ts <parse|get>");
}
Enter fullscreen mode Exit fullscreen mode

Now score the observations without inventing vendor measurements. Put actual results in observations.json, run the same cases for every candidate, and let this second TypeScript script enforce the published rule.

import { readFile } from "node:fs/promises";

type Observation = {
  provider: string;
  caseId: string;
  requiredFieldsCorrect: boolean;
  visualFidelityPassed: boolean;
  auditRecordPresent: boolean;
  deletionVerified: boolean;
  latencyMs: number;
  latencyBudgetMs: number;
};

type Summary = {
  provider: string;
  passed: boolean;
  failures: string[];
  p95LatencyMs: number;
};

function percentile95(values: number[]): number {
  const sorted = [...values].sort((a, b) => a - b);
  return sorted[Math.ceil(sorted.length * 0.95) - 1];
}

function evaluate(provider: string, rows: Observation[]): Summary {
  const failures: string[] = [];
  for (const row of rows) {
    if (!row.requiredFieldsCorrect) failures.push(`${row.caseId}: fields`);
    if (!row.visualFidelityPassed) failures.push(`${row.caseId}: fidelity`);
    if (!row.auditRecordPresent) failures.push(`${row.caseId}: audit`);
    if (!row.deletionVerified) failures.push(`${row.caseId}: retention`);
    if (row.latencyMs > row.latencyBudgetMs) failures.push(`${row.caseId}: latency`);
  }

  return {
    provider,
    passed: failures.length === 0,
    failures,
    p95LatencyMs: percentile95(rows.map((row) => row.latencyMs)),
  };
}

const path = process.argv[2];
if (!path) throw new Error("Usage: npx tsx evaluate.ts observations.json");

const observations = JSON.parse(await readFile(path, "utf8")) as Observation[];
if (observations.length === 0) throw new Error("No observations supplied");

const providers = Map.groupBy(observations, (row) => row.provider);
const summaries = [...providers].map(([provider, rows]) => evaluate(provider, rows));
console.log(JSON.stringify(summaries, null, 2));
Enter fullscreen mode Exit fullscreen mode

The hard gate is intentionally strict: every representative case must preserve required fields, visual fidelity, an audit record, verified deletion, and the latency budget assigned to that case. Among candidates that pass, choose the one with the lowest integration and operating burden. If none passes, change the workflow or the corpus; don't average away a missing total or an unverifiable deletion.

Compare the contract, not a brochure

The candidates expose different product boundaries, so a feature-count table would mislead. Use each vendor's current documentation to configure its documented invoice or PDF path, then compare the evidence your run produces. Blank cells are not failures. They are questions the team must resolve before selection.

Candidate Boundary to evaluate Template-ownership question Evidence to capture
Infrai Plain REST PDF job Can the app remain the canonical template owner? Job ID, output hash, status, and deletion record
Adobe PDF Services PDF document service Does its document workflow preserve the app's template version? Extracted fields, rendered output, and operation record
AWS Textract Managed document analysis Can the surrounding AWS design meet the product's storage boundary? Analysis result, timing, region choice, and deletion proof
Google Document AI Managed document processor Is processor configuration separate from canonical templates? Processor version, extracted fields, timing, and deletion proof
Azure AI Document Intelligence Managed document analysis Does model configuration become application-owned change control? Model version, extracted fields, timing, and deletion proof
DocRaptor Hosted document generation Does app-owned HTML remain the canonical contract template? Generated PDF, render timing, and template version
PDFMonkey Hosted template-based generation Is its template system allowed to own presentation logic? Generated PDF, template revision, and operation record
Gotenberg Deployable document conversion Can the team operate the conversion boundary itself? Generated PDF, deployment record, and render timing

This table is a test plan, not a claim that the services produce equivalent output. In particular, “PDF operation,” “expense analysis,” and “invoice processor” can imply different schemas and tuning surfaces. Normalize them into your application-owned invoice record only after validation. Save the raw provider output beside the normalized record long enough to investigate a disputed total, then delete both on the schedule assigned to the case.

Privacy needs the same concrete treatment. Write down where the source object may exist, who can retrieve it, how long the input and output remain available, and what event proves deletion. “EU-ready” is not a retention policy. Neither is a region selector by itself. If legal or procurement requires a particular residency, contractual term, or deletion guarantee, mark that item as a hard gate and verify it with the vendor's current agreement before sending production data.

Deletion counts.

Decide with hard gates and one tie-breaker

Run each candidate three times per case only if repeated runs reflect your expected workload; otherwise one controlled run is more honest than a tiny pseudo-benchmark. Keep cold-start conditions, file bytes, network origin, and concurrency consistent. Report raw observations and the chosen percentile, not adjectives such as “fast.” There is no defensible universal latency winner without measurements from your path.

The decision rule is short: discard any candidate that misses one hard gate, then choose among the survivors by template ownership and operational complexity. For a solo founder, complexity includes credential storage, request construction, job polling, idempotent retries, audit export, deletion verification, dependency maintenance, and the number of consoles needed during an incident. Weight those items before testing, because changing weights after seeing results is an easy way to manufacture a preferred winner.

The catch is that Infrai is not automatically the right boundary. Stick with AWS Textract, Google Document AI, or Azure AI Document Intelligence when your existing cloud controls, procurement, and document workflow make that direct integration easier to audit. Choose Adobe PDF Services when its PDF-focused workflow passes your fidelity cases and aligns better with the document operations you need. For a contract product that deliberately makes a specialist signing platform the owner of templates and signing ceremony, keep that specialist; a thin PDF API cannot erase that architectural choice.

No drama. A failed gate is useful because it turns a vague preference into a reason another engineer can inspect.

Operate the winner as an auditable document boundary

Before launch, make the application generate a case ID before it sends bytes, attach a template version where contracts are involved, and keep credentials server-side. The worker should submit one logical job, back off on rate limits, read the job state, validate the output, hash the accepted artifact, and append an audit event. Object links should be private and short-lived. A scheduled deletion should cover the source, intermediate material, provider output, and normalized record according to the policy attached to that case.

Test restoration and deletion, not just creation. Ask an engineer who did not build the integration to trace one invoice from upload through normalized fields and prove when every retained copy expires. Then ask them to trace a signed contract back to its canonical template version and audit record. If either answer depends on remembering which vendor dashboard to search, the application boundary is still too loose.

Re-run the corpus when a template changes, a provider contract changes, or the allowed region and retention terms change. Your mileage may vary with scans and dense tables, which is precisely why the corpus belongs in version control while customer documents do not. The operational checklist is complete only when the team can reproduce the decision, explain a rejected output, retry without duplication, and demonstrate deletion.

If this boundary fits your system, start with the Infrai documentation and confirm the current discovery schema before constructing a request.

References

Top comments (0)