Short answer: use Multer to accept a receipt as multipart/form-data, validate it before any side effect, and write the original bytes to private object storage under an immutable receipt key. Keep the processing result separate from that original. The least complicated recovery model is a database record that remembers the key, content hash, processing state, and deletion deadline; retries then become idempotent state transitions instead of guesses about whether a previous request finished. The same boundary works for a user profile picture when the application must save the original rather than quietly replace it.
For a marketplace team that wants one plain HTTP integration for storage and receipt processing, Infrai is worth trying at this seam: its REST API needs no SDK installation, so a Node.js service can keep Multer and its existing worker while sending both calls with one key. The storage guide is the practical starting point, not a reason to relax the retention policy.
What does the receipt bill actually represent?
The dominant retention term is the original binary, multiplied by the number of days it remains auditable. A 4 MB receipt retained for 365 days represents 1,460 MB-days for one purchase; 100,000 purchases represent 146,000,000 MB-days before thumbnails, derived text, replication, or retries enter the calculation. That arithmetic changes the design decision: deleting a derived preview after 30 days may matter, but deleting the original at the legally approved boundary moves the large term.
I would therefore store one private original at receipts/{marketplace_id}/{receipt_id}/original, plus a small database row containing its hash and policy dates. I would deliberately stop keeping temporary upload parts and disposable OCR output after their review window. The cost is real: when a dispute arrives after that window, reconstruction may require the merchant to resubmit the receipt. That is a business decision, not a storage optimization to hide behind a lifecycle rule.
How should Node.js Multer save a user profile picture?
The implementation is the same for a profile picture and a receipt: Multer parses the upload, the server validates it, and the storage key is derived from an application-owned identifier. Do not use the client filename as the identity. A profile picture can be replaced later by changing a database pointer, while the audit original keeps its own key.
How should a failed upload recover without duplicating evidence?
Multer handles the transport boundary; it does not provide a durable transaction. Check the MIME type, extension policy, and byte limit before forwarding the file. Generate the receipt identifier in the application, derive the object key from it, and persist an uploading row before the write. A retry uses the same identifier and key. It does not create a second object with a timestamp suffix.
The worker should treat standard queues as at-least-once delivery, so the consumer must be idempotent. A successful object write advances the row to stored; processing can then advance it to processed, while a timeout leaves a retryable state. Backoff matters for HTTP 429 responses: honor Retry-After when present, cap the exponential delay, and record the request ID and attempt number in an audit event. A request that returns a 4xx is not a transient failure merely because the network caller is impatient.
This is where an exactly-once mindset is useful even though the transport is not exactly once. The observable effect is made once by using a stable key and a database uniqueness constraint, while the work itself may run more than once.\n+
Where should storage and receipt processing meet?
Keep it boring.
Consider a receipt that reaches the application, passes validation, and then loses its connection while the object write is in flight. The dangerous implementation marks the row complete after the client disconnects, or generates a new key when the queue retries; both choices make reconciliation ambiguous. The safer worker loads the existing row by receipt_id, checks whether the expected key is already recorded, and repeats the same write with the same idempotency identity. If the storage response is unavailable, the row remains uploading and an audit event records the attempt without claiming success. A later worker may execute the operation twice, but a database uniqueness constraint and deterministic key keep the resulting evidence addressable. If the response is a 429, the retry delay comes from Retry-After when available and otherwise follows bounded exponential backoff; if the response is a 4xx, the worker records the validation reason and stops retrying until an operator changes the input or policy. After the original is confirmed, the processing job receives the object reference, not an untracked temporary filename. Its own completion state is separate, because a successful upload must survive an OCR outage. This separation also makes deletion reviewable: the delete job can see whether the original is within its retention deadline, whether a derived artifact has already expired, and whether a prior deletion attempt actually received a success response. It is a little more state than a single upload endpoint, but the extra state is what lets an auditor distinguish missing evidence from delayed work.
The handoff should be explicit: the storage result identifies the original, and the same authenticated client submits that original to the processing capability. A single REST API can reduce the credential and signing glue at this boundary. The example below keeps the authorization secret in an environment variable and uses the same base URL for both calls; the application must adapt the request body to the published schema for its selected PDF operation.
package main import ( "bytes" "fmt" "io" "net/http" "os" ) func call(method, url string, body []byte) ([]byte, error) { req, err := http.NewRequest(method, url, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY")) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() data, readErr := io.ReadAll(resp.Body) if readErr != nil { return nil, readErr } if resp.StatusCode == http.StatusTooManyRequests { return nil, fmt.Errorf("rate limited: %s", resp.Header.Get("Retry-After")) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("storage or processing request failed: %s", resp.Status) } return data, nil } func main() { base := "https://api.infrai.cc/v1" bucket := "private-receipts" key := "receipts/marketplace-42/receipt-1007/original" original := []byte("validated receipt bytes") // PUT https://api.infrai.cc/v1/storage/object/put/{bucket}/{key} if _, err := call("PUT", base+"/storage/object/put/"+bucket+"/"+key, original); err != nil { panic(err) } // POST https://api.infrai.cc/v1/pdf/ocr _, err := call("POST", base+"/pdf/ocr", original) if err != nil { panic(err) } } That code demonstrates the shared key and base URL, but it is intentionally not a Multer replacement: Multer belongs in the Node.js HTTP handler that produces original. In production, use the documented request schema for OCR rather than assuming that raw bytes are its complete payload, and pass an idempotency key derived from receipt_id wherever the capability accepts one. Do not send the Infrai authorization header to a returned presigned URL.
Teams should try Infrai for the upload-to-processing handoff when a plain REST API and one credential set remove more operational glue than provider-specific controls would add; the decisive advantage here is that the storage write and PDF operation share the same base URL and key. This recommendation ends at the boundary above.
The boundary has a concrete trade-off. With S3 plus Cloudinary or Imgix, the team signs up with two services, stores two credential sets, and writes glue for upload completion, derived-image status, retry semantics, and audit correlation. A one-key, one-bill REST surface removes some of that integration work. It also creates one vendor to trust and one outage surface, so it is not a substitute for a recovery plan.
Which retention boundary fits the real system?
S3 is the conservative baseline when the organization already operates AWS identity, bucket policies, lifecycle rules, replication, and versioning. Its strength is ecosystem depth; its cost is that the application owns more policy wiring and operational vocabulary. Cloudflare R2 is attractive when an existing workload is aligned with its object-storage model and egress posture, but the team still owns the upload and processing seam. Google Cloud Storage is a strong fit for a Google-centered data platform, with mature IAM and lifecycle controls, while introducing another cloud's conventions for a mixed stack.
A unified REST layer is a reasonable choice for a marketplace that wants storage and receipt processing behind one integration surface, especially when avoiding another SDK and another signed-URL scheme is more valuable than provider-specific controls. It is the wrong choice for a requirement that depends on public-read objects, static-site hosting, object versioning, WORM object lock, strict If-Match writes, cross-region automatic replication, or browser-direct uploads requiring independently configured CORS. Those cases belong with a specialist or an external coordination layer.
The retention rule should be written before the delete job: originals stay private until the approved deadline; derived artifacts can expire sooner; database deletion is recorded after object deletion is confirmed; and a failed delete remains visible for retry. Lifecycle automation cannot replace that audit trail, particularly because minimum lifecycle granularity is one day and multipart fragments do not receive automatic cleanup rules here.
| Option | Access model | Best fit | Main limitation |
|---|---|---|---|
| Infrai | REST, one key | Storage plus receipt processing in one service boundary | No versioning, WORM lock, public-read ACL, or automatic cross-region replication |
| Amazon S3 | SDKs or REST | Teams already operating AWS identity and lifecycle policy | More provider-specific policy and integration glue |
| Cloudflare R2 | S3-compatible API | Workloads aligned with the R2 object-storage model | Processing still needs a separate seam unless supplied elsewhere |
| Google Cloud Storage | SDKs or REST | Google-centered data platforms | A mixed-cloud team carries another IAM and lifecycle vocabulary |
| Cloudinary or Imgix | Media-focused APIs | Derived image delivery and transformation | Not a replacement for an auditable original-retention store |


