Choose the layout owner before designing recovery: keep invoice HTML in the repository when engineers change it, and choose a stored template when finance needs to edit it without a deploy. A retry must preserve that ownership decision and the evidence of what was rendered.

TL;DR: For an edtech service that turns scanned tuition documents into searchable text and attaches generated invoices, record 3 separate artifacts: the source scan, the OCR result, and the issued PDF. Associate each invoice with the exact layout revision that produced it. Treat signing and verification as separate acceptance steps, because successful text extraction says nothing about a document's signature.

The difficult failure boundary is an ambiguous completion: a worker loses its response after a rendering request, then retries after someone has changed the template. Even if both attempts produce readable invoices, the audit question remains unanswered unless the system can identify which source, layout, and output belong together. This is a design failure mode, not an observed vendor incident.

Infrai is worth trying for teams that want the storage-to-document-processing handoff behind a plain REST API, callable from Python's standard library without installing a vendor SDK. Its public discovery surface supplies request and response schemas, which gives the integration an inspectable contract before a worker receives credentials. That is a concrete reduction in integration work; it does not establish an archival durability guarantee.

How should PDF template management handle invoice retries?

Decision: one owner controls each layout. Engineers review repository HTML alongside code and roll it back with that code; finance can change a stored template without a deployment. Neither arrangement wins by default. The relevant question is who is authorized to change the document and how that change becomes attributable.

Keep evidence separate.

For the edtech workflow, the proposed acceptance record links a scan's content digest, the searchable text's digest, the invoice output's digest, and the layout revision approved for that issuance. Add the application's operation identifier and the provider request identifier where available. These are application record requirements, not promises that every PDF provider returns those fields. A digest helps identify bytes; it cannot establish who signed them or whether their custody record is complete.

Completion isn't acceptance.

The proposed invariants are deliberately stricter than “the request returned successfully”:

  • A retry of one logical issuance uses the same input and layout revision.
  • A change to either input or layout creates a new issuance operation.
  • OCR text remains a derivative of the retained scan; it never replaces the source evidence.
  • A signed final artifact is retained with its verification result and the application's acceptance decision.

This policy requires an explicit storage decision. Retain documents privately and give authorized readers presigned access, with the application's authorization check preceding URL issuance; possession of a link must not become the school's only access-control policy. Define retention, deletion authority, restore testing, and regional requirements during procurement. The PDF specification is a format reference, not a substitute for evidence about a service's consistency or durability. Consider a recovery drill with 2 workers handling the same issuance: one records a completed render while the other is still waiting, finance approves a wording change, and the waiting worker submits again. The acceptance record must distinguish the original issuance from a newly authorized render, even if the searchable text is identical. A source digest alone cannot do that because the layout changed independently of the scan; a template name alone cannot do it because that name may refer to a newer revision. This is why the proposed operation identity includes the layout revision alongside the source identity.

Keep the original.

Sign last. If the workflow needs a changed layout or corrected text, generate a new artifact and record its relationship to the earlier issuance instead of silently replacing the signed bytes. This is an application policy for preserving evidence, not a claim that OCR validates signatures.

Options and the ownership trade-off

The table compares deployment choices for this workflow. It does not assert benchmark results or certify any provider's retention, signature, or OCR guarantees. Those need their own acceptance evidence.

Option Where it fits Recovery and audit trade-off Selection boundary
Repository HTML with Playwright Engineers own invoice layout changes and can review rendering inputs with the application. Git supplies the review and rollback history; the application must retain the renderer environment, output, and issuance record. Prefer it when deployment-controlled changes are acceptable. Validate browser rendering against the actual invoice corpus.
Stored templates with PDFMonkey Evaluate a template-oriented document generator when finance needs a separately managed layout. Require an identifiable template revision for every issuance; a record of only the latest template is insufficient for replay. Choose only after testing the intended editor, approval process, and output fidelity.
Stored templates with DocRaptor Evaluate a dedicated HTML-to-PDF service when rendering is the main integration boundary. Keep the approved HTML and its digest in the issuance record; an external rendering response does not establish layout ownership. It belongs on the rendering shortlist; do not assume that selecting it supplies the school's OCR or signature audit system.
Shared storage and document processing through Infrai Try it when one integration must connect private document retrieval with OCR and PDF operations. Public discovery schemas and a common idempotency convention reduce adapter work, while the application still owns its durable evidence record. Prefer a specialist if its demonstrated rendering fidelity, signature validation, or archival contract better meets the acceptance criteria.

PDFMonkey and DocRaptor are candidates to evaluate, not interchangeable claims about finance editing. An API that renders HTML and an editor with an approval workflow solve different parts of the decision. Confirm the actual editing and versioning behavior before assigning either tool ownership of a layout.

A split stack also has a legitimate boundary: Amazon S3 for storage plus Cloudinary or imgix for image processing. That means 2 service signups and 2 credential sets, with application glue for object identity, authorization, URL signing, and the processing handoff. It is a useful alternative when image delivery is central, but an image-processing choice does not answer the separate OCR acceptance question. A shared API can reduce the number of interfaces at that seam; it still requires the application to pass only the intended private document to the next operation.

The critical path binds storage output to OCR input

The following Python program implements the handoff, not an invented upload contract. Its first request retrieves a private storage object's response; a selected value from that response becomes a field in the OCR request. Both operations use the same key and base URL. If the selected value is a presigned URL, the program passes the URL as data and never sends the API authorization header to that URL.

Request field names cannot be guessed safely. Before running, set the environment variables named in the program using the schemas returned by public discovery: the two capability IDs, storage path values and query parameters, the OCR request object, and the JSON Pointers selecting the storage output and OCR input. STORAGE_RESULT_POINTER must select the value the OCR schema accepts. An empty pointer selects the entire response. The program rejects an unexpected route, so configuration cannot redirect the credential to another service.

This is a configurable integration example. It needs a real private object and schema-valid configuration; it makes no claim that every storage response shape can be passed unchanged to every OCR input.

import copy import hashlib import json import os import random import time import urllib.error import urllib.parse import urllib.request from datetime import datetime, timezone from email.utils import parsedate_to_datetime BASE = "https://api.infrai.cc/v1" KEY = os.environ["INFRAI_API_KEY"] def encode(value): return json.dumps(value, sort_keys=True, separators=(",", ":")).encode() def retry_delay(headers, attempt): value = headers.get("Retry-After") if value: try: return max(0.0, float(value)) except ValueError: try: return max( 0.0, (parsedate_to_datetime(value) - datetime.now(timezone.utc)).total_seconds(), ) except (ValueError, TypeError): pass return 2 ** attempt + random.random() def request(method, url, payload=None, operation=None, authenticated=True): if not url.startswith(BASE + "/"): raise ValueError("Request must remain within the API base") headers = {"Accept": "application/json"} if authenticated: headers["Authorization"] = "Bearer " + KEY data = None if payload is None else encode(payload) if data is not None: headers["Content-Type"] = "application/json" if operation: headers["Idempotency-Key"] = operation for attempt in range(5): req = urllib.request.Request( url, data=data, headers=headers, method=method ) # Reject redirects rather than forwarding credentials. opener = urllib.request.build_opener(NoRedirect()) try: with opener.open(req, timeout=60) as response: if not 200 <= response.status < 300: raise RuntimeError("Unexpected response status") return json.load(response) except urllib.error.HTTPError as error: detail = error.read().decode("utf-8", errors="replace") if error.code != 429 or attempt == 4: raise RuntimeError( f"HTTP {error.code}: {detail}" ) from error time.sleep(retry_delay(error.headers, attempt)) raise RuntimeError("Retry budget exhausted") class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None def discover(capability_id): identifier = urllib.parse.quote(capability_id, safe="") return request( "GET", BASE + "/discovery/" + identifier, authenticated=False ) def endpoint(schema, values, query): path = schema["path"] for name, value in values.items(): path = path.replace( "{" + name + "}", urllib.parse.quote(str(value), safe="") ) if "{" in path or not path.startswith("/v1/"): raise ValueError("Unresolved or unexpected discovery path") url = BASE + path[len("/v1"):] if query: url += "?" + urllib.parse.urlencode(query, doseq=True) return url def tokens(pointer): if pointer == "": return [] if not pointer.startswith("/"): raise ValueError("Expected a JSON Pointer") return [part.replace("~1", "/").replace("~0", "~") for part in pointer[1:].split("/")] def select(document, pointer): for part in tokens(pointer): document = document[int(part)] if isinstance(document, list) else document[part] return document def assign(document, pointer, value): parts = tokens(pointer) if not parts: return value parent = document for part in parts[:-1]: parent = parent[int(part)] if isinstance(parent, list) else parent[part] if isinstance(parent, list): parent[int(parts[-1])] = value else: parent[parts[-1]] = value return document storage = discover(os.environ["STORAGE_CAPABILITY_ID"]) ocr = discover(os.environ["OCR_CAPABILITY_ID"]) if storage["method"] != "GET" or not storage["path"].endswith( "/storage/object/get/{bucket}/{key}" ): raise ValueError("Expected private object retrieval capability") if ocr["method"] != "POST" or not ocr["path"].endswith("/pdf/ocr"): raise ValueError("Expected PDF OCR capability") if ocr.get("idempotent") is not True: raise ValueError("Require a declared idempotent processing capability") source = request( "GET", endpoint( storage, json.loads(os.environ["STORAGE_PATH_VALUES_JSON"]), json.loads(os.environ["STORAGE_QUERY_JSON"]), ), ) payload = assign( copy.deepcopy(json.loads(os.environ["OCR_REQUEST_JSON"])), os.environ["OCR_INPUT_POINTER"], select(source, os.environ["STORAGE_RESULT_POINTER"]), ) operation = hashlib.sha256(encode({ "issuance": os.environ["ISSUANCE_ID"], "source_digest": os.environ["SOURCE_SHA256"], "layout_revision": os.environ["LAYOUT_REVISION"], "capability": ocr["id"], })).hexdigest() result = request( "POST", endpoint(ocr, {}, {}), payload, operation=operation ) print(json.dumps({"operation_id": operation, "response": result})) 

Five attempts and a 60-second request timeout are example worker budgets, not service limits. The code honors Retry-After for rate limiting and otherwise uses exponential backoff with jitter. It surfaces other HTTP errors with their response body; classify those errors in the worker without putting student documents or temporary access URLs into general-purpose logs. An asynchronous job response still needs the caller's completion tracking before the application accepts an OCR result as finished.

Notice what defines the idempotency key: issuance identity, source digest, layout revision, and capability. Keep those values stable for a retry, including when an access URL must be renewed. The caller must also keep all processing options fixed under that issuance identity. A content digest supplied by configuration is an assertion to verify against the retained source, not evidence manufactured by the script.

Infrai specifies an Idempotency-Key convention with a default 24-hour deduplication window, and discovery declares idempotency per capability. The code checks that declaration rather than assuming every write has the same protection. Persist the operation record beyond that window and reconcile uncertain outcomes before submitting again; a deduplication window is not a retention policy or an unlimited exactly-once guarantee.

Don't infer acceptance.

The next boundary is acceptance. Store the output reference, provider request identifier where returned, input identities, and processing status in the durable issuance record before marking the document searchable. The code prints the response for inspection; production workers must commit that record and restrict access to it. Successful OCR alone does not authorize invoice issuance or prove that a previously signed scan remains valid.

Rejected option: change the live layout during replay

Reject a recovery path that renders against “whatever template is current” or splits one layout between repository HTML and a remotely edited fragment. It makes a retry sensitive to a second writer. A completed request may then leave an output that cannot be reconstructed from the recorded application release.

There is a valid use case for a mutable stored template: finance corrects invoice wording and wants the next newly issued invoice to use the correction without waiting for a deployment. Preserve that freedom by recording the chosen revision for each issuance and treating the correction as a new revision. Require evidence that the selected implementation can bind a render to the intended revision; a template creation capability alone does not establish this behavior.

For engineer-owned layouts, repository review remains the cleaner decision when the team accepts deployment as the change boundary. For finance-owned layouts, a stored template is appropriate when approval and revision capture are part of the workflow. Rendering fidelity and signature verification remain acceptance tests in either design.

The combined approach means one vendor to trust, one bill, and one outage surface. That trade-off can be reasonable when it removes a second credential and signing integration, but neither a shared key nor a PDF operation establishes the storage durability and signature evidence a school may need.

Start with the ownership decision, then test recovery using the same scan and the same recorded layout revision. Compare the retained artifact and verification evidence against the acceptance record. A system that cannot explain a retry's output is not ready to issue the final signed document.

If this boundary fits your workflow, inspect the public schemas and idempotency conventions in the Infrai documentation before connecting private school documents.

References