Manage product images and documents

Upload product images and datasheets, register them as catalog assets, reference them from products, serve resized images from the asset URL, and hand out private datasheets by signed link.

A product page needs a picture that loads fast, and a buyer's purchasing team wants the datasheet. At the end of this guide, a product carries a main image and a datasheet, the image is served at the size each page asks for, and the datasheet opens through a link that expires. If your catalog came in through an import, it has no media yet; step 8 covers attaching media to a whole catalog.

This is for developers and integrators who fill a catalog or build a storefront. New to the platform? Prerequisites covers Cockpit, your tenant slug, installing an app and a test tenant. You need:

  • a tenant (your company's isolated workspace on revenexx) with the Products app installed, which holds the catalog, and Storage, the object store behind every file;
  • its tenant slug, the short name you send as X-Revenexx-Tenant;
  • an API key with two scopes (see which scope covers which app): storage for the file calls and products.* for the catalog calls. products.* includes writes, which every step below needs. You create the key in Cockpit, the admin interface at app.revenexx.com;
  • a product to attach media to, and the product must have a family (a family decides which attributes its products carry; see Data model);
  • one image and one PDF to try.

Every call goes through the API gateway:

Environment
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."
Statements below about what the API does not check or enforce can change (as of 2026-10).

How the pieces fit

Three records are involved, and keeping them apart is most of the work. The word "asset" names two of them, so this guide says file for the Storage object and catalog asset for the catalog row:

RecordLives inWhat it is
The fileStorageThe bytes, a visibility (public or private) and an id like ast_…. Storage's own API calls it an asset
The catalog assetthe catalogA row that says what the file is: its class (an asset family), a code, and the file's id
The referencethe productThe catalog asset's code, stored in an asset_collection attribute of the product

There is no call that links a product to an asset. A product points at a catalog asset by writing its code into an attribute. The steps below set up that attribute and then create the three records in order.

The two APIs shape their answers differently. Storage wraps a file in data (read .data.id), the catalog returns a single row as it is (read .id) and a list as items next to page and filter (read .items[0]), and a signed link comes back unwrapped. The jq filters below follow that.

1. Create an asset family

An asset family is a class of media, such as product photos or datasheets. Catalog assets in an asset family share a code namespace. Create one for each class and keep the id each answer returns:

Create the asset families
curl -s -X POST https://api.revenexx.com/v1/products/asset_families \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "code": "packshots", "labels": { "en": "Packshots" } }'

curl -s -X POST https://api.revenexx.com/v1/products/asset_families \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "code": "datasheets", "labels": { "en": "Datasheets" } }'

A duplicate code answers 409. In the steps below, <PACKSHOTS_FAMILY_ID> and <DATASHEETS_FAMILY_ID> are these two ids.

2. Add media attributes to the product's family

A product's attributes come from its family: the family lists them, and the product form and the completeness check read that list. Create two attributes of type asset_collection, one for images and one for datasheets, and add both to the product's family.

Find the product's id and its family's id by its SKU:

Find the product and its family
curl -s "https://api.revenexx.com/v1/products?sku=ACME-4711" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  | jq '.items[0] | { id, family_id }'

<PRODUCT_ID> and <PRODUCT_FAMILY_ID> below are these two values. Then create the attributes. config.asset_family names the asset family by its code; Cockpit shows and edits it as the attribute's asset family:

Create the attributes
curl -s -X POST https://api.revenexx.com/v1/products/attributes \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "code": "images", "type": "asset_collection", "config": { "asset_family": "packshots" }, "labels": { "en": "Images" } }'

curl -s -X POST https://api.revenexx.com/v1/products/attributes \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "code": "datasheets", "type": "asset_collection", "config": { "asset_family": "datasheets" }, "labels": { "en": "Datasheets" } }'

Each answer is the stored attribute; keep its id (<IMAGES_ATTRIBUTE_ID> and <DATASHEETS_ATTRIBUTE_ID>). A 409 means an attribute with that code exists already. Find it with GET /v1/products/attributes?code=images and reuse it if it is an asset_collection. The API does not check the codes you write later against config.asset_family.

Add both attributes to the family:

Add the attributes to the family
curl -s -X POST https://api.revenexx.com/v1/products/family_attributes \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "family_id": "<PRODUCT_FAMILY_ID>", "attribute_id": "<IMAGES_ATTRIBUTE_ID>" }'

curl -s -X POST https://api.revenexx.com/v1/products/family_attributes \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "family_id": "<PRODUCT_FAMILY_ID>", "attribute_id": "<DATASHEETS_ATTRIBUTE_ID>" }'

A 409 here means the attribute is in the family already, which is the state you want. To make images the main image that grids and pickers show, set it on the family with PUT /v1/products/families/<PRODUCT_FAMILY_ID> and the body { "image_attribute": "images" }. Send only that field: the update leaves the family's other fields alone.

3. Upload the files

Upload is a Storage call, not a catalog one. Choose the visibility with care: a public file can be fetched by anyone who has its address, and a private one only through a signed link (step 7). Make the image public and the datasheet private:

Upload an image and a datasheet
curl -s -X POST https://api.revenexx.com/v1/storage/assets \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -F "file=@./ACME-4711-front.jpg" \
  -F "visibility=public" \
  -F "alt_text=Cordless drill, front"

curl -s -X POST https://api.revenexx.com/v1/storage/assets \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -F "file=@./ACME-4711-datasheet.pdf" \
  -F "visibility=private" \
  -F "display_name=Datasheet ACME-4711"
Response
{ "data": { "id": "ast_01hxyz…", "status": "pending_processing", "visibility": "public", "url": "https://…" } }

Keep the id of each: <IMAGE_FILE_ID> and <DATASHEET_FILE_ID> below. The status starts as pending_processing and becomes available once processing finishes; poll GET /v1/storage/assets/<FILE_ID> until it does. A file that fails becomes processing_failed and can be retried with POST /v1/storage/assets/<FILE_ID>/reprocess. A private file has url: null, because it has no public address.

Two refusals to expect: 413 when the file is too large or the tenant is over its quota, and 415 when the type is not allowed (executables and scripts are blocked). Upload and manage files covers the folder, rename and delete calls, and who may see a file is set out in Access control and visibility.

4. Register the files as catalog assets

Create one catalog asset per file, pointing at the file's id. The code is what a product will store, and it only has to be unique inside its asset family:

Register the image and the datasheet
curl -s -X POST https://api.revenexx.com/v1/products/assets \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "asset_family_id": "<PACKSHOTS_FAMILY_ID>", "code": "ACME-4711_front", "source": "storage", "storage_asset_id": "<IMAGE_FILE_ID>" }'

curl -s -X POST https://api.revenexx.com/v1/products/assets \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "asset_family_id": "<DATASHEETS_FAMILY_ID>", "code": "ACME-4711_datasheet", "source": "storage", "storage_asset_id": "<DATASHEET_FILE_ID>" }'

storage_asset_id is the stable identifier: it survives a rename or a move of the file. delivery_path is optional and only a cached copy of the address, so never join on it. A file on someone else's host is registered with source: "external" and an external_url instead, which the tenant must allow (Assets has the fields and the rules). An asset family may carry a naming_convention, but nothing reads it today.

5. Reference the catalog assets from a product

Write the codes into the attributes from step 2, images and datasheets. Their values go in the common bucket, because neither attribute is localizable or scopable; Products explains the four buckets of attribute_values:

This PUT replaces attribute_values as a whole. A body that carries only images and datasheets deletes every other value the product holds. Read the product first and send the merged object back, as below.

Read the product, merge the two codes into what it already holds, and send the result. The merge is in jq, so nothing else in attribute_values is touched:

Reference the image and the datasheet
BODY=$(curl -s "https://api.revenexx.com/v1/products/<PRODUCT_ID>" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  | jq '{ attribute_values: ((.attribute_values // {}) * { common: { images: ["ACME-4711_front"], datasheets: ["ACME-4711_datasheet"] } }) }')

curl -s -X PUT "https://api.revenexx.com/v1/products/<PRODUCT_ID>" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$BODY"

The merged body keeps the product's other values next to the two new ones:

Merged body
{ "attribute_values": { "common": { "images": ["ACME-4711_front"], "datasheets": ["ACME-4711_datasheet"] } } }

The update changes only the fields in the body and leaves the others alone, but attribute_values is one field: keys you leave out are gone. The jq merge sets images and datasheets to the lists shown, so if the product already holds codes there, put them in those lists too. A code that does not resolve is accepted without complaint, so a typo shows up later as a missing picture, not as an error.

6. Serve the image at the size the page needs

A storefront goes from the product to the picture in three reads. Cache the result rather than repeating it per page view; the catalog asset list is cached on the platform side too, and that cache is cleared on every write to a catalog asset.

From a code to a URL
# 1. the code is on the product: attribute_values.common.images[0]
# 2. the catalog asset for that code, to get the file id
curl -s "https://api.revenexx.com/v1/products/assets?asset_family_id=<PACKSHOTS_FAMILY_ID>&code=ACME-4711_front" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  | jq -r '.items[0].storage_asset_id'

# 3. the file, to get its url
curl -s "https://api.revenexx.com/v1/storage/assets/<IMAGE_FILE_ID>" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  | jq -r '.data.url'

Pass the asset family id along with the code, because a code is unique only inside its asset family. A query key that is not a column of the entity is dropped without an error and the list comes back unfiltered. The list answer echoes the filter it applied, so check that both keys are in it:

List response
{
  "items": [ { "id": "…", "asset_family_id": "…", "code": "ACME-4711_front", "source": "storage", "storage_asset_id": "ast_01hxyz…" } ],
  "page": { "limit": 50, "offset": 0, "total": 1, "returned": 1, "hasMore": false },
  "filter": { "asset_family_id": "…", "code": "ACME-4711_front" }
}

The url is the original. To get a derivative, add parameters to it. The image is resized and re-encoded when it is asked for, and the answer is marked cacheable for a year:

A 400 px WebP
<url from the file>?w=400&fm=webp

w and h are pixels (up to 5000), fm is webp, avif, jpg, png or gif, q is the quality from 1 to 100, and fit and gravity control cropping. Upload the largest version you have and generate every smaller size this way; do not store thumbnails. Serve and transform images lists every parameter.

The url follows the file's folder and name, and names are lowercased, so renaming or moving a file changes it. Two live files in one folder never share a name: a second upload of front.jpg is stored as front_2.jpg. If you delete a file and upload another under its name, the old address serves the new bytes, and copies of the old ones may stay cached for a long time. Re-read the url after you move a file instead of keeping the old one.

7. Hand out the private datasheet

A private file opens through a signed link, and anyone who holds the link can open it until it expires. Mint one when a buyer asks for the file, not when the page renders:

Sign a link
curl -s -X POST "https://api.revenexx.com/v1/storage/assets/<DATASHEET_FILE_ID>/sign" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "ttl_seconds": 900 }'
Response
{ "url": "https://…?expires=1750680000&signature=…", "expires_in": 900 }

ttl_seconds defaults to one hour and goes up to seven days. A link cannot be revoked before it expires, so keep it short and sign on demand. After it expires the link answers 403. Your API key does the signing, so mint the link on your server, never in the browser (Signed URLs and access).

8. Load a whole catalog

A catalog import does not carry media today. A BMECAT file (the XML standard for exchanging B2B product catalogs) lists a product's images and documents in MIME entries; the import reads them and creates nothing from them in the catalog: no catalog asset, no reference. Import the products first, then attach media with a script of your own over a list of SKU, file and kind: upload each file (step 3), create its catalog asset (step 4), and write the codes with the product update (step 5). Step 2 is done once per family, not per product.

For a large folder of files, an SFTP sync rule pulls them from your SFTP server into a Storage folder on a schedule. The other route is the Direct Media Import from ZIP Integration Studio template, a ready-made workflow you install in Integration Studio, which takes a ZIP you drop in by hand (it needs an API credential with the storage scope). Make the script safe to run again: creating a catalog asset with an existing code in the asset family answers 409, which means the code is taken. Before you treat that as done, check that the existing catalog asset points at the file you meant, because a code that points at the wrong file looks the same as a code that is already right. Import a BMECAT catalog covers getting the products in.

Troubleshooting

SymptomCauseFix
The image url answers 404The file is not available: still pending_processing (also right after a reprocess), or processing_failedRead the file's status; retry a failed one with reprocess (step 3)
Upload answers 413 or 415Too large, over the tenant's quota, or a blocked typeReduce the file, free quota, or use an allowed type
A product shows no imageThe code on the product does not match a catalog asset's code; nothing checked itCompare the codes (step 5)
The picture disappeared after a moveThe url follows the folder and nameRead the url again (step 6)
A signed link answers 403It expiredMint a new one (step 7)
Creating a catalog asset answers 409The code exists in that asset familyCheck it points at the right file, then keep it or pick another code (step 8)
A product update dropped other attribute valuesThe update replaced attribute_values as a wholeSend the full object you want to keep (step 5)

Where to go next

Do one product by hand, then script steps 3 to 5 for the catalog. Then read:

Was this page helpful?