Processing an image at upload gives a marketplace an early screening opportunity, while rendering a short promo video only when requested avoids generating a derivative that nobody needs. TL;DR: store each upload as pending, screen what you can immediately, and require approval before publication or on-demand rendering. Keep generated videos behind their own publication decision; approving an input image cannot establish that a later prompt and its output are acceptable.
This is a publication decision before it is a media API decision. A seller uploading a product photo and requesting a promotional clip creates two related assets, and treating the second as automatically publishable merely moves the review gap.
How should an API hold image uploads pending approval?
At upload, record a pending state and start screening. At demand, admit rendering only for the approved image-and-prompt revision. Optimistic publishing puts the worst content in front of viewers while review is happening; making the processor faster doesn't repair that ordering.
Keep bytes private.
A pending column represents review state, but it isn't an object-store access policy. Keep originals and generated derivatives private or signed-only, issuing presigned URLs only after authorization. Reviewers need a separate authorized path to inspect pending material. Check approval before issuing a signed URL, not merely on the page displaying it; otherwise the database can describe a private asset while an older delivery path still exposes its bytes. This is the first storage failure mode to test.
For a Node.js backend, the important boundary is the database transaction changing eligibility. The reference implementation uses Python 3.11 and SQLite to expose that boundary without selecting a JavaScript SDK or assuming undocumented provider payloads. It models application state, not object transfer. Python 3.11 is the example's runtime target, not a provider requirement.
Step 1: Bind approval to immutable inputs
Use one record for an immutable image-and-prompt revision. Here, the marketplace promotion pairs a ceramic mug image with a prompt for a short launch clip. Changing either input creates a fresh pending record. Otherwise a reviewer can approve one image while a renderer consumes another, and an audit log containing only the listing identifier will hide the mismatch.
Run this complete Python example. The first portion creates the record; the two functions implement the next steps. The assertions test denial before approval, acceptance afterward, duplicate requests, and rejection. Identifiers and copy are illustrative application data, not provider fields.
import sqlite3 db = sqlite3.connect(":memory:") db.executescript(""" CREATE TABLE assets ( id TEXT PRIMARY KEY, image_revision TEXT NOT NULL, prompt TEXT NOT NULL, state TEXT NOT NULL CHECK (state IN ('pending', 'approved', 'rejected')) ); CREATE TABLE notices ( asset_id TEXT PRIMARY KEY, outcome TEXT NOT NULL, delivered INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE renders ( asset_id TEXT PRIMARY KEY, state TEXT NOT NULL CHECK (state IN ('pending', 'approved', 'rejected')) ); """) asset_id = "mug-image-r1-prompt-r1" with db: db.execute( "INSERT INTO assets VALUES (?, ?, ?, 'pending')", (asset_id, "mug-image-r1", "Make a short ceramic mug launch clip"), ) def decide(key, outcome): if outcome not in {"approved", "rejected"}: raise ValueError("Expected a terminal review decision") with db: changed = db.execute( "UPDATE assets SET state = ? WHERE id = ? AND state = 'pending'", (outcome, key), ).rowcount if changed: db.execute( "INSERT INTO notices (asset_id, outcome) VALUES (?, ?)", (key, outcome), ) else: row = db.execute( "SELECT state FROM assets WHERE id = ?", (key,) ).fetchone() if row is None or row[0] != outcome: raise ValueError("Missing asset or conflicting decision") def request_render(key): with db: db.execute( """INSERT OR IGNORE INTO renders (asset_id, state) SELECT id, 'pending' FROM assets WHERE id = ? AND state = 'approved'""", (key,), ) return db.execute( "SELECT state FROM renders WHERE asset_id = ?", (key,) ).fetchone() assert request_render(asset_id) is None decide(asset_id, "approved") decide(asset_id, "approved") assert request_render(asset_id) == ("pending",) assert request_render(asset_id) == ("pending",) assert db.execute("SELECT COUNT(*) FROM notices").fetchone()[0] == 1 assert db.execute("SELECT COUNT(*) FROM renders").fetchone()[0] == 1 rejected_id = "mug-image-r2-prompt-r1" with db: db.execute( "INSERT INTO assets VALUES (?, ?, ?, 'pending')", (rejected_id, "mug-image-r2", "Make a short ceramic mug launch clip"), ) decide(rejected_id, "rejected") assert request_render(rejected_id) is None assert db.execute("SELECT COUNT(*) FROM notices").fetchone()[0] == 2 print("Approval gate and duplicate-request checks passed") The primary key deliberately allows one render request per immutable input pair. Multiple creative variants need separate identities and decisions; removing uniqueness would let repeated delivery create repeated work. That's a product choice with a storage consequence. The example has 3 review states, 2 notification outcomes, and 1 render intent per input pair; these are design constraints, not performance measurements.
Step 2: Commit the decision and notification intent together
The decide function accepts a decision only while an asset is pending, then inserts notification intent in the same transaction. Repeating the same outcome is harmless. A conflicting outcome raises an error because this example uses terminal decisions; a production system supporting revocation needs an explicit transition and corresponding byte-access policy.
Notify on both outcomes.
Otherwise a seller cannot distinguish rejection from a lost upload and may submit the image again. The sample records intent for approval and rejection, but inserting a row doesn't send an email. A worker must deliver outstanding notices, track progress, and tolerate retries. The unique notice key prevents duplicate local intents; external delivery still needs its own deduplication strategy. Don't confuse a committed outbox row with a confirmed recipient notification.
Automated screening supplies evidence for the decision. It doesn't automatically constitute publication approval. An image format check establishes a technical property, not acceptable content; the MDN guide helps with the former, while marketplace policy and reviewers must define the latter.
Step 3: Admit rendering atomically and review its output
The request_render function selects only an approved asset when inserting render intent. Its uniqueness constraint makes repeated local requests converge. The acceptance criterion is concrete: zero render records before approval, one afterward, and still one after a repeated request.
Pending is intentional.
A worker can consume this durable intent, but claiming work and recovering interrupted provider submissions require more implementation. Local uniqueness cannot guarantee exactly-once execution by an external generator. Bind remote submissions to the same immutable input identity and use the selected provider's documented idempotency contract where available, rather than assuming every HTTP operation has one. The example also leaves output decisions pending: rendering completion must not become publication permission.
That extra review boundary costs work. It prevents an approved mug photo from becoming blanket authorization for every promotional claim introduced by a subsequent prompt.
The upload-versus-demand decision follows from that boundary. Screen the original at upload, when the seller is waiting for acceptance, and defer video generation until an approved revision is actually requested. Processing at upload is reasonable if every accepted promotion needs a clip immediately; on-demand processing is preferable if the marketplace may never use that revision. Neither scheduling choice changes the publication rule. A completed render still needs its own decision.
Compare integrations after defining the gate
Amazon S3, Cloudinary, ImageKit, and Uploadcare address different parts of storage and media handling. Compare the boundary you need, not a claim that an upload API supplies your whole approval workflow. Their documentation is the starting point for validating exact access and processing configuration.
| Option | Integration focus | Appropriate scope and limitation |
|---|---|---|
| Amazon S3 | Object storage and presigned access | Suitable when private originals are the starting point; application code still owns decisions, render admission, and notifications. |
| Cloudinary | Image upload and media management | Consider when media handling is central; validate moderation and delivery configuration rather than equating upload completion with approval. |
| ImageKit | Image upload and delivery | Consider for the upload-to-delivery boundary; verify private-file access and retain a separate generated-output decision. |
| Uploadcare | File uploading and handling | Consider when upload integration is the immediate requirement; don't assume that file acceptance authorizes marketplace publication. |
| Discovery-first backend API, discussed below | A self-describing REST API with public, keyless discovery | Request and response schemas plus runnable examples in 10 languages let an integration begin by reading one endpoint rather than learning a new SDK. Check each selected operation's contract before connecting it to the review state machine. |
Infrai offers a single key and a single bill through one REST API, with no SDK required. Public, keyless discovery supplies request and response schemas and runnable examples in 10 languages; integration starts by reading an endpoint rather than learning a new SDK. The single credential and consolidated billing cover 295 routes across 20 modules, so the marketplace can connect upload and outcome notification without managing separate API keys or reconciling separate invoices. That is useful breadth for a workflow spanning media and email, but shared credentials and billing don't make distinct service operations atomic. Prefer this option when discovery and credential consolidation reduce integration work; prefer a dedicated storage or media service when that narrower boundary is the actual requirement. None of these choices removes the application transaction.
The relevant comparison is where state lives. A media service can accept an object, a reviewer can approve an input, and a generator can finish a clip at different times; combining those milestones into one Boolean hides the exact condition the marketplace must enforce. Keep the review decision explicit even if one provider handles several operations.
For the discovery-first option, run this read-only inspection before choosing upload or video-generation parameters. Set INFRAI_BASE_URL to the documented HTTPS API base including its v1 prefix, and set INFRAI_API_KEY to your credential. Discovery requires no key; authentication here demonstrates the environment-based pattern used for subsequent authenticated requests. The returned manifest is integration data, not a publication decision. Select operations by their discovery path and inspect their schemas and examples before submitting media.
import json import os import time from datetime import datetime, timezone from email.utils import parsedate_to_datetime from urllib.error import HTTPError from urllib.request import Request, urlopen base = os.environ["INFRAI_BASE_URL"].rstrip("/") if not base.startswith("https://"): raise ValueError("Use the documented HTTPS API base URL") request = Request( base + "/discovery", headers={"Authorization": "Bearer " + os.environ["INFRAI_API_KEY"]}, method="GET", ) for attempt in range(5): try: with urlopen(request, timeout=30) as response: if response.status != 200: raise RuntimeError(f"Unexpected status: {response.status}") manifest = json.load(response) print(json.dumps(manifest, indent=2)) break except HTTPError as error: detail = error.read().decode("utf-8", errors="replace") if error.code != 429 or attempt == 4: raise RuntimeError(f"HTTP {error.code}: {detail}") from error retry_after = error.headers.get("Retry-After") delay = float(2 ** attempt) if retry_after: try: delay = max(delay, float(retry_after)) except ValueError: deadline = parsedate_to_datetime(retry_after) delay = max( delay, (deadline - datetime.now(timezone.utc)).total_seconds(), ) time.sleep(delay) The 5-attempt limit and 30-second timeout are example client policies, not service limits. This GET creates no asset, so retries cannot duplicate a render. Write requests need their own documented idempotency contract; the application primary key alone cannot deduplicate a remote submission.
Roll out the gate before changing processors
Start with the database state and the authorization check that controls presigned URL issuance. Then connect upload screening, decision notifications, and render admission, in that order. Carry the example's denial and duplicate-request assertions into the backend implementation. Treat a changed image or prompt as a fresh pending revision, and verify that both approval and rejection create notification intent.
The in-memory example loses its records when the process exits; use persistent application storage before connecting real workers. It demonstrates a transaction boundary, not a durability claim. During rollout, check that pending originals and pending clips cannot receive viewer download authorization, and that a worker retry cannot silently create another local render intent. Only then choose whether accepted promotions should render immediately or wait for demand.
Sources
References for the storage, media, and format boundaries discussed above:
- MDN, Image file type and format guide: https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types
- Amazon S3, Download and upload objects with presigned URLs: https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- Cloudinary, Image upload API: https://cloudinary.com/documentation/image_upload_api_reference
- ImageKit, Upload file API: https://imagekit.io/docs/api-reference/upload-file/upload-file
- Uploadcare, Uploading files: https://uploadcare.com/docs/uploading-files/
- Python 3.11, SQLite interface: https://docs.python.org/3.11/library/sqlite3.html
![Robotics Engineering Standards — HRS-013: Quadruped [EN]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F42u7msle9fmc8ba0f0p8.png)