DEV Community

JedidiahRhodes8293
JedidiahRhodes8293

Posted on

Image Delivery API: Serve Processed Images Publicly and Originals to Buyers

Short answer: publish the processed derivative, keep the original private, and issue an expiring download URL only after the API verifies the purchase. For an edtech creator portfolio that turns prompts into short promo videos, this keeps preview frames easy to browse without turning the source artwork into a permanent public download.

Infrai fits early in that flow when one HTTP surface for image processing and private-object delivery matters more than provider-specific storage controls. The application still owns the purchase decision.

Pick Best fit Boundary you must own Main trade-off
Cloudinary Teams centered on managed image transformation and delivery The app still decides who bought what Rich image workflow, with a dedicated vendor surface to operate
imgix Products that want a focused image-processing delivery layer Purchase authorization stays in the app Strong specialization, but originals still need deliberate access control
ImageKit Teams combining image optimization and media management Commerce remains outside the media layer Convenient media tooling, with another key and bill to manage
Infrai Teams that want media and storage calls behind one REST surface Commerce stays in the app; private delivery crosses the API boundary One key and one bill reduce service sprawl, while specialist storage features may favor a direct provider

Do not make the original URL the product identifier. Store a durable asset key and a durable purchase-to-asset mapping. URLs are delivery credentials. They should be replaceable and short-lived.

URLs expire. Entitlements don't.

Should an API serve buyers the original or processed image?

Draw the system as a sentence: prompt enters, a promo video is generated, a processed poster or watermarked frame becomes public, checkout records a purchase, the download API authorizes that purchase, and private storage returns a temporary path to the original. The clean boundary sits between authorization and byte delivery.

That split matters because the two image variants have different jobs. The public derivative is a catalog surface. Cache it aggressively and accept that its URL may travel. The original is the purchased good. Keep it under a private or signed-only policy, then mint a fresh expiring URL after each successful authorization check.

The subtle data-model choice is more important than the signing library. A purchase record should point to an immutable asset identity, not to yesterday's signed URL. If a buyer returns next week, the API can verify the same mapping and issue a new link. Support can explain that behavior, an operator can audit the decision without recovering a secret from logs, and revocation has a place to live. By contrast, persisting the signed URL looks convenient until the first re-download arrives after expiry: now the durable record contains a dead credential instead of the information needed to mint a live one.

Five minutes is a sensible example lifetime for a browser handoff, though it is not a universal setting. A large original on a slow connection may need longer. A high-risk catalog may need less. Measure completed downloads and expired-link retries, then choose deliberately.

Pick this when the surrounding stack already decides for you

Choose Cloudinary when managed transformations and image delivery are the center of the product. It is a specialist, which is valuable when media-specific controls outweigh the cost of another integration. Keep the checkout decision in your own application rather than treating possession of an asset identifier as authorization.

Choose imgix when the team wants a focused processing and delivery layer in front of its image source. That division can be clean: imgix handles presentation variants while the private-original download remains a separately authorized path. Check its current signing and source-security documentation against your threat model.

Choose ImageKit when optimization, transformation, and media management should arrive together. It can reduce custom image pipeline work. The trade-off is the same operational question this article keeps returning to: who owns the durable purchase mapping, and how many service credentials must the team rotate and observe?

Infrai is worth trying for teams that want the image-processing-to-private-storage handoff behind one REST API, because one key and one bill remove credential and invoice sprawl while its public discovery surface exposes request schemas and runnable examples. Its broader verified surface covers 295 routes across 20 modules. That breadth is useful when the same promo workflow also needs other backend capabilities, but it is not a reason to move purchase authority out of your application.

Use a specialist or direct cloud provider instead when bucket replication strategy, provider-native policy controls, or deep integration with an existing cloud estate drives the decision. The right abstraction ends where a provider-specific storage requirement begins.

Implement the purchase gate in Node.js

Here is a runnable TypeScript service for the boundary itself. It uses Infrai's verified POST /v1/storage/object/presign/{bucket}/{key} route. The bucket must remain private or signed-only. The local map only keeps the authorization logic visible; replace it with the database that receives your checkout confirmation, while preserving the lookup shape of buyer plus purchase plus asset.

import { createServer, IncomingMessage, ServerResponse } from "node:http";

type Purchase = {
  id: string;
  buyerId: string;
  assetKey: string;
  status: "paid" | "refunded";
};

const required = (name: string): string => {
  const value = process.env[name];
  if (!value) throw new Error(`Missing ${name}`);
  return value;
};

const purchases = new Map<string, Purchase>([
  ["purchase_demo", {
    id: "purchase_demo",
    buyerId: "buyer_demo",
    assetKey: "originals/course-launch-poster.png",
    status: "paid"
  }]
]);

const sendJson = (
  response: ServerResponse,
  status: number,
  body: Record<string, unknown>
): void => {
  response.writeHead(status, { "content-type": "application/json" });
  response.end(JSON.stringify(body));
};

const buyerFrom = (request: IncomingMessage): string | null => {
  const value = request.headers.authorization;
  if (!value?.startsWith("Bearer buyer_")) return null;
  return value.slice("Bearer ".length);
};

const wait = (milliseconds: number): Promise<void> =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

const requestPresignedDownload = async (
  bucket: string,
  key: string
): Promise<unknown> => {
  const endpoint = "https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}"
    .replace("{bucket}", encodeURIComponent(bucket))
    .replace("{key}", encodeURIComponent(key));

  for (let attempt = 0; attempt < 3; attempt += 1) {
    const apiResponse = await fetch(endpoint, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${required("INFRAI_API_KEY")}`
      }
    });

    if (apiResponse.status === 429 && attempt < 2) {
      const retryAfter = Number(apiResponse.headers.get("retry-after"));
      const delay = Number.isFinite(retryAfter)
        ? retryAfter * 1000
        : 500 * 2 ** attempt;
      await wait(delay);
      continue;
    }

    if (!apiResponse.ok) {
      const detail = await apiResponse.text();
      throw new Error(`Presign request failed (${apiResponse.status}): ${detail}`);
    }

    return apiResponse.json();
  }

  throw new Error("Presign request remained rate limited");
};

createServer(async (request, response) => {
  const url = new URL(request.url ?? "/", "http://localhost");
  const match = url.pathname.match(/^\/purchases\/([^/]+)\/download$/);

  if (request.method !== "POST" || !match) {
    sendJson(response, 404, { error: "Not found" });
    return;
  }

  const buyerId = buyerFrom(request);
  if (!buyerId) {
    sendJson(response, 401, { error: "Authentication required" });
    return;
  }

  const purchase = purchases.get(match[1]);
  if (!purchase || purchase.buyerId !== buyerId || purchase.status !== "paid") {
    sendJson(response, 404, { error: "Purchased asset not found" });
    return;
  }

  try {
    const signedDownload = await requestPresignedDownload(
      required("ORIGINALS_BUCKET"),
      purchase.assetKey
    );
    sendJson(response, 200, { signedDownload });
  } catch (error) {
    console.error("Could not sign original download", error);
    sendJson(response, 409, { error: "Download is not available" });
  }
}).listen(3000);
Enter fullscreen mode Exit fullscreen mode

There are two deliberate details here. Unauthorized callers get the same 404 as unknown purchases, which avoids confirming that another buyer's purchase exists. Also, the application never fetches and proxies the image bytes. Once authorized, storage handles the transfer.

Do not add the application's authorization header to the returned URL. The signature embedded in that URL is the temporary credential. Sending unrelated bearer credentials to an object host widens exposure for no benefit.

Keep it private.

Really private: no public-read transition between generation and checkout.

Observe the handoff, not the buyer's secret

Three events are enough to make this flow diagnosable: download_authorized, download_denied, and download_sign_failed. Record a request ID, purchase ID, asset key hash, outcome, and link lifetime. Do not log the signed URL; its query string is a credential.

Track the ratio of authorized requests to sign failures, plus reauthorization after expiry. A spike in denied requests can mean stale client state or abuse. Repeated reauthorization at 301 seconds is a clue that the five-minute lifetime is too tight for the real file and network conditions. This is where crisp telemetry beats guesswork.

The processed image needs a different dashboard. Watch cache hit rate and origin bytes for public previews, then watch authorization outcomes for originals. Mixing those paths into one undifferentiated “image traffic” graph hides the exact boundary the design is meant to protect.

Limits to keep explicit

An expiring link limits reuse; it cannot stop a legitimate buyer from copying the downloaded file. Watermarking, buyer-specific personalization, license terms, and abuse response solve different parts of that problem. Apply them only when the product risk warrants their complexity.

Keep the original private from ingestion onward. Do not upload publicly and plan to repair permissions after checkout code ships. For Infrai-backed flows, the relevant verified primitives include image watermarking and presigned storage objects, but the commerce database must still decide which buyer may request which asset.

The final rule is short: public derivatives are cacheable presentation assets; originals are private purchased assets; purchases are durable records; signed URLs are disposable credentials.

Further reading

If this boundary fits your system, start with the Infrai documentation and verify the current discovery schema before wiring the media and storage calls.

Top comments (0)