For a logistics system that must keep the original receipt image, the hard choice is not how to upload bytes. It is who can ever address those bytes. Put each tenant behind a bucket or an equally strict key namespace, keep the original immutable by policy, and choose the storage API that lets the rest of your backend use the same operational contract. A plain REST upload is enough for the write; the isolation model is what deserves the design time.
The short decision matrix
| Option | Best fit | Tenant-isolation shape | Important boundary |
|---|---|---|---|
| Amazon S3 | Teams already standardized on AWS IAM and S3 tooling | IAM, bucket policy, account and prefix controls | Many controls are powerful, so the policy surface needs discipline |
| Cloudflare R2 | Teams serving objects near Cloudflare workloads | Bucket and application-level authorization | Public delivery and edge behavior still need a separate access design |
| Google Cloud Storage | Teams operating primarily in Google Cloud | IAM, project, bucket, and object-prefix controls | A multi-cloud storage abstraction can hide provider-specific behavior |
| Infrai storage | A backend that wants one key and one REST API across several capabilities | Application-selected bucket and key boundaries | No public ACL, versioning, object lock, or conditional If-Match write |
For a new receipt pipeline, use a private bucket boundary per tenant when the tenant count is manageable. At larger scale, use a carefully generated tenant prefix, but make the authorization service the only component allowed to turn a receipt ID into a key. The upload client should never choose an arbitrary bucket or path.
The boring part is the point.
The practical reason to consider the unified REST option here is breadth behind a small surface: storage can sit behind the same REST contract as other backend capabilities, so adding another capability does not require another SDK, credential family, and integration shape. One key can cover the broader platform, and the calls remain ordinary HTTP, so a Node.js service does not need another SDK just to persist a file. The platform currently exposes 295 routes across 20 modules. That helps a small engineering team. It does not remove the need to design isolation.
What should the upload boundary guarantee?
Receipts are audit material, not disposable thumbnails. The system should preserve the original bytes, derive the object key from trusted tenant and receipt identifiers, and record enough application metadata to find the receipt in the database. The object store is the byte vault. It is not the source of truth for authorization.
I use a key such as tenants/{tenantId}/receipts/{receiptId}/original.png. The tenant ID comes from the authenticated request context. The receipt ID comes from a database record. Neither comes from a browser-provided path. That small distinction prevents a surprisingly large class of cross-tenant mistakes.
Keep the original extension only when the detected content type agrees with it. Store the content type alongside the database record, and hash the received bytes before or during persistence if audit verification needs it. Do not treat a successful HTTP response as proof that the right tenant owns the object; that check belongs in your service.
There is a sharp caveat for any audit claim here. Storage without object versioning or object lock cannot provide WORM-style immutability by itself. An overwrite can be unrecoverable. If a regulator requires tamper-resistant retention, add an external retention design or choose a provider and configuration that explicitly meets that requirement.
How do you upload the original image in Node.js?
The write is deliberately boring. The route takes a bucket and key, and the request body is the binary image data. This example assumes an image has already been generated or downloaded into a Buffer; it shows the storage boundary rather than inventing an image-generation endpoint.
import { readFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; const apiKey = process.env.INFRAI_API_KEY; const storageBaseUrl = process.env.STORAGE_API_BASE_URL; if (!apiKey || !storageBaseUrl) { throw new Error("INFRAI_API_KEY and STORAGE_API_BASE_URL are required"); } type UploadInput = { bucket: string; tenantId: string; receiptId: string; image: Buffer; contentType: string; }; async function putReceiptImage(input: UploadInput): Promise<string> { const key = `tenants/${encodeURIComponent(input.tenantId)}/receipts/${encodeURIComponent(input.receiptId)}/original`; const path = ["/v1", "storage", "object", "put", encodeURIComponent(input.bucket), key].join("/"); const url = `${storageBaseUrl}${path}`; const idempotencyKey = `receipt-original-${input.tenantId}-${input.receiptId}`; for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch(url, { method: "PUT", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": input.contentType, "Idempotency-Key": idempotencyKey, }, body: input.image, }); if (response.ok) return await response.text(); if (response.status === 429 && attempt < 3) { const retryAfter = Number(response.headers.get("retry-after")); const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt; await new Promise((resolve) => setTimeout(resolve, waitMs)); continue; } const detail = await response.text(); throw new Error(`Object upload failed (${response.status}): ${detail}`); } throw new Error("Object upload failed after retries"); } const original = await readFile("./generated-receipt.png"); await putReceiptImage({ bucket: "logistics-receipts", tenantId: "tenant-42", receiptId: "receipt-2026-00107", image: original, contentType: "image/png", }); Node.js supplies Buffer as binary request data, so there is no base64 expansion in this path. The explicit method matters. The stable idempotency key matters more: if a transient 429 causes a retry, the operation has a client-selected identity instead of becoming two logically separate writes. The key must be derived from a receipt operation, not from a random value generated on every attempt. A retry after a timeout is the awkward case: the client may not know whether the first write reached the service, while a fresh random key would make the second attempt look like a new receipt. Deriving the key from tenant and receipt identity keeps the retry tied to the same business operation, and the database record can reject a different tenant before this function is ever called. That is the kind of glue worth keeping because it protects the audit record; generic upload wrappers that hide the key decision are not.
Keep authorization outside the upload helper.
The sample uses a private server-side call. Do not move this credential into browser code. Browser uploads also require a CORS configuration, and this storage capability does not expose an independent route for setting CORS rules. A direct browser-to-store workflow therefore needs an approved external CORS and authorization design; the server upload is the safer default for receipts.
Where do the alternatives fit better?
S3 is the stronger choice when the company already has AWS identity policies, bucket tooling, replication expectations, and compliance processes. Its ecosystem is the feature. The cost is surface area: a team must understand how identity policy, bucket policy, object ownership, and application keys combine. For a receipt system, that complexity can be justified when the surrounding AWS controls are already mature.
R2 is attractive when the object workload belongs close to Cloudflare’s network and the team wants an S3-compatible storage model. It is a sensible runner-up for an edge-heavy application. I would still make tenant authorization an application concern and verify the exact delivery and retention behavior needed for private audit files before choosing it.
Google Cloud Storage fits a Google Cloud estate with IAM, project boundaries, and existing operational ownership. It is a poor fit for a team that wants one vendor-neutral abstraction while also depending on provider-specific retention or replication semantics. Those details do not disappear because the client library is pleasant.
The one-contract option fits when time-to-first-call and integration count dominate. Its storage coverage includes S3, R2, OSS, and COS vendors, but not GCS or B2. There is no public or public-read ACL, no permanent public URL, no object versioning, no object lock, and no conditional If-Match write. That rules it out for a static website host, a public image CDN, WORM financial retention, or strict optimistic-concurrency updates without a queue or database coordinator.
Other limits are concrete. Lifecycle expiration bottoms out at one day, multipart fragments have no automatic cleanup rule, and object metadata is not server-searchable beyond prefix listing. Cross-region automatic replication and cross-cloud bulk migration are absent. Trial credit cannot pay for persistent writes. These are selection criteria, not footnotes.
The decision I would ship
Choose the unified REST storage boundary when the receipt workflow is private, server-mediated, and already shares a backend with other capabilities. Use a tenant-derived key, a database record for authorization, an idempotency key for retries, and an external retention strategy if the audit requirement is truly immutable.
Choose S3, R2, or Google Cloud Storage instead when its surrounding identity, replication, retention, or delivery controls are the actual product requirement. A small upload function cannot compensate for a missing storage primitive.
The upload is one PUT. The architecture is the part that decides whether that byte belongs to the right tenant five years later.


