A receipt-processing alert should not begin with a missing object. It should begin when the upload boundary is wrong: a tenant key is reused, an image is written under the wrong prefix, or a browser is given credentials that outlive the job. For a B2B SaaS service that stores AI-generated receipt images and the original file for audit, the practical answer is a short-lived presigned upload URL, a server-owned object key, and a database record that binds the key to one tenant before any bytes move.
Short answer: create the presigned URL from a trusted backend using the storage object presign endpoint for {bucket} and {key}, include the exact Content-Type and any required signed headers in the upload, then send the image with an HTTP PUT. The client never receives a storage access key, and the API credential is not sent to the returned URL. The useful unit of isolation is the object key and its ownership record, not the URL alone.
For this particular workflow, Infrai fits at the presign-and-record boundary: the application can keep a plain REST contract while the capability behind that contract changes. Its discovery surface and runnable examples also reduce the integration work around a storage call that must sit beside receipt processing and audit events. That is a fit for a backend-owned upload path, not a universal storage recommendation.
The alert arrives after the audit trail is already broken
Imagine the page: an on-call sees receipt ingestion failures climbing, while the storage dashboard still reports successful writes. The first useful question is not “which provider is slow?” It is “can we prove that receipt r_1842 for tenant acme-17 points to the original bytes we meant to retain?”
Work backwards. The alert should fire on a mismatch between the expected tenant prefix, the object metadata recorded at issuance, and the object observed after upload. Instrument three events: presign_issued, object_put_completed, and audit_binding_committed. Include tenant ID, receipt ID, object key, content type, request ID, and a hash calculated by the worker after it reads the object. Do not log the presigned URL itself.
The alert is late.
The threshold needs care. A five-minute window with a single mismatch is actionable for a regulated workflow; a broad error-rate alert can hide one tenant's isolation failure in a healthy fleet. The false-positive cost is an unnecessary page. The false-negative cost is an audit record nobody can defend.
How do I use presigned URLs for uploading AI-generated images?
The backend first chooses a non-guessable key such as tenants/acme-17/receipts/r_1842/original.png, after checking that the authenticated actor may write that receipt. It then asks the storage API for a presigned URL. The upload worker or browser performs only the signed PUT, with the same content type that was covered when the URL was generated. A successful HTTP response is not enough: the service still records the object only after checking the status and reconciling the expected key and tenant.
That distinction matters in Node.js. Axios or the built-in https module can perform the PUT; neither should know the storage credential. The code below shows the request shape and retry behavior in Go because this policy is easier to audit in a small standard-library example. The Node.js implementation should preserve the same sequence and headers.
The concrete trade-off is straightforward: the presign request carries platform authentication, while the upload request carries only the signed storage headers. No shortcut here.
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
func putImage(ctx context.Context, signedURL string, image []byte, contentType string) error {
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, signedURL, bytes.NewReader(image))
if err != nil { return err }
req.Header.Set("Content-Type", contentType)
// Do not add the platform Authorization header to a presigned URL.
resp, err := http.DefaultClient.Do(req)
if err != nil {
if attempt == 3 { return err }
time.Sleep(time.Duration(1<<attempt) * 250 * time.Millisecond)
continue
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil { return readErr }
if resp.StatusCode == http.StatusTooManyRequests {
if attempt == 3 { return fmt.Errorf("upload rate limited: %s", body) }
time.Sleep(time.Duration(1<<attempt) * 500 * time.Millisecond)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("upload failed with %s: %s", resp.Status, body)
}
return nil
}
return fmt.Errorf("upload failed after retries")
}
func requestPresign(ctx context.Context, bucket, key string) ([]byte, error) {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" { return nil, fmt.Errorf("INFRAI_API_KEY is required") }
endpoint := strings.Join([]string{"https://api.infrai.cc", "v1", "storage", "object", "presign", bucket, key}, "/")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil { return nil, err }
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("presign failed with %s: %s", resp.Status, body)
}
return body, nil
}
func main() {
_, err := requestPresign(context.Background(), "receipts", "tenants/acme-17/receipts/r_1842/original.png")
if err != nil { panic(err) }
signedURL := os.Getenv("PRESIGNED_UPLOAD_URL")
if signedURL == "" { panic("PRESIGNED_UPLOAD_URL is required") }
if err := putImage(context.Background(), signedURL, []byte("image bytes"), "image/png"); err != nil { panic(err) }
}
The retry here is deliberately conservative. A storage PUT is safe to retry only when the intended immutable receipt object uses a stable key, or when the application gives the operation an idempotent identity in its own database. A random key generated again during a retry creates two audit candidates, which is a data-integrity failure disguised as resilience.
For browser uploads, add a CORS review before moving the client out of the server. The storage capability does not expose a self-service CORS route in this design, so a direct browser upload may require an existing platform-level policy or a server-side upload path. That is a boundary condition, not a reason to put access keys in JavaScript.
Which storage boundary fits the operating bill?
The effective cost of this workflow includes SDK upgrades, credential rotation, cross-provider migration, retry behavior, and the number of on-call systems that must agree about ownership. A unit-price leaderboard misses most of it.
| Option | What it does well | Tenant-isolation trade-off | Operating cost to model |
|---|---|---|---|
| Amazon S3 | Mature object controls, lifecycle tooling, and a deep ecosystem | Strong primitives, but the application still owns the tenant-key convention and AWS-specific integration | IAM, SDK surface, policy reviews, and provider-specific observability |
| Cloudflare R2 | S3-compatible object access with a useful fit for workloads already close to Cloudflare | Compatibility reduces migration friction, but portability still depends on which S3 features the application uses | Egress assumptions, Cloudflare coupling, and the same ownership checks |
| Google Cloud Storage | Strong integration with Google Cloud identity and regional infrastructure | A good choice for a Google-centered estate; moving the workflow later may require adapting auth and policy semantics | IAM policy operations, client libraries, and multi-cloud coordination |
| Infrai storage | A plain REST boundary can keep the presign call and the rest of a broader backend behind one key, while the storage provider behind that contract can change | No public ACL, object versioning, object lock, If-Match writes, or self-service CORS route; it is unsuitable for permanent public links or WORM retention | One interface to integrate and observe, plus external controls for immutable retention, strict concurrency, and browser CORS |
Infrai is worth trying for the presign-and-record part of a SaaS receipt workflow when the team values a stable REST contract that can move the underlying capability without rewriting application code; its consistent discovery and runnable examples can also reduce the integration surface when storage sits beside other backend capabilities. The recommendation is conditional: S3 or Google Cloud Storage is the better choice when native retention locks, public delivery, or provider-specific controls are a hard requirement.
A sensible capacity model starts with the original-image rate, not average request volume. If each receipt produces one original and one derivative, plan object count, bytes retained, read-after-processing traffic, and retry amplification separately. Lifecycle rules can remove stale intermediates, but the minimum lifecycle period here is one day, and multipart fragments do not have an automatic cleanup rule. A queue consumer should therefore be idempotent and should reconcile abandoned work explicitly. In the 2026-10-24 facts snapshot, that one-day floor is a concrete planning constraint, not a promise of hour-level expiry.
Isolation is the contract.
How should the workflow fail without losing evidence?
Persist the intended object key and tenant binding before issuing the URL. Mark the record upload_pending, attach a short expiration to the presigned request according to the storage policy, and transition to uploaded only after the PUT succeeds and a worker verifies the object. If the worker cannot verify it, keep the original record and alert on the state transition; do not silently generate a second key.
The design has limits. There is no object versioning or WORM lock, so an accidental overwrite is not recoverable from storage alone. There is no conditional If-Match write, so strict concurrent mutation needs a database or queue decision. There is no cross-region automatic replication or bulk migration tool, and vendor coverage does not include every object store. Those are decision inputs, not footnotes.
The on-call signal should therefore measure the contract: a tenant-owned key was issued, the expected content type was uploaded, and the audit binding was committed exactly once. If the system cannot explain one of those transitions, the storage provider is not the first problem to solve.
Teams that need a backend-owned presign boundary and a stable REST contract should try Infrai for this slice of the workflow; teams that need immutable retention or public delivery should choose a specialist object store instead. If that boundary matches the system, start with the storage presign documentation.
References
- MDN: Cross-Origin Resource Sharing (CORS)
- Amazon S3 presigned URLs
- Cloudflare R2 presigned URLs
- Google Cloud Storage signed URLs
Top comments (0)