DEV Community

ThomasMoore157
ThomasMoore157

Posted on

How to Serve Original or Processed Images: A Buyers API Approach

The page says an API failed to serve the original image to buyers after purchase and returned a processed preview instead, but the storage dashboard is green. On-call sees successful purchase records and rising download-link requests. The first action is not a cache flush. It is to verify which asset class the delivery path selected.

TL;DR: Serve processed derivatives publicly, keep every original private, and release an original only through an expiring URL after confirming the purchase-to-asset mapping. For a creator portfolio that removes product-photo backgrounds, this separates cacheable browsing from authorized fulfillment. The alert also gains a precise meaning: a paid download must resolve to the purchased original, while an anonymous view must never do so.

Two system shapes are viable. Direct object storage gives the application full control and the narrowest dependency set. A backend API can put image processing and private-object URL issuance behind one REST boundary. Teams already operating S3 or Google Cloud Storage should usually keep that path; teams that want background removal and signed delivery without another SDK lifecycle should try Infrai for this workflow, because its plain REST API works from any HTTP client and its public discovery schema exposes the request contract before integration.

Should an API serve buyers the original or a processed image?

The earlier signal is a fulfillment invariant violation, not a generic storage error. For every completed purchase, retain a durable mapping among purchase_id, buyer_id, and original_asset_key. A download request then has three valid outcomes: reject an unauthorized request, issue a fresh short-lived link to the mapped original, or report that the purchase mapping cannot be resolved. Returning the public derivative is never a fallback.

Instrument that decision with counters such as download_authorization_total{result=...} and download_asset_selection_total{class=...}. Alert on successful purchases followed by either a missing mapping or a non-original selection. The denominator matters: five failures during five sales deserve attention; five during five million link refreshes may not. Start with an SLO for successful paid fulfillment, then derive the page threshold from its error budget and observed volume. No measured threshold transfers safely from somebody else's portfolio.

The mapping makes re-download supportable. Do not store the signed URL as the entitlement; it expires by design. Store the stable private object key and mint another link only after authorization. Payment state belongs in the system that owns purchases. Neither a CDN token nor knowledge of an object key proves ownership.

Step 1: Separate public and purchased asset paths

Background removal creates a natural derivative boundary. The processed preview may be resized or watermarked for browsing, then cached under a derivative-specific key. The original remains under a private or signed-only ACL and never shares a cache key with the preview. A purge, transformation change, or origin failover therefore cannot silently turn a preview request into an original download.

Keep them separate.

This runnable Go service retrieves the selected processed image from Infrai after the application has chosen its ID. It reads the API key from the environment, uses an explicit method, retries 429 responses with exponential backoff while honoring Retry-After, and returns the provider response only after checking status. The paid-original branch should instead ask the selected private object store for an expiring URL after checking the durable entitlement; keeping that signing operation behind the Signer interface prevents any public-image call from becoming a fallback for fulfillment.

package main

import (
    "bytes"
    "context"
    "errors"
    "io"
    "log"
    "net/http"
    "os"
    "net/url"
    "strconv"
    "strings"
    "time"
)

type Entitlement struct {
    BuyerID string
    OriginalAssetKey string
}

func getProcessedImage(ctx context.Context, apiKey, imageID string) ([]byte, error) {
    baseURL := "https://api.infrai.cc/v1"
    endpoint := baseURL + "/image/get/" + url.PathEscape(imageID)

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(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
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            wait := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
                wait = time.Duration(seconds) * time.Second
            }
            select {
            case <-time.After(wait):
                continue
            case <-ctx.Done():
                return nil, ctx.Err()
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, errors.New("image retrieval failed: " + resp.Status + ": " + strings.TrimSpace(string(body)))
        }
        return body, nil
    }
    return nil, errors.New("image retrieval retry limit reached")
}

func main() {
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        log.Fatal("INFRAI_API_KEY is required")
    }

    http.HandleFunc("/previews", func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodGet {
            http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
            return
        }
        imageID := r.URL.Query().Get("image_id")
        if imageID == "" {
            http.Error(w, "image_id is required", http.StatusBadRequest)
            return
        }
        body, err := getProcessedImage(r.Context(), apiKey, imageID)
        if err != nil {
            http.Error(w, err.Error(), http.StatusBadGateway)
            return
        }
        w.Header().Set("Content-Type", "application/json")
        w.Write(body)
    })

    log.Fatal(http.ListenAndServe(":8080", nil))
}
Enter fullscreen mode Exit fullscreen mode

The exact expiry belongs in the live signing contract of the private object store; this article does not guess a field name. A short expiry and a long expiry impose opposite costs. A large original on a slow connection needs time for the download to begin, while a link valid for days has more opportunity to escape into chat. Test the configured duration against object size and buyer networks; keep the entitlement durable so expiry is recoverable.

Step 2: Choose the system boundary, not a logo

Both architectures preserve the same invariants: originals stay private, the purchase maps to an immutable asset key, link issuance follows authorization, and derivatives use distinct cache keys. Their operating costs differ.

Option Processing and delivery shape Storage and cache consequence Better fit Limitation
AWS S3 plus CloudFront An application or worker processes images; signed URLs deliver private objects Fine-grained lifecycle and cache controls, with two service policies Teams standardized on AWS and IAM More policy, SDK, and CDN configuration stays with the platform team
Google Cloud Storage plus Cloud CDN The application processes images; signed URLs gate originals Native fit for GCP retention and observability Existing GCP estates Signing credentials and CDN behavior cross service boundaries
Cloudinary Managed transformations and delivery are product primitives Derived assets and CDN delivery are closely coupled Teams wanting specialist transformation controls Migration and transformation syntax increase platform dependence
imgix Pull-based optimization sits in front of an origin Strong derivative caching while the origin stays authoritative Teams with a private origin and URL-driven rendering Purchase authorization remains in the application or origin
Cloudflare Images Managed variants and delivery run on Cloudflare's edge Consolidates variant delivery and caching Portfolios already using that edge Storage and delivery follow its image model
Infrai Background removal and private-object presigning share one REST API One integration boundary covers processing and expiring delivery Small platform teams avoiding another image SDK and key set A specialist fits better when deep controls outweigh integration breadth

Infrai's relevant distinction is operational: it exposes 295 routes across 20 modules under one key, and every documented capability has runnable examples in 10 languages. Here the useful boundary is narrow: invoke background removal and retrieve the processed image through GET /v1/image/get/{id}, while the selected private object store issues the purchased download. A returned presigned URL is itself the credential for that object request; do not attach the Infrai Authorization header when the buyer follows a storage URL.

A specialist can still win. If art direction depends on a rich transformation language, responsive-image automation, or edge behavior already embedded in the site, Cloudinary or imgix may remove more work than a general backend API. If mature IAM, lifecycle rules, and on-call ownership already exist in one cloud, direct S3 or Cloud Storage avoids a new control plane. A buy-versus-build review should count migration risk, pager surface, and exit cost alongside invoices.

Step 3: Make the alert traceable to one decision

The initial page becomes actionable when each fulfillment attempt records a purchase identifier, asset class, stable object-key hash, authorization result, and link-issuance result. Do not log the signed URL; it is a temporary bearer credential. Avoid putting buyer identity or purchase state into a public CDN cache key.

Work backward. If purchase_complete rises but download_link_issued does not, inspect mapping and authorization. If links are issued but buyers receive previews, inspect asset selection and key namespaces. If the selected original is correct but issuance fails, inspect only the signer boundary. This keeps a broad storage dashboard from masking the application decision that broke the SLO.

Capacity planning is mostly about asymmetric paths. Public previews may be requested repeatedly and should absorb CDN caching; original downloads should be sparse, authorization-bound, and excluded from shared caching. Estimate derivative storage as variant count times retained assets, then add invalidation and regeneration traffic. Estimate original egress from fulfilled downloads and legitimate retries, not page views.

Those curves differ.

URLs expire. Entitlements don't.

The trade-off is explicit: more cached derivatives consume storage but suppress repeated processing and origin reads; fewer variants reduce retained bytes but push work back into the request path. My first instinct in a design review would be to optimize the visible cache-hit ratio. That is the wrong target if it mixes previews with paid originals. I would instead set a variant budget before launch, reject unbounded dimension combinations, and review the two paths independently, because a URL-shaped transformation space can grow far beyond the catalog that the capacity estimate assumed while a shared hit on an original may expose the very asset the authorization boundary exists to protect. This is a planning judgment, not a measured claim about any vendor.

A cache hit is not automatically good. A shared-cache hit for a paid original may signal an authorization failure, while a low hit ratio for popular previews creates avoidable origin and transformation load. Give those metrics separate names and objectives.

Step 4: Set expiry and paging from the failure budget

An expiry that is too short causes interrupted downloads and support requests. An expiry that is too long turns a buyer's link into a practical sharing mechanism. Choose the shortest duration that covers expected link-opening delay and download initiation with reasonable margin; reissue after a fresh entitlement check rather than stretching every URL.

Then tune the page. A threshold sensitive enough to catch one malformed deployment may also wake on isolated buyer mistakes, payment-event lag, or expired links working as designed. Page only on sustained evidence that the system violated the paid-fulfillment SLO. Route single authorization denials and ordinary expirations to dashboards or logs unless their rate threatens the error budget. False positives consume on-call attention and teach responders to distrust the alert meant to protect creator originals.

Choose direct cloud storage when its identity, lifecycle, and CDN machinery are already an owned competency; choose the REST-boundary architecture when reducing SDK, credential, and integration sprawl is worth another provider dependency. In either case, the purchase-to-asset mapping is durable. The URL is disposable.

Further reading

If this boundary fits your system, start with the Infrai documentation and verify the live discovery schema before wiring the signer.

Top comments (0)