Images and 3D objects
Each product's photos and its 3D object, with alt text and three sizes.
The scope
This endpoint requires the media:read scope, separate from catalog:read. It's split because the addresses it returns ARE access to the file: the CDN serves by unguessable path, so whoever has the address has the image. A key that only reads the product record doesn't get them.
A product's media
Returns the assigned media: the main one first, then by position, and the 3D object last. The order IS data, but now that the 3D object exists the array can carry something that is not an image: pick the base image by role main — or by filtering mime on image/ — not by index 0.
GET /v1/products/{id}/media
{
"product_id": "…",
"media": [
{
"id": "…",
"filename": "portada.jpg",
"mime": "image/jpeg",
"bytes": 482913,
"role": "main",
"position": 0,
"origin": "upload",
"synthetic_performer": false,
"alt": { "es": "Frasco blanco de crema, 50 ml", "en": "White 50 ml cream jar" },
"url": "https://media.bruxel.ai/orig/…/….jpg",
"medium_url": "https://media.bruxel.ai/deriv/…/…/medium.webp",
"thumb_url": "https://media.bruxel.ai/deriv/…/…/thumb.webp"
}
]
}url is the ORIGINAL: that's what you import into your store, which generates its own sizes. medium_url (1024 px) and thumb_url (320 px) are WebP derivatives for rendering interfaces — don't use them as the product image. If a derivative doesn't exist yet, fall back to the original.
Role, position and provenance
role is the REAL role of the link: main (the base image), gallery (additional) or swatch (a variant's color swatch, drawn from the hex on its page; it serves as Google's color_swatch_link) or model_3d (the product's 3D object). position is the explicit position within the gallery, so you do not depend on list order. origin says whether the customer uploaded the image (upload) or the Studio generated it (ai), and synthetic_performer is true only when it carries a synthetic person: it is what Amazon and Google require to be declared since 2026, and the original already carries the XMP metadata.
The product's 3D object
The 3D object has no endpoint of its own: it comes out of this same GET as one more medium, with role model_3d and mime model/gltf-binary. Its url is the GLB — what a web viewer or Android's Scene Viewer opens — and usdz_url is the same model in USDZ, the only thing Quick Look opens on iPhone: two formats of one object, not two models. thumb_url and medium_url are its WebP preview, so you can paint a thumbnail without downloading the model. usdz_url is null on an image or a video, and there is at most one model_3d per product. The model ALWAYS comes last in the array, so media[0] stays an image for integrations that already exist.
{
"id": "…",
"filename": "objeto-3d-KOS-MUSE-50.glb",
"mime": "model/gltf-binary",
"bytes": 8134422,
"role": "model_3d",
"position": 3,
"origin": "ai",
"synthetic_performer": false,
"alt": {},
"url": "https://media.bruxel.ai/orig/…/….glb",
"medium_url": "https://media.bruxel.ai/deriv/…/…/medium.webp",
"thumb_url": "https://media.bruxel.ai/deriv/…/…/thumb.webp",
"usdz_url": "https://media.bruxel.ai/deriv/…/…/model.usdz"
}Upload and link through the API
With the media:write scope the integration puts its own image on the product, in the same three steps the app uses. First POST /v1/media/presign with name, type and size: it validates the type against the allowlist, reserves quota and returns an S3 signed POST (url and fields) that expires in expires_in seconds. Second, the integration sends that form to S3 with the file in the file field; Bruxel's server never sees the bytes. Third, POST /v1/media/ASSET/complete confirms against S3: it reads the binary signature and the real size; a file that is not of the declared type is discarded with 415. Once ready, POST /v1/products/ID/media links it with its role (main, gallery, swatch or model_3d) and DELETE /v1/products/ID/media/ASSET unlinks it without deleting it from the library. Linking and unlinking emit media.updated.
POST /v1/media/presign
{ "filename": "frente.jpg", "mime": "image/jpeg", "bytes": 482113 }
→ { "asset_id": "…", "url": "https://s3…", "fields": { "key": "…", "Content-Type": "image/jpeg", … }, "expires_in": 900 }
POST {url} (multipart/form-data: cada campo de "fields", y el archivo en "file" al final)
POST /v1/media/{asset_id}/complete
→ { "asset_id": "…", "status": "ready", "mime": "image/jpeg", "bytes": 482113, "width": 1600, "height": 1600 }
POST /v1/products/{id}/media
{ "asset_id": "…", "role": "main" } → 204
DELETE /v1/products/{id}/media/{asset_id} → 204Incremental changes
GET /v1/media/deltas (scope export) tells you which products changed their gallery since your cursor: linking or removing an image, changing the main one, trashing one, writing its alt text or saving a generated image. It returns the product, not the image: for each one you fetch the full gallery with GET /v1/products/{id}/media. That is how deletions are communicated (the product shows up again and its gallery no longer carries the image) and how forty generated photos of the same product become a single entry. Same semantics as the content feed: keep next whenever the page has rows, drain while pages come full, and a since that does not parse is 422.
GET /v1/media/deltas?since=<next anterior>&limit=100
{
"deltas": [
{ "product_id": "…", "sku": "PLY-M-ROJ", "updated_at": "2026-09-03T15:04:05.123456Z" }
],
"next": "2026-09-03T15:04:05.123456Z~…",
"limit": 100
}The media.updated webhook
Subscribe to the media.updated event (Settings → Webhooks) and you get one notice per product whenever its gallery changes, with the usual thin payload: product_id and sku, never the URLs. You fetch the gallery with your key. It is at-least-once and coalesced: fifteen changes to the same product in a minute are one delivery.
{
"event": "media.updated",
"occurred_at": "2026-09-03T15:04:05.123456Z",
"data": { "product_id": "…", "sku": "PLY-M-ROJ" }
}Generating images by API
With the media:generate scope you can open a photo session from your own system: kind packshot turns ONE real photo of the product into its studio main photo (no people); kind product_photo combines a ready synthetic model (model_id) with 1 to 3 real photos and returns up to two proposals. It responds 202 with the job and reserves the credits; the price is charged on success and refunded on failure. Poll GET /v1/ai-media/jobs/{id} until status stops being queued, running or ingesting: that GET is the one that downloads, validates, stamps the metadata and saves the proposals. With link_role they are linked to the product when done; without it, they stay in the library.
POST /v1/products/{id}/ai-media (scope media:generate → 202)
{
"kind": "packshot",
"ref_asset_ids": ["<id de una foto REAL del producto>"],
"num_images": 1,
"aspect_ratio": "1:1",
"link_role": "main"
}
GET /v1/ai-media/jobs/{job_id} (poll: cuando termina, ESTE GET guarda las propuestas)
{
"id": "…", "type": "packshot", "status": "succeeded", "credits_charged": 5,
"results": [{ "asset_id": "…", "mime": "image/png", "url": "https://media.bruxel.ai/orig/…" }],
"error": "", "error_kind": ""
}GET /v1/ai-models lists the organization's synthetic models; a session with a model needs one in stage ready. Models are created and accepted in the app (casting), not by API.
Clothing and the model's expression
A kind product_photo session takes two optional fields, and their defaults reproduce the previous behavior. subject decides how the product appears: product (the model holds or uses it) or garment (the model wears it; fidelity focuses on fabric, print, cut and length). expression picks her face among natural, smile, laugh, neutral, serious and playful. Packshots ignore both, and the job returns them in subject and expression.
POST /v1/products/{id}/ai-media (scope media:generate → 202)
{
"kind": "product_photo",
"model_id": "<modelo en stage: ready>",
"ref_asset_ids": ["<foto REAL de la prenda>"],
"subject": "garment", // product (default) | garment
"expression": "smile", // natural (default) | smile | laugh | neutral | serious | playful
"num_images": 1
}The look of each photo
Three more optional fields, in the same style: hairstyle (as_model, loose, ponytail, bun, braid, half_up, slicked_back), makeup (as_model, bare, natural, glam, editorial, bold_lip) and outfit (as_model, casual, business, evening, sporty, studio_neutral). The casting freezes the model's identity; this is what changes between shots. With all three on as_model — the default — the prompt is identical to the one before they existed.
POST /v1/products/{id}/ai-media (scope media:generate → 202)
{
"kind": "product_photo",
"model_id": "<modelo en stage: ready>",
"ref_asset_ids": ["<foto REAL del producto>"],
"hairstyle": "ponytail", // as_model (default) | loose | ponytail | bun | braid | half_up | slicked_back
"makeup": "glam", // as_model (default) | bare | natural | glam | editorial | bold_lip
"outfit": "business", // as_model (default) | casual | business | evening | sporty | studio_neutral
"num_images": 1
}
# el job responde con lo que se pidió de verdad:
# "look": { "hairstyle": "ponytail", "makeup": "glam", "outfit": "business" }AI video
The video starts from an image already saved to the product: that image is the first frame, and the framing comes from it. short is the five-second clip at a flat rate; extended goes up to 30 seconds with audio and is charged per second × quality, so duration and quality are required and have to be among the ones your environment offers. You poll it with the same GET as the photos, and the job returns tier: that is what lets you reconcile the charge, because both kinds of clip come through as video.
POST /v1/products/{id}/ai-video (scope media:generate → 202)
{
"ref_asset_id": "<una imagen YA guardada en el producto: es el primer cuadro>",
"tier": "extended", // short (default, 5 s) | extended (hasta 30 s con audio)
"duration_seconds": 15, // obligatorio en extended, y de la lista que ofrece tu ambiente
"resolution": "720p", // obligatorio en extended: 480p (mitad de precio) | 720p
"audio_mode": "ambient", // ambient (default) | none
"prompt": "gira despacio y termina en el logo"
}
GET /v1/ai-media/jobs/{job_id} (el MISMO poll de las fotos)
{
"id": "…", "type": "video", "tier": "extended", "status": "succeeded",
"credits_charged": 300,
"results": [{ "asset_id": "…", "mime": "video/mp4", "url": "https://media.bruxel.ai/orig/…" }]
}With ai_model_id the long clip goes through reference-to-video: your model's five views go in as an identity reference, so it is the same person in every cut and not just in the first frame. That is the only case where the framing is chosen. Extra product images only exist with a model, and the last frame only without one: with references there is no first or last frame to pin down.
POST /v1/products/{id}/ai-video
{
"ref_asset_id": "<primer cuadro>",
"tier": "extended", "duration_seconds": 15, "resolution": "720p",
"ai_model_id": "<modelo en stage: ready>", // la misma persona en TODOS los cortes
"aspect_ratio": "9:16", // con modelo SÍ se elige; sin él lo dicta la imagen
"extra_ref_ids": ["<otra imagen del producto>"] // solo con modelo, hasta 2
}
# sin modelo, y solo sin modelo, puedes fijar el ÚLTIMO cuadro:
# "end_asset_id": "<otra imagen del producto>"Seedance does its best work with shot-by-shot scripts with timestamps, and nobody writes that by hand. This route shows Claude your first frame and returns the clip shot by shot plus the prompt ready to generate. You can edit it before sending; what travels to the provider is script.prompt, with our fidelity and safety anchors pasted on the end.
POST /v1/products/{id}/ai-video/script (3 créditos, se cobran AL ÉXITO)
{
"ref_asset_id": "<el mismo primer cuadro>",
"duration_seconds": 15, "resolution": "720p",
"platform": "reels", // tiktok | reels | shorts | amazon | web | other
"brief": "que se vea la textura" // opcional; NO nombres la red social aquí
}
# devuelve el guion toma por toma y el prompt listo:
# { "script": { "shots": [...], "prompt": "0-4s slow dolly-in…" },
# "credits_charged": 3, "balance": 8870 }
#
# y ese guion se manda tal cual al generar:
# POST …/ai-video { …, "script": <el objeto de arriba> }Syncing a whole catalog
Don't use this endpoint product by product. The catalog CSV already carries the image columns with the names Adobe Commerce expects: one call for the whole catalog instead of one per product.
curl -s "https://bruxel.ai/api/public/v1/export/catalog.csv" \
-H "Authorization: Bearer bxk_…" -o catalogo.csv
# columnas: base_image, small_image, thumbnail, additional_imagesWhat shows up and what doesn't
Only images that are ready and assigned to the product. Anything the customer sent to the trash doesn't appear, even though it stays restorable for 30 days; neither does a half-finished upload.