How to Upload an AI-Generated Image from Node.js to Object Storage: Retention Rules A developer outlined a pattern for uploading AI-generated images to object storage from application code, using a PUT request with a deterministic object key, a private bucket, and an idempotency key held constant across retries. The writeup argues that retention and deletion decisions belong in an application-owned database state machine (pending, stored, held, deletable) rather than in storage lifecycle rules, since the storage interface offers no object versioning, object lock, or conditional writes and caps lifecycle expiration at one day. 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<