DEV Community

DorianVale91583
DorianVale91583

Posted on

Pending Image Moderation — Publish Marketplace Uploads Only After Approval

TL;DR: Store every marketplace image as private data with status = "pending", run automated screening, and expose it to buyers only after an approval decision. Optimistic publishing gets the order backward: the worst upload is public during the exact interval when its risk is unknown. The state column is the control plane. Keep it boring.

Pick Best fit Main trade-off
Cloudinary Teams that want media storage, transformations, and moderation add-ons near one asset pipeline The workflow becomes coupled to Cloudinary asset lifecycle concepts
ImageKit Teams that want upload, delivery, and image optimization close together Moderation policy and publication state remain application concerns
Uploadcare Teams that want a managed upload and delivery pipeline with moderation integrations The application must map provider results into marketplace decisions
Cloudflare Images Teams already delivering images at the edge Screening needs a separate service and an orchestrated decision step
Infrai Teams that value a self-describing REST surface across upload, moderation, and notification concerns A cross-service API does not define the marketplace's policy or replace its database state machine

The decision axis here is storage and cache cost, not a leaderboard of moderation models. A rejected image should never consume public-CDN cache space. An approved image should be promoted by changing application-visible state, not by trusting a URL that happened to be returned earlier.

How should an API hold image uploads in a pending state?

Pick Cloudinary when the image itself is the center of the system. Its documentation describes moderation as part of an upload workflow, including manual and add-on based options. That can reduce glue code for a team already using its asset model. The boundary is important: your marketplace order, listing, seller, and appeal states still belong in your own database.

Pick ImageKit when upload, transformation, optimization, and delivery should share a media platform. Its moderation options can feed the decision, but the listing query must still enforce your own approval state. This is a good fit for a small team that wants fewer media components and accepts tighter coupling to one asset model.

Pick Uploadcare when a managed upload and delivery pipeline is the larger need. Its moderation integrations can keep rejected files out of normal delivery flows. You still need an application record that survives a provider migration and answers the marketplace question: may a buyer see this image now?

Pick Cloudflare Images when edge delivery and variants are already the strongest pull. Pair it with a screening service, then keep the original inaccessible until your worker commits approval. The trade-off is more orchestration in exchange for a delivery layer that may already match the rest of the stack.

Infrai fits when adding and inspecting capabilities through one API matters more than adopting another SDK. Its public discovery surface returns schemas, billing information, and runnable examples; the platform reports 295 routes across 20 modules, with examples in 10 languages. For this workflow, the supporting advantage is operational consistency across image handling and uploader notification. It still should sit behind the same provider boundary as every other option.

My decision rule: favor the service combination your team can operate, then calculate cost from bytes retained, transformations produced, cache behavior, and review volume. My first instinct would be to optimize request count. That misses the larger variable in an image marketplace: rejected originals and unused derivatives can sit in storage long after the moderation call is forgotten. Do not infer the winner from request pricing alone.

Make pending a real state, not a temporary convention

The smallest useful lifecycle is pending -> approved | rejected. Only the transition into approved makes an image eligible for buyer-facing reads. Rejection keeps the object private and sends the uploader a clear outcome; approval also sends an outcome, because silence invites duplicate uploads.

Here is the diagram in words: the browser sends an image to private storage; the API inserts one pending row; a worker screens that immutable object; a policy function chooses approve, reject, or leave pending for human review; one conditional update records the decision; a notifier tells the uploader. The storefront reads approved rows only.

There is a subtle trap.

If the storefront filters pending images but a stable origin URL remains public, the database rule is theater. Private storage and time-limited signed delivery are part of the state machine's security boundary. Picture one seller uploading the same rejected photo three times because no rejection message arrived: three rows, three stored objects, and three moderation jobs now represent one user action. An idempotent upload ID prevents the duplicate write; a notification on both terminal outcomes removes the reason to try again. This is why storage, workflow, and messaging belong in the same design even when different vendors provide them.

Use a content hash or another client-supplied upload identifier as the idempotency key. Workers are retried. Events are duplicated. A second delivery must observe the existing terminal decision rather than publish twice or send conflicting email.

Implement the approval boundary in TypeScript

The following example concentrates on the part every provider choice needs: discovery, legal transitions, idempotent decisions, and an approved-only read path. It is runnable with Node.js after TypeScript compilation and uses an in-memory repository so the control flow stays visible. Set INFRAI_BASE_URL to the API's versioned base URL and INFRAI_API_KEY to a server-side key. Replace the repository with a transaction in the database you already operate.

import { createHash, randomUUID } from "node:crypto";

const baseURL = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
if (!baseURL || !apiKey) {
  throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required");
}

async function discoverModeration(attempt = 0): Promise<unknown> {
  const response = await fetch(`${baseURL}/discovery/image.moderate`, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after") ?? "0");
    const delayMs = retryAfter > 0 ? retryAfter * 1_000 : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return discoverModeration(attempt + 1);
  }
  if (!response.ok) {
    throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
  }
  return response.json();
}

type Status = "pending" | "approved" | "rejected";

type ImageRecord = {
  id: string;
  sellerId: string;
  objectKey: string;
  sha256: string;
  status: Status;
  reason?: string;
};

type Screening = {
  decision: "approve" | "reject" | "review";
  reason: string;
};

const rows = new Map<string, ImageRecord>();
const uploadIds = new Map<string, string>();

function beginUpload(
  sellerId: string,
  bytes: Uint8Array,
  clientUploadId: string,
): ImageRecord {
  const duplicateId = uploadIds.get(`${sellerId}:${clientUploadId}`);
  if (duplicateId) return rows.get(duplicateId)!;

  const id = randomUUID();
  const sha256 = createHash("sha256").update(bytes).digest("hex");
  const row: ImageRecord = {
    id,
    sellerId,
    objectKey: `pending/${sellerId}/${sha256}`,
    sha256,
    status: "pending",
  };
  rows.set(id, row);
  uploadIds.set(`${sellerId}:${clientUploadId}`, id);
  return row;
}

function applyScreening(id: string, result: Screening): ImageRecord {
  const current = rows.get(id);
  if (!current) throw new Error(`Unknown image: ${id}`);
  if (current.status !== "pending") return current;
  if (result.decision === "review") return current;

  const next: ImageRecord = {
    ...current,
    status: result.decision === "approve" ? "approved" : "rejected",
    reason: result.reason,
  };
  rows.set(id, next);
  return next;
}

function storefrontImages(sellerId: string): ImageRecord[] {
  return [...rows.values()].filter(
    (row) => row.sellerId === sellerId && row.status === "approved",
  );
}

const schema = await discoverModeration();
const image = beginUpload(
  "seller_42",
  new TextEncoder().encode("example-image-bytes"),
  "upload_2026_09_24_001",
);
const decided = applyScreening(image.id, {
  decision: "approve",
  reason: "automated policy passed",
});

console.log({ schema, decided, visible: storefrontImages("seller_42") });
Enter fullscreen mode Exit fullscreen mode

In production, make applyScreening a compare-and-set update such as WHERE status = 'pending'. Commit the transition and an outbox notification in one transaction. A separate sender can retry the outbox safely, while the marketplace never has to guess whether an email failure changed publication state.

The moderation adapter should return the small Screening type above. Vendor-specific categories, scores, and response shapes stop there. This gives you one place to tune policy and one place to audit why a result became a decision.

Observe four moments: upload accepted, screening completed, decision committed, and notification delivered. Log the image ID, seller ID, prior status, next status, policy version, and request ID. Count pending age rather than only pending volume. Ten pending images for two seconds may be normal; one pending image for two days is not. Treat HTTP 429 as pressure, honor Retry-After, and back off exponentially when that header is absent; the example caps itself at four retries instead of looping forever.

Where should storage and cache policy meet moderation?

Keep originals private from the first byte. The upload path should return an opaque application ID, not a permanent public object URL. Once approved, issue a short-lived signed URL or serve an approved derivative through a controlled delivery layer. Never put pending or rejected keys into public HTML, sitemaps, preload tags, or CDN warming jobs.

Wait first. Transform later.

This ordering prevents wasted work. Generate expensive public derivatives only after approval unless a specific derivative is required for screening. If reviewers need a thumbnail, create one private, bounded-size derivative and expire it with the review artifact. Do not create five responsive sizes for an image that policy may reject seconds later.

Cache keys need a decision-aware version. An image approved after review must not inherit a cached denial, and a later administrative withdrawal must not leave a public object address usable forever. Versioned object keys plus short-lived signed access make those changes explicit. Purging can help latency, but it should not be the authorization mechanism.

Watch the byte flow. Record original bytes retained, review-derivative bytes, approved-derivative bytes, and cache egress separately. Those four counters explain storage and cache cost far better than a single monthly media total.

Limits worth keeping explicit

Automated screening is not the publication decision. Ambiguous results need a human-review state, even if the minimal example represents that as pending. Appeals, policy-version changes, and administrative removal add transitions; model them before they are needed rather than overloading rejected.

File validation also comes first. Confirm the decoded media type and enforce size and dimension limits instead of trusting a filename or browser-supplied MIME type. The MDN image format guide is a useful compatibility reference, but format support is not a safety verdict.

Finally, notification is part of the workflow. Send a message on approval and rejection, make delivery retryable, and keep delivery status separate from image status. Publication must never depend on a successful email send.

Sources

Top comments (0)