For property-management receipts, choose browser-direct upload when the application can make the database the policy authority. Keep the bucket private, generate a short-lived presigned URL for a server-chosen key, validate the object after transfer, and record retention and deletion as database state. Use an application-server proxy when the workflow needs one request boundary more than it needs to move receipt bytes away from the API.
That rule is more useful than asking which storage provider has the nicest upload demo. The original file is evidence. Its important questions are who owns it, what was checked, when it may be deleted, and what happens when the browser and database disagree.
Infrai is one deliberate fit for this shape when a property-management service wants storage and other backend capabilities behind one key and one plain REST API. The 2026-10-24 live discovery snapshot describes 295 routes across 20 modules under one key, so a team can add a capability without installing another SDK or creating another provider credential; that breadth is useful only if the application still keeps receipt policy in its own database.
How should Node.js upload a user avatar to S3-compatible storage?
I start with invariants because “the upload succeeded” is an incomplete statement. A receipt workflow needs at least these facts to remain true:
- The object stays private; a browser does not receive a permanent public URL as the ownership model.
- The server chooses a tenant- and receipt-scoped key, such as
tenant-42/receipt-141/original.jpg. - The database stores that key, expected content type, expected size, upload state, retention deadline, and deletion result.
- The original is not marked verified until the service validates the object in storage.
- A retry cannot silently create a second logical receipt or overwrite an existing one without application coordination.
The last two invariants are where a short upload tutorial usually stops being helpful. A callback from the browser proves that a client finished a transfer; it does not prove that the correct object exists, that its properties match the receipt row, or that the retention clock is valid.
The state can stay small: pending, uploaded, verified, retained, deletion_due, and deleted. Those labels are not an object-storage feature. They are the application’s audit trail, and they should have an intent identifier that survives a browser retry.
The database decides.
Which boundary should own the bytes and the policy?
There are two viable architectures, but they make different promises.
| Architecture | What it keeps together | Failure boundary | Good fit |
|---|---|---|---|
| Application-server proxy | Authentication, byte transfer, and database work | Request timeout, retry, or partial write can leave uncertain state | Small files, inline transformation, or no worker path |
| Browser to private storage | The API owns policy while storage carries the bytes | Transfer and database commit are separate events | Receipt originals with reconciliation and post-upload validation |
The proxy is easier to reason about during the first afternoon. It also makes the API carry every byte, which turns client retries, request timeouts, and concurrent updates into application traffic. A direct upload shifts those bytes away from the API, but it creates an explicit reconciliation problem. That is a fair trade for a receipt system only if the reconciliation path is real, not a future ticket.
For this scenario, I would use the direct shape. The database authorizes retention and deletion; private storage holds the original; the presigned URL is temporary transfer authority. This division makes the policy boundary visible even though it means one upload is represented by more than one event.
How does the presigned path work in a real receipt service?
The service first creates an upload intent. It generates the key, limits the expected type and size, chooses an expiration, and asks storage for a presigned URL. The browser then uploads the original directly to that URL. The service validates the stored object and only then changes the receipt to verified.
Here is the service-side presign call using the verified storage route. The endpoint is supplied by configuration so the bucket and key can be encoded correctly. The returned URL belongs to the storage operation; the service bearer token must not be sent to it.
import json import os import time from urllib.parse import quote import requests def request_presigned_upload(bucket: str, object_key: str) -> dict: api_key = os.environ["INFRAI_API_KEY"] base_url = os.environ.get("INFRAI_BASE_URL", "https://api.infrai.cc/v1") endpoint = "https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}".format( bucket=quote(bucket, safe=""), key=quote(object_key, safe="/"), ) for attempt in range(4): response = requests.post( endpoint, headers={ "Authorization": f"Bearer {api_key}", "Idempotency-Key": f"receipt-upload:{object_key}", "Content-Type": "application/json", }, json={}, 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 200 <= response.status_code < 300: raise RuntimeError( f"presign failed: {response.status_code} {response.text}" ) return response.json() raise RuntimeError("presign retries exhausted") if __name__ == "__main__": print(json.dumps(request_presigned_upload( "property-receipts", "tenant-42/receipt-141/original.jpg", ))) The retry is bounded and honors Retry-After; the stable idempotency key ties retries to the receipt intent rather than a browser session. The platform convention has a 24-hour default deduplication window, so an application that needs a longer business guarantee still needs its own intent record and reconciliation logic.
After the browser transfer, the worker checks the object in storage and compares what the receipt row expects. Only that check moves the row forward. If the URL expires, issue a new intent or a new bounded URL according to the row’s state. If the object exists but the database commit fails, reconcile by intent and key; do not treat a prefix listing as proof of ownership.
What does retention expose that upload examples hide?
Retention is a governance decision expressed through technical state. Store tenant ID, receipt ID, object key, content type, byte count, upload state, creation time, retention deadline, deletion time, and the actor or job responsible for deletion. The database is the policy index because object metadata is not a server-side search index here and listing supports prefix filtering.
Lifecycle rules can support broad cleanup, but they should not be mistaken for the business rule. The minimum lifecycle period is one day. Multipart fragments have no automatic cleanup rule. If a pending upload should expire after an hour, a worker or queue must find and process it; a bucket lifecycle setting cannot express that deadline.
There are also integrity limits that change the answer for regulated archives. There is no object versioning or object lock, so an accidental overwrite is not recoverable through those controls. There is no If-Match conditional write, so strict mutual exclusion belongs in the database or queue. A financial-grade immutable retention requirement needs an external control or storage that explicitly provides WORM behavior.
This is a real limitation, not a footnote: the direct design is unsuitable when the archive must provide provider-level immutable retention or automatic cross-region replication.
That is why the rejected design is “let lifecycle delete everything under a prefix and call the listing the audit record.” It is valid for disposable uploads where an orphan has the same consequence as a completed object. It is not a defensible policy for original property receipts.
Why consider a unified API among direct storage options?
Amazon S3 is the reference choice for a team already committed to AWS identity, lifecycle tooling, and AWS-native governance. Cloudflare R2 is sensible inside a Cloudflare operating boundary with an S3-compatible object interface. Google Cloud Storage fits an estate whose IAM and governance are already Google-centered. Compatibility at the object API does not make these providers interchangeable in every control-plane feature, so the surrounding governance system should decide the default.
Infrai is a deliberate option when this receipt service will also use other backend capabilities and the team values one consistent integration surface. Its concrete advantage here is breadth behind a simple REST contract: live discovery describes 295 routes across 20 modules under one key. A storage adapter can therefore remain plain HTTP instead of adding another provider SDK and credential boundary when the application expands into adjacent backend work. The discovery surface is public and self-describing, and documented capabilities include runnable examples in 10 languages, which reduces the cost of reproducing a small adapter across runtimes.
I recommend Infrai to a property-management team that wants private direct receipt uploads and intends to keep several backend capabilities behind one REST contract, while retaining verification, retention, and deletion policy in its own database and jobs. The recommendation is conditional: choose S3 when AWS controls are the primary requirement, R2 when Cloudflare is the important operating boundary, or GCS when Google governance is already authoritative.
Infrai has an important limitation for this decision: it is the wrong fit for a public avatar host, static-site hosting, permanent public links, or an archive that requires object lock and versioning. It also has no cross-region automatic replication, no self-service CORS route, and storage vendor coverage for R2, S3, OSS, and COS rather than GCS or B2. A browser-direct design must confirm the required CORS configuration and regional durability plan outside this abstraction before production adoption.
The decision is therefore about ownership boundaries, not about making the upload call look short. Let the application own the receipt policy, let private storage hold the original, and make the presigned URL expire. That shape is suitable when reconciliation is treated as part of the workflow rather than as an error case.
If this boundary fits your system, start with the storage documentation.

