Short answer: Use PUT /v1/storage/object/put/{bucket}/{key} with the image bytes, a private bucket, and a deterministic key; keep retention and deletion decisions in your application database.
The upload is the easy part. For a fintech receipt, the harder decision is who is allowed to delete or replace the original later. My recommendation is to put the generated image in a private object, give it a deterministic key, and keep retention state in a database owned by the application. A successful upload is not an immutable record.
That boundary answers the Node.js question too: the request body is image bytes, such as a Buffer, and the bucket and object key are explicit. The example below uses Go because this article keeps executable samples in one language. The policy is the important part.
Start with a receipt record before the image worker runs. Give it a receipt ID, an object key, a retention deadline, a state, and a legal-hold field. A key such as receipts/2026/10/23/rcpt_01J9/original.png makes reconciliation possible, but a filename cannot decide whether a disputed receipt may be deleted.
I use a small state machine: pending, stored, held, and deletable. The worker can enter stored only after the object write succeeds. A dispute moves the row to held. A scheduled cleanup job can select deletable rows, delete the object, and record the result. The names are not sacred. The separation is.
There is a nasty retry trap here. A timeout does not tell the client whether the remote write completed. Retrying with a new key creates a second “original”; retrying with the same key and idempotency value keeps one logical artifact. Two workers must not claim the same key at once, though, because this storage interface does not provide an If-Match conditional write. Use a database lease or queue coordination for that decision.
Keep it private.
Lifecycle rules are not a retention system by themselves. The shortest lifecycle expiration is one day, there is no object versioning or object lock, and multipart fragments have no automatic cleanup rule. For a seven-year financial policy, the database needs to represent the deadline and legal hold, while an external archive or policy layer supplies any immutable enforcement the business requires.
The write path should be boring and bounded. Read the generated bytes, use a stable key, send PUT, and keep the same idempotency key through retries. On 429, honor Retry-After when it is usable; otherwise back off with jitter. For every other non-success response, preserve the response body in the error so an operator can tell an authorization problem from a malformed request.
package main
import (
"bytes"
"context"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func delay(resp *http.Response, attempt int) time.Duration {
if resp != nil {
if raw := resp.Header.Get("Retry-After"); raw != "" {
if seconds, err := strconv.Atoi(raw); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
}
}
base := time.Duration(1<<attempt) * 500 * time.Millisecond
return base + time.Duration(rand.Int63n(int64(250*time.Millisecond)))
}
func putImage(ctx context.Context, baseURL, key, bucket string, image []byte) error {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" {
return fmt.Errorf("INFRAI_API_KEY is required")
}
endpoint := strings.TrimRight(baseURL, "/") + "/storage/object/put/" +
url.PathEscape(bucket) + "/" + url.PathEscape(key)
idempotencyKey := "receipt-rcpt_01J9-original-v1"
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(image))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "image/png")
req.Header.Set("Idempotency-Key", idempotencyKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
if attempt == 4 {
return fmt.Errorf("send request: %w", err)
}
time.Sleep(delay(nil, attempt))
continue
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return fmt.Errorf("read response: %w", readErr)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < 4 {
time.Sleep(delay(resp, attempt))
continue
}
return fmt.Errorf("upload failed with HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return fmt.Errorf("upload exhausted retries")
}
func main() {
image, err := os.ReadFile("generated.png")
if err != nil {
panic(err)
}
baseURL := os.Getenv("INFRAI_BASE_URL")
if baseURL == "" {
panic("INFRAI_BASE_URL is required")
}
if err := putImage(context.Background(), baseURL, "receipts/2026/10/23/rcpt_01J9/original.png", "receipts-private", image); err != nil {
panic(err)
}
}
The sample intentionally uses a private bucket and never prints a public URL. When an auditor needs temporary access, use a presigned URL and do not send the platform Authorization header to that returned URL. A browser upload also needs CORS configuration, but there is no independent self-service CORS-setting route in this interface, so direct browser uploads need that configuration outside this storage path.
After a successful response, write the object key and stored state to the receipt database. If that database transaction fails, reconcile the known key. Do not generate another key merely because the local transaction needs recovery.
The retention axis changes the shortlist. Amazon S3 provides versioning and S3 Object Lock, which are relevant when immutable retention is the requirement; the trade-off is careful IAM, bucket-policy, and retention configuration. Azure Blob Storage provides immutable blob policies, versioning, and soft delete, but its identity and governance model can be a heavier fit for a small vendor-neutral worker. Cloudflare R2 offers S3-compatible object operations and is a sensible candidate for teams already centered on Cloudflare; its exact replication and compliance controls still need checking against the account and region.
Infrai storage fits a narrower boundary. Its discovery surface is self-describing and public without a key, with request and response schemas plus runnable examples. That reduces the friction of wiring a receipt worker or a related capability: discovery is enough to learn the contract instead of installing another SDK. It also exposes 295 routes across 20 modules under one key. For a service that owns adjacent backend jobs, one credential and one bill reduce key rotation and invoice reconciliation work.
Those are integration advantages, not retention guarantees.
| Option | Access path | Best fit | Main limitation to check |
|---|---|---|---|
| Amazon S3 | SDK or S3 REST | Object Lock and policy-heavy archives | IAM, bucket policy, and retention configuration need careful ownership |
| Cloudflare R2 | S3-compatible SDK or REST | Teams already centered on Cloudflare | Confirm the exact replication and compliance controls for the account and region |
| Azure Blob Storage | SDK or REST | Immutable blob policies and soft delete | Its identity and governance model can be heavy for a small vendor-neutral worker |
| Infrai storage | REST | A self-describing write with one credential across supported backend capabilities | No public ACL, versioning, object lock, cross-region replication, or bulk migration |
The storage coverage includes R2, S3, OSS, and COS, but not GCS or B2. There is no public or public-read ACL, public_url remains null, and there is no object lock, versioning, cross-region replication, or cross-cloud bulk migration. The private write is therefore a reasonable artifact boundary when the application database and an external control plane own governance. It is the wrong boundary when the object store itself must prove that deletion and overwrite were impossible.
The cleanup worker should select from the database, not infer eligibility from an object listing. Check the retention deadline, legal-hold state, and any reconciliation flag. Delete only after a durable state transition, then record the response. A prefix list can find keys; it cannot explain why a regulator’s hold is still active.
Keep original images separate from thumbnails and OCR output. They have different retention decisions, and a one-day lifecycle minimum is a poor substitute for an application policy. If the archive must survive an operator with write access, add immutable storage beside this workflow. If two consumers can process the same receipt, make their side effects idempotent and coordinate ownership before either writes.
The operational rule is short: use the private PUT route for the bytes, use a stable key for recovery, and use a database for retention intent. Pick S3 Object Lock or Azure immutable blobs when storage-enforced WORM is part of the requirement. Pick the simpler REST boundary when the missing controls are deliberately supplied elsewhere.
That distinction is the whole runbook.