Product Shot
Product Shot turns a plain product photo into a catalogue or lifestyle image. FOTOhub AI cuts the product out, builds the background (a clean studio colour, or a generated scene), places the product with a believable shadow and composes the result. The product's own pixels are kept: labels, logos and text on the product stay legible, because the product is composited from your original photo, not redrawn. Every result passes an automatic quality check before you are charged for it.
Capabilities
- Presets: studio white (marketplace-compliant main image), grey, black, five lifestyle scenes, four seasonal scenes, or your own scene prompt
- Standard and Premium scene tiers; Premium lets you pick the scene model
- Outputs at 1:1 (2000x2000), 4:5 (1600x2000) and 16:9 (2560x1440) from one request
- Batches for whole catalogues, with per-image charge and refund, retry of failed images, and a zip of the results
- Price shown first:
quoteanswers the per-image and total price before you commit - Failed means not charged: an image that fails is refunded, individually
Availability
Product Shot is rolled out in stages. Until it is enabled for your account, every route answers 403 with {"error": "feature_disabled", "feature": "product_shot"}. Nothing is charged.
Endpoints
| Method | Path | Charged | Description |
|---|---|---|---|
| GET | /v1/aiwave/product-shot/config | no | Presets, aspects, marketplaces, premium scene models, your saved presets, the consent text (residency), limits |
| POST | /v1/aiwave/product-shot/quote | no | Price per image and in total, availability, ETA |
| POST | /v1/aiwave/product-shot/jobs | yes | One job per requested aspect |
| GET | /v1/aiwave/product-shot/jobs | no | Your recent jobs (filter by request_id) |
| GET | /v1/aiwave/product-shot/jobs/{job_id} | no | Status, stage, result or error, refund state |
| POST | /v1/aiwave/product-shot/jobs/{job_id}/cancel | no | Cancel; refunded once |
| GET | /v1/aiwave/product-shot/presets | no | Your saved presets |
| POST | /v1/aiwave/product-shot/presets | no | Save a preset |
| DELETE | /v1/aiwave/product-shot/presets/{preset_id} | no | Delete a saved preset |
| POST | /v1/aiwave/product-shot/batches/quote | no | Price, ceilings and fit of a batch |
| POST | /v1/aiwave/product-shot/batches | per image | Create a batch |
| GET | /v1/aiwave/product-shot/batches | no | Your recent batches and ceilings |
| GET | /v1/aiwave/product-shot/batches/{batch_id} | no | Status, counts, progress, spend, pause, ETA |
| GET | /v1/aiwave/product-shot/batches/{batch_id}/items | no | Per-image status, result, charge and refund |
| POST | /v1/aiwave/product-shot/batches/{batch_id}/cancel | no | Stop: waiting images never charged, running ones refunded |
| POST | /v1/aiwave/product-shot/batches/{batch_id}/retry-failed | per image | Queue the failed images again |
| POST | /v1/aiwave/product-shot/batches/{batch_id}/resume | per image | Resume a paused batch |
| POST / GET | /v1/aiwave/product-shot/batches/{batch_id}/archive | no | Build / fetch the results zip |
Base URL: https://apis.fotohub.appAuthentication: Authorization: Bearer fh_live_your_api_key (see Authentication)
Pricing
Prices are not hard-coded in this page. They come from the API:
quotereturnsper_image(credits and USD, plus thesceneleg for a generated scene) andtotalfor the number of aspects you asked for, for your account.- The USD wallet rates are listed by
GET /v1/pricing. The price keys areaiw-productshot-std(a Standard image) andaiw-productshot-premium-fee(the composite fee that Premium adds to the scene model's own catalogue price):GET /v1/pricing?model=aiw-productshot-std. While the feature is disabled,GET /v1/pricing?model=aiw-*answers404for its keys. - A Standard image is priced by the scene backend that will actually run, which the quote names. If an option cannot be sold at a sustainable price, the quote answers
available: falsewith areasoninstead.
Processing location and consent (read this first)
Two steps of Product Shot run in the United States. Background removal runs on FOTOhub AI servers (Google Cloud, us-central1), and the image review (a quality check) sends a reduced-size (768 px) preview of each finished image to an AI vision model hosted in the United States. Your product photo is sent there for processing only, and the finished images are stored in the EU (FOTOhub storage, Poland). Generated backgrounds are created by FOTOhub AI image models, which may also run outside the EU (in the EU, Switzerland or the United States, or at a partner provider).
Because of that, creating jobs requires an explicit consent flag. GET /config returns it as residency:
{
"consent_version": "2026-10-05",
"cutout": {"region": "US", "location": "Google Cloud us-central1 (United States)"},
"review": {"region": "US", "location": "AI vision model hosted in the United States"},
"storage": {"region": "EU", "location": "Azure Poland Central"},
"text": "Two steps of Product Shot run in the United States ..."
}Show text to whoever decides, then send consent_us_processing: true and consent_version (the value from the config) with POST /jobs and POST /batches. Without it the API answers 422 consent_required and charges nothing. If you resell Product Shot, pass the notice on to your users and say in your privacy policy that cut-outs and image reviews are processed in the United States. The consent version changed to 2026-10-05 when the image review was added to the notice; send the current value from GET /config, a stale one answers 422 consent_required.
| Step | Where it runs |
|---|---|
| Background removal (cut-out) | United States (Google Cloud us-central1) |
| Image review (768 px preview) | United States (AI vision model hosted in the United States) |
| Background generation | FOTOhub AI image models (EU, Switzerland or United States) or a partner provider |
| Storage of photos and results | EU (FOTOhub storage, Poland) |
Create images
from fotohub import FotoHub
client = FotoHub() # reads FOTOHUB_API_KEY
cfg = client.product_shot.config()
consent = cfg["residency"]["consent_version"]
quote = client.product_shot.quote(
"photos/<your-user-id>/shoe.jpg", preset_id="studio_white", aspects=["1:1", "4:5"]
)
print(quote["total"], quote["available"])
created = client.product_shot.create(
"photos/<your-user-id>/shoe.jpg",
preset_id="studio_white",
aspects=["1:1", "4:5"],
consent_us_processing=True,
consent_version=consent,
)
for j in created["jobs"]:
done = client.product_shot.wait_for_job(j["job_id"])
print(j["aspect"], done["result"]["outputs"]["delivery"]["url"])import { FotoHub } from "fotohub";
const client = new FotoHub({ apiKey: process.env.FOTOHUB_API_KEY! });
const cfg = (await client.productShot.config()) as any;
const created = (await client.productShot.create({
imagePath: "photos/<your-user-id>/shoe.jpg",
presetId: "studio_white",
aspects: ["1:1", "4:5"],
consentUsProcessing: true,
consentVersion: cfg.residency.consent_version,
})) as any;
for (const j of created.jobs) {
const done = await client.productShot.waitForJob(j.job_id);
console.log(j.aspect, (done.result as any).outputs.delivery.url);
}curl https://apis.fotohub.app/v1/aiwave/product-shot/jobs \
-H "Authorization: Bearer $FOTOHUB_API_KEY" -H "Content-Type: application/json" \
-d '{
"image_path": "photos/<your-user-id>/shoe.jpg",
"preset_id": "studio_white",
"aspects": ["1:1", "4:5"],
"consent_us_processing": true,
"consent_version": "2026-10-05",
"request_id": "6f1d3c52-8a0e-4b49-9c1f-0a2b3c4d5e6f"
}'| Field | Type | Description |
|---|---|---|
image_path | string | Required. Your product photo in your own storage: photos/<your user id>/.... Anything else is a 422 |
preset_id | string | A built-in id (studio_white, studio_grey, studio_black, lifestyle_marble, lifestyle_wood, lifestyle_concrete, lifestyle_bathroom, lifestyle_nature, seasonal_christmas, seasonal_summer, seasonal_autumn, seasonal_spring) or a saved user:<uuid> preset. config().presets is the authoritative list |
prompt | string | A custom scene instead of a preset: 3-400 characters |
params | object | Overrides: fill (0.3-0.95), anchor (center, bottom), ground_y (0.5-0.97), shadow_mode (none, contact, drop), shadow_opacity (0-1), shadow_direction (-80 to 80), harmonize (bool), background (#RRGGBB) |
aspects | string[] | Subset of 1:1, 4:5, 16:9; up to 3; default ["1:1"]. One job, and one charge, per aspect |
tier | "standard" | "premium" | Default standard. Premium applies to generated scenes only |
scene_model | string | Premium only: one of config().premium_models |
seed | integer | Reproducible scene |
consent_us_processing, consent_version | bool, string | Required, see above |
request_id | UUID | Idempotency key: the same request_id returns the jobs it already created (replayed: true) and never bills twice. The SDKs generate one |
The request is all or nothing: if the second job cannot be charged, the first is cancelled and refunded.
Answer: {"request_id", "jobs": [{"job_id", "aspect", "status", "replayed"}], "quote": {"per_image", "images", "total"}, "eta", "residency"}.
Result
A succeeded job's result is:
{
"outputs": {
"master": {"url": "...", "path": "photos/...", "width": 2000, "height": 2000, "bytes": 0, "mime": "image/png"},
"delivery": {"url": "...", "path": "photos/...", "width": 2000, "height": 2000, "bytes": 0, "mime": "image/jpeg"},
"preview": {"url": "...", "path": "photos/...", "width": 0, "height": 0, "bytes": 0, "mime": "image/jpeg"}
},
"aspect": "1:1", "preset_id": "studio_white", "tier": "standard",
"qa": {"passed": true, "reasons": [], "warnings": []},
"compliance": {"...": "marketplace main-image checks, for presets that have a marketplace"},
"metrics": {"ssim_product": 0.99},
"residency": {"cutout": "US", "storage": "EU"}
}studio_white produces a marketplace main image (pure white background, the product filling about 85% of the frame, 2000 px, sRGB). compliance reports the checks. The values above are illustrative; read the real ones from the response.
Job states and refunds
status is queued, running, succeeded, failed or cancelled. A failed job has error: {"code", "message"} (cutout_unavailable, scene_unavailable, scene_refused, composite_failed, qa_failed, ...) and is refunded exactly once; refunded and refund_message tell you it happened. POST .../cancel refunds a queued or running job.
Presets
curl https://apis.fotohub.app/v1/aiwave/product-shot/presets \
-H "Authorization: Bearer $FOTOHUB_API_KEY" -H "Content-Type: application/json" \
-d '{"name": "Oak table, golden hour", "prompt": "rustic oak table, warm golden-hour light", "kind": "lifestyle"}'Saved presets are free and appear in config().user_presets; use the returned id as preset_id.
Batches
A batch processes a whole catalogue: up to the ceiling in batches/quote (and GET /batches → ceilings) per batch. Each image is charged when it starts and refunded on its own if it fails.
items = [
{"image_path": "photos/<your-user-id>/a.jpg", "sku": "SKU-1"},
{"image_url": "https://shop.example.com/b.jpg", "sku": "SKU-2"}, # https only
]
q = client.product_shot.batches.quote(len(items), preset_id="studio_white")
print(q)
batch = client.product_shot.batches.create(
items, name="Autumn range", preset_id="studio_white",
consent_us_processing=True, consent_version=consent,
)
final = client.product_shot.batches.wait(batch["batch_id"], on_progress=print)
if final["counts"].get("failed"):
client.product_shot.batches.retry_failed(batch["batch_id"])const batch = (await client.productShot.batches.create({
items: [
{ image_path: "photos/<your-user-id>/a.jpg", sku: "SKU-1" },
{ image_url: "https://shop.example.com/b.jpg", sku: "SKU-2" },
],
name: "Autumn range",
presetId: "studio_white",
consentUsProcessing: true,
consentVersion: cfg.residency.consent_version,
})) as any;
const final = await client.productShot.batches.wait(batch.batch_id);- Quote first.
POST /batches/quotewith{"skus": N, ...settings}returns the per-image and total price, your ceilings (items per batch, concurrent batches, a daily credit ceiling) andfits(trueorfalse) with therefusalsthat would stop it (too_many_items,active_batch_limit,daily_cap,unavailable). - Items.
image_path(your storage) orimage_url(https://only), plus optionalsku,external_idandtitle. - Status.
active,paused,completed,completed_with_errors,failed,cancelled. Apausedbatch carriespause.reason(insufficient_funds,daily_cap,feature_disabled,price_changed,not_offered) and a message; fix the cause andPOST .../resume(send{"accept_price": true}forprice_changed). Adaily_cappause continues by itself after midnight UTC. - Failures.
GET .../items?status=failedlists them with their error;POST .../retry-failedqueues them again. - Cancel. Waiting images are never charged; images in flight are refunded.
- Download. When the batch has finished,
POST .../archivebuilds a zip andGET .../archivereturns its parts (409 archive_not_readywhile it is being built). - Idempotency.
request_idagain returns the same batch (replayed: true).
Errors
| Status | error | Meaning |
|---|---|---|
| 401 | authentication_required | No or invalid key |
| 402 | insufficient_funds | Wallet too low (required_usd, balance_usd, shortfall_usd, topup_url) |
| 403 | feature_disabled | Not enabled for your account yet |
| 404 | job_not_found, batch_not_found, preset_not_found | Not yours or gone |
| 409 | not_cancellable, archive_not_ready, price_changed, not_paused, batch_cancelled | State conflict |
| 422 | invalid_image_path, invalid_preset, premium_needs_scene, invalid_scene_model, consent_required, too_many_items, invalid_image_url, missing_image, no_items | Fixable input; nothing charged |
| 429 | ceiling_exceeded, active_batch_limit, daily_cap | Concurrency / daily ceilings; Retry-After; nothing charged |
| 503 | backend_unavailable, store_unavailable, scene_below_margin_gate | Not offered right now; Retry-After unless the option is not sold at all; not charged |
Related
- Character Studio: keep a person or mascot consistent across images
- Background Removal Pro: cut-outs without a scene
- Commerce Bridge API and the store integrations: connect a shop, run Product Shot from your catalogue
- Billing & Pricing and
GET /v1/pricing - Python SDK and TypeScript SDK

