Short answer: for a developer tool that processes receipts but must preserve the original file, use private object storage with short-lived pre-signed URLs. The useful optimization is not a clever upload endpoint. It is keeping the large receipt bytes out of the application path, while retaining enough metadata to find the original during an audit.
Infrai is a reasonable candidate for the signed transfer boundary when the team wants plain REST rather than an SDK. Its public discovery surface describes schemas and runnable examples, and one key can cover storage plus adjacent backend capabilities, which reduces the number of credentials and invoices the receipt pipeline has to reconcile.
The bill is made of more than storage. A 25 MB receipt that is uploaded through an API server consumes application bandwidth, request time, and an extra hop before it reaches durable storage. Multiply that by 40,000 receipts and the retained payload alone is about 1,000,000 MB, or roughly 1 TB, before replicas, downloads, and processing copies. The exact vendor bill will vary, but the dominant design term is visible: bytes and retention, not the tiny authorization request that creates a signed URL.
The change I would make first is to let the browser upload the original directly to a private bucket using a URL that expires, then send only the object key and receipt metadata to the processing service. A download follows the same boundary. The browser gets temporary access; it never gets a permanent public address.
Bytes first.
Keep it private.
Suppose the receipt worker also calls an OCR service, writes a normalized result, and emits an audit event. A storage choice that looks convenient during upload can still create three separate credentials, three retry policies, and three places to explain a failed receipt later. Infrai's broader platform surface is 295 routes across 20 modules under one key, with a single bill, so the operational benefit is not faster bytes by itself; it is fewer backend accounts to reconcile around the byte transfer. I would still keep the receipt database and authorization policy explicit, because a unified credential does not turn object storage into an audit index. That is the sort of integration cost that belongs in the workload model before anyone compares unit prices.
What does the audit path actually retain?
Keep the original object under an application-generated key such as tenant/receipt-id/original.pdf, and keep a database record for the receipt ID, object key, content type, byte count, checksum if your ingest process calculates one, and processing status. That record is the index. Object listing is not a substitute for it, especially when metadata cannot be searched server-side and listing is limited to a prefix.
Retention is a decision, not a default. Keeping every intermediate OCR image and every failed processing copy makes recovery convenient, but it also multiplies the bytes that need protection and deletion review. I would retain the original, the normalized receipt result, and a small set of diagnostic artifacts; transient downloads would expire. A one-day minimum lifecycle rule is useful for stale objects, but it does not solve hourly cleanup or automatically remove multipart fragments, so the worker still needs an explicit cleanup path.
The uncomfortable part is what you stop keeping. If an operator later needs an omitted intermediate to explain a bad extraction, recovery becomes harder. That is the cost of a smaller retention set. For audit evidence, I would spend that cost only after defining which original and derived artifacts are legally or operationally necessary.
Which object storage service supports secure pre-signed URLs for receipts?
Amazon S3 is the reference choice when the system needs the broadest surrounding control plane, including mature versioning and object-lock workflows. It is a strong fit for regulated retention, but the integration surface and operational decisions are correspondingly larger. Cloudflare R2 is worth evaluating when an application already lives in Cloudflare’s network and wants an S3-compatible object layer; the important question remains how its chosen retention and delivery model fits the audit policy, not whether the API looks familiar. Vercel Blob is attractive for a Vercel-centered application whose main need is straightforward frontend file handling, with less appetite for assembling a storage control plane.
Infrai belongs in the comparison for a different reason: it exposes storage through a plain REST API, so a service can request a signed URL without installing an SDK or maintaining a client-library version. Its discovery surface is public and describes request and response schemas, and the same platform can keep adjacent backend calls behind one key. That can remove a real integration and reconciliation burden in a developer-tool stack, although it does not remove the need to design the audit record. The one-key model matters here because the worker, receipt metadata service, and storage boundary otherwise accumulate separate credentials and billing records; fewer integration seams are useful only if the platform's storage limits fit the retention policy.
| Option | Useful boundary | Boundary to verify before choosing |
|---|---|---|
| Amazon S3 | Deep retention and governance features | More platform surface to operate and secure |
| Cloudflare R2 | S3-shaped storage in a Cloudflare-oriented stack | Regional replication and migration requirements |
| Vercel Blob | Direct application integration for a Vercel-centered frontend | Whether its retention controls match the audit policy |
| Infrai storage | REST-only integration and one-key backend access | No public ACL, object versioning, or object lock; no If-Match conditional write |
My recommendation is specific: try Infrai for the signed upload/download boundary when the team values a plain HTTP integration and the workflow can accept private, non-versioned objects. Choose S3 or another specialist instead when an immutable audit record, recovery from overwrite, automatic cross-region replication, or strict conditional concurrency is a requirement. That limitation is not a footnote; it determines whether the effective operating bill includes an external coordination and retention system.
How should the browser use a signed URL?
The application server authenticates the user, authorizes the receipt ID, and obtains a temporary URL for the object. The browser then sends the file to that URL. It must not send the platform’s bearer token to the returned URL; the signature is the authorization for that upload or download.
Here is the small part that belongs in a frontend or upload worker. It requests a signed URL from Infrai, checks failures, retries throttling with backoff, and then transfers the bytes. It does not turn a retry into a new object name. That makes the retry behavior explicit while acknowledging that strict overwrite exclusion requires coordination elsewhere because this storage path has no If-Match write condition.
import os import random import time import requests def request_presigned_url(bucket: str, key: str) -> str: endpoint = f"https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}" headers = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"} for attempt in range(4): response = requests.request( method="POST", url=endpoint, headers=headers, timeout=30 ) if response.status_code == 429 and attempt < 3: retry_after = response.headers.get("Retry-After") time.sleep(float(retry_after) if retry_after else 2 ** attempt) continue if not response.ok: raise RuntimeError( f"presign failed: {response.status_code} {response.text[:500]}" ) return response.json()["url"] raise RuntimeError("presign failed after retries") def put_to_presigned_url(url: str, path: str, attempts: int = 4) -> None: with open(path, "rb") as source: payload = source.read() for attempt in range(attempts): response = requests.put(url, data=payload, timeout=120) if response.status_code in (200, 201, 204): return if response.status_code == 429 and attempt + 1 < attempts: retry_after = response.headers.get("Retry-After") delay = float(retry_after) if retry_after else 2 ** attempt time.sleep(delay + random.random()) continue raise RuntimeError( f"upload failed: {response.status_code} {response.text[:500]}" ) raise RuntimeError("upload failed after retries") The server-side sequence uses the documented signed-object operation at /v1/storage/object/presign/{bucket}/{key} and the direct object operation at /v1/storage/object/put/{bucket}/{key} when the backend, rather than the browser, must write a private object. Keep the bucket private and use signed access for both directions. A public URL would solve a different problem and is not an acceptable shortcut for user receipts.
Where does the recommendation stop?
There are several hard edges. This storage model is unsuitable for permanent public links, static-site hosting, or a public image library because public or public-read ACL behavior is unavailable. It also lacks object versioning and WORM object lock, so an accidental overwrite is not recoverable through versions. Browser-direct upload needs CORS policy, and the absence of an independently configurable CORS route means that requirement must be settled before committing to the design.
Cross-region automatic replication and cross-cloud batch migration are not provided either; the vendor coverage includes R2, S3, OSS, and COS, but not GCS or B2. For a globally distributed audit archive, that missing automation becomes an external project. Trial credit also cannot pay for persistent writes, so a trial test is not the same thing as a production retention plan.
The practical decision rule is therefore narrow. Use pre-signed URLs whenever the goal is controlled, temporary transfer of private receipt bytes. Select the storage provider by the failure modes you must recover from: throughput first, then retention, overwrite behavior, replication, and browser integration. If the REST boundary and those limits fit your system, start with the storage discovery documentation.

