bruxel.ai

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.

Until September 2026 the role was derived from position (the first one was main and the rest gallery), so swatch never came through here, and model_3d is newer still. If your integration assumed only those two values, get it ready: when you meet a role you do not know, check the mime before publishing anything — a model/gltf-binary hung in an image gallery breaks the storefront.

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"
}
The GLB does not travel in the CSV's image columns and never will: base_image and additional_images expect images, and a model there would break the store's import. This endpoint is the only public path to the 3D object. Generating one is done from the app today; over the API it is read-only. The file carries the AI-generation disclosure inside it, just like the photos' XMP.

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}  → 204
The library's base quota is the organization's (500 MB today): presign answers 413 when it does not fit, and storage is expanded from Credits. Uploading from a remote URL is not in this phase: whoever has the file sends it.

Incremental 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
}
Changes show up after a few minutes of settling (the feed holds back the hot edge so long transactions are never missed). The real-time path is the media.updated webhook; the feed is recovery.

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
}
In garment mode ONE real photo of the garment is enough, and it does not matter if someone else is wearing it in that photo: only the garment is taken. If your integration sends more than one while the dedicated try-on is active, the answer is 422 with no credits reserved.

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" }
The face, hair length, color and texture, and build never change: they belong to the model, not to the photo. outfit is ignored with subject garment, because there your product's garment is the wardrobe, and video generation accepts none of the three (it animates an image that already has them). The job returns look with only what was actually requested.
References must be REAL photos of that product: a generated, foreign or trashed one responds 422 without reserving anything. Library space is validated before charging (413, not charged), and the spending limit and the daily AI cap apply just like from the app. Generated images come labeled (origin ai and, with a model, synthetic_performer true): you decide whether to publish them.

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/…" }]
}
A 30-second clip at 720p is 600 credits. Test the idea at 480p, which costs half, and generate at 720p only what you're going to publish. The cap is six extended clips a day per organization and it counts ATTEMPTS: if one fails we refund the credits, but that attempt is already used.

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> }
It costs 3 credits, charged on success: if the model doesn't return a usable script the response is 503 and you weren't charged. Ask for it with the duration and the mode already picked, because changing the duration afterwards doesn't rewrite it. And don't name the social network in brief: the real-people filter reacts to "tiktok" or "instagram" and rejects the request without charging; that is what platform is for.

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_images
The CSV goes with the export scope, not media:read: it's the full catalog, the same data classification.

What 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.

If image storage isn't configured on the account, the response is an empty list with 200 — never addresses that would 404 in your catalog.
Images and 3D objects — Bruxel