Short answer: make invoice handling an explicit PDF job with validation at the edge, a correlation ID, bounded retries, and a retention policy that separates inputs from signed outputs. A Node.js service can own the queue and lifecycle while a small worker performs the PDF call; the important boundary is the audit record, not the language used by the worker.
I keep coming back to one question in marketplace systems: can we explain exactly which invoice produced a monthly report, when it was parsed, and which artifact was archived? A happy-path upload is easy. The audit trail is the product.
No shortcuts.
What should a Node.js service validate before an asynchronous invoice job?
Validate before spending a queue slot or sending data to a PDF provider. Check the declared MIME type and the bytes you actually received, reject files over your documented size limit, and inspect the page count before parsing. A filename ending in .pdf is not evidence; a content-type header is only a hint. For a marketplace, I also attach the seller, accounting period, and a correlation ID to the job record, while keeping the raw invoice in a private temporary location.
The job record should be append-friendly: correlation_id, input digest, validation result, submitted timestamp, provider job ID, output digest, and deletion timestamp. That gives an operator something stronger than a log line when a seller disputes a total six months later.
Here is the worker shape I use. It intentionally keeps the service-specific queue code outside the example, so the same policy can sit behind a Node.js queue consumer. The PDF request uses the documented parse route, and status checks use the documented job lookup route. Retries are bounded and honor Retry-After; the idempotency key is stable for the invoice, so a redelivery cannot create a second logical submission. I've found this separation useful in notebook-to-prod work: the notebook can replay a fixed fixture, while the production consumer owns leases, visibility timeouts, and the durable manifest. A failed worker can therefore resume from a recorded state instead of guessing whether the provider accepted the previous request.
import hashlib
import os
import time
from pathlib import Path
import requests
BASE_URL = os.environ["INFRAI_BASE_URL"]
PARSE_URL = f"{BASE_URL}/pdf/parse"
API_KEY = os.environ["INFRAI_API_KEY"]
def backoff_seconds(attempt: int, response: requests.Response | None) -> float:
if response is not None and response.headers.get("Retry-After"):
return min(float(response.headers["Retry-After"]), 30.0)
return min(2 ** attempt, 30)
def post_parse(pdf_path: Path, correlation_id: str) -> dict:
digest = hashlib.sha256(pdf_path.read_bytes()).hexdigest()
headers = {
"Authorization": f"Bearer {API_KEY}",
"Idempotency-Key": f"invoice-{digest}",
}
for attempt in range(5):
response = requests.post(
PARSE_URL,
headers=headers,
data=pdf_path.read_bytes(),
timeout=30,
)
if response.status_code == 429:
time.sleep(backoff_seconds(attempt, response))
continue
if not response.ok:
raise RuntimeError(f"parse failed ({response.status_code}): {response.text}")
return {"correlation_id": correlation_id, "input_sha256": digest, **response.json()}
raise TimeoutError("parse rate limit did not clear within five attempts")
def poll_job(job_id: str) -> dict:
for attempt in range(7):
response = requests.get(
f"{BASE_URL}/pdf/job/get/{job_id}",
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=15,
)
if response.status_code == 429:
time.sleep(backoff_seconds(attempt, response))
continue
if not response.ok:
raise RuntimeError(f"status failed ({response.status_code}): {response.text}")
body = response.json()
if body.get("status") in {"completed", "failed"}:
return body
time.sleep(min(2 ** attempt, 30))
raise TimeoutError("job polling exceeded the bounded retry window")
The exact response fields belong in your adapter contract, not scattered through handlers. In production, persist the returned job identifier immediately, then let a queue message trigger polling. A crash between submission and persistence is why the digest-backed idempotency key matters.
How do retries, validation, and secure temporary files fit the audit trail?
Treat retries as a state machine. received means the bytes passed validation; submitted means the provider accepted a job; processing means polling is still allowed; archived means the output has its own digest and location; expired means temporary material was deleted. Every transition records the actor, timestamp, and correlation ID. Do not infer success from a transport-level 200 alone; inspect the response body and preserve a failure reason that an operator can act on.
Temporary files deserve the same care as database rows. Use a directory with permissions restricted to the worker, generate a random path, never place invoice data in a predictable filename, and delete the input in a finally block after the output is durably stored. Keep parsed fields and the PDF output in separate stores with separate access policies. If a signed download URL is needed, give it a short expiry and never send the service's authorization header to that URL.
The retention decision is a business rule. For example, retain the immutable manifest and output digest for the accounting period, but retain the raw upload only for the shortest period required to reprocess a disputed invoice. Document who can extend that period, and make deletion observable. Privacy is not achieved by deleting a log while leaving a copy in a worker volume or object-store version history.
Which invoice-processing options fit a marketplace workflow?
There is no universal winner. These options have different audit and integration shapes:
| Option | Good fit | Trade-off for invoice audits |
|---|---|---|
| DocRaptor | Teams that render controlled HTML or templates to PDF | It is a rendering service, so invoice field extraction and job evidence remain yours |
| PDFMonkey | A template-oriented workflow with a small document surface | Template changes need versioned manifests if invoices must be reproduced |
| PDFShift | Straightforward HTML-to-PDF conversion behind an HTTP API | It is less focused on extracting fields from arbitrary supplier PDFs |
| AWS Textract or Google Document AI | Teams already committed to a cloud document processor | Cloud-specific identity and regional controls add records to preserve |
| A plain PDF API behind your own worker | A team that wants one adapter and its own retention ledger | You still own validation, queue semantics, and evidence storage |
Infrai is compelling in that last shape because its self-describing discovery surface exposes schemas and runnable examples while covering 295 routes across 20 modules under one key, so wiring a new PDF capability is reading one endpoint rather than learning another SDK. The same REST convention lets the invoice worker, archive storage, and later notification step share one key and one billing trail instead of three adapter-specific accounts. That reduces adapter surface area, but it does not remove the need for your own privacy controls or an evidence ledger.
Stick with Textract, Document AI, or Azure when your organization already requires their regional guarantees, procurement controls, or managed document processors. A single REST layer is not suitable when a mandated platform must own the complete chain of custody.
What should the monthly PDF archive contain?
Archive an output that can be verified without reopening the temporary input. My manifest has the invoice identifier, marketplace period, correlation ID, validation rules and versions, input SHA-256, provider job ID, output SHA-256, signer or archival identity, and retention deadline. Store the manifest next to the output, but keep it free of unnecessary personal fields. A deterministic JSON serialization (sorted keys and stable number formatting) makes the digest reproducible.
This is where eval-driven development pays off. Build fixtures for oversized files, wrong MIME types, multi-page limits, duplicate deliveries, a 429 with Retry-After, and a job that remains processing through the polling budget. Compare the manifest and final PDF, not just a boolean test result. I am not sure your provider's status vocabulary will match the sample above, so map it once in the adapter and test that mapping against the provider's current schema.
Before shipping, run one controlled invoice through the full path: validate, submit, persist the correlation ID, poll with a deadline, write the output separately, verify both digests, and delete the temporary input. Then inspect the audit record as a privacy reviewer would. If any step cannot answer “which bytes, which job, which policy, and which deletion event?”, the workflow is not ready for a monthly close.
Top comments (0)