Assets
An asset is the catalog's record of a piece of media. The bytes live in Storage; the asset row says what the file is, which class of media it belongs to, and which product attribute may reference it.
| Entity | Base path |
|---|---|
asset_families | /v1/products/asset_families |
assets | /v1/products/assets |
Asset families
A family is a class of media with one shared shape — packshots, datasheets, installation videos.
{
"code": "packshots",
"labels": { "en": "Packshots", "de": "Produktfotos" },
"naming_convention": {
"source": "sku",
"pattern": "{sku}_{index}",
"allowed_extensions": ["jpg", "png", "webp"]
}
}
Label keys follow the tenant's locale policy: de and en in a tenant that keeps text per language, as in these examples, de-DE and en-GB in one that keeps text per region. See Languages and locale codes.
naming_convention records how a file of this family is meant to be named: source is the product value the name is built from, pattern how it is assembled, allowed_extensions what may be uploaded. It is stored and not applied yet: nothing reads it, so it does not bind files to products or restrict uploads.
Like a reference entity, an asset family carries its own attributes — declare them with entity_type: "asset" and entity_ref: "<family code>".
Read, change or delete one family
| Operation | What it does |
|---|---|
GET /v1/products/asset_families/{id} | Reads one family, the whole row as stored. |
PUT /v1/products/asset_families/{id} | Partial patch: changes only the named columns. A code already taken answers 409. |
DELETE /v1/products/asset_families/{id} | Hard delete: the family row is gone, and every asset of that family (asset_family_id) is deleted with it, trashed ones included. |
Those assets do not go to the trash and cannot be restored. To retire a family and keep its assets, set its active to false instead.
On PUT, a body that names no column answers 400, and so does an active that is not true or false (invalid_active). A locale key in labels or descriptions that is not one of the tenant's write keys answers 422. An unknown id answers 404, and so does another tenant's id.
Assets
{
"asset_family_id": "…",
"code": "ACME-4711-BLK_1",
"source": "storage",
"storage_asset_id": "ast_01hxyz…",
"attribute_values": { "common": { "alt_text": "Cordless drill, front" } }
}
{
"asset_family_id": "…",
"code": "ACME-4711-BLK_video",
"source": "external",
"external_url": "https://cdn.example.com/acme/4711/assembly.mp4"
}
| Field | Meaning |
|---|---|
code | Stable identifier within its family — the value a product's media attribute stores. |
asset_family_id | Which family. A create with none falls back to the tenant's default_asset_family setting. |
source | Where the bytes live: storage (this platform's object store) or external (somebody else's host). |
storage_asset_id | The stable ast_… id of the storage object. Required when source is storage. |
external_url | Absolute URL. Required when source is external. |
delivery_path | The path the CDN serves the asset under. |
attribute_values | The asset's own modelled values, same four buckets as a product. |
file_name | The name as a person reads it. A write that leaves it out takes the last segment of delivery_path (an external asset: of external_url). |
file_format | The format in capitals, such as JPG. Taken from the extension of file_name when the caller sends none. |
file_size | The size in bytes, as the caller sent it. Null when nobody did. |
file_size_label | The size for a person, such as 2,2 MB or 412 KB, computed by the database from file_size. Null without a size. A generated column: it cannot be written. |
The database enforces the pair, so neither half of a source/identifier combination can be stored on its own.
Rights and licence
Seven optional fields record who holds the rights to an asset and on what terms it may be used. Send them on create (POST /v1/products/assets) or update (PUT /v1/products/assets/{id}); they come back on reads. Each one is nullable, and each is also an exact-match filter on GET /v1/products/assets: a date filter matches one day, and there is no range filter, so ?license_valid_until=2026-12-31 finds the assets whose licence ends on that day, not the ones ending before it.
| Field | Meaning |
|---|---|
rights_holder | Who made the asset or holds its rights: a photographer, an agency, the manufacturer. |
rights_origin | Where the asset was obtained: the agency, portal or contract it came from. |
license | Under which terms the asset may be used. One of the asset-licenses vocabulary (GET /v1/products/vocabularies/asset-licenses lists them with titles): own, licensed, royalty_free, rights_managed, manufacturer, editorial, cc_by, cc_by_sa or cc0. Null when nobody said. |
usage_rights | What the licence allows and forbids, in words: media, regions, channels, an embargo. |
license_valid_from | The first day the asset may be used under its licence, as YYYY-MM-DD. Null: no start date. |
license_valid_until | The last day the asset may be used under its licence, as YYYY-MM-DD. Null: no expiry. |
copyright_notice | The credit line printed with the asset where it is shown, such as © 2026 Studio Weiss. |
The fields are recorded, not enforced. The API does not check that license_valid_from comes before license_valid_until, and an asset past its last day is not hidden or flagged: showing the credit line and acting on an expiry is up to the client.
Two rules worth internalising
Never join on delivery_path. It is the convenient value for rendering and it changes when the file is moved. storage_asset_id survives a rename or a folder move, which is exactly why it and not the path is the identifier.
External URLs are gated, and only here. source: "external" is accepted only when the tenant has allow_external_media on and the host is on its external_media_allowed_hosts list. POST /v1/products/assets is the only place an external URL can enter the catalog, so it is the only place those checks exist.
Getting the bytes there
Uploading a file is a Storage operation, not a catalog one. Upload first, take the returned ast_… id, then create the asset row that points at it. See Storage for the upload paths. Manage product images and documents walks the whole job; an import does not carry media.
Where a product references it
A media or media-collection attribute stores the asset code:
{ "common": { "images": ["ACME-4711-BLK_1", "ACME-4711-BLK_2"] } }
Which attribute carries the main image is a family setting — families.image_attribute, read by grid thumbnails and pickers. See Data model.
Read, change or delete one asset
| Operation | What it does |
|---|---|
GET /v1/products/assets/{id} | Reads one asset, the whole row as stored. |
PUT /v1/products/assets/{id} | Changes the named columns. A clash on asset_family_id + code answers 409, and so does moving it to an inactive asset family (entry_inactive). |
DELETE /v1/products/assets/{id} | Moves the asset to the trash. Called on one already there, deletes it for good, which a storefront contact may not do (403). See Trash and restore. |
The first DELETE only moves the asset to the trash, and its stored file stays where it is. The second DELETE, or emptying the trash, deletes the asset for good and removes the stored file first, through the storage service, unless another asset uses the same file. An asset with no stored file (an external URL) deletes without one.
Nothing in the catalog blocks deleting an asset for good: products, categories and manufacturers that name it keep their reference, and the answer counts them as warnings. When storage does not answer, the delete for good is refused with 409 (storage_unreachable) and the asset stays in the trash. An id no asset of this tenant carries answers 404.
Trash and restore
The shared rules for the trash, including emptying it and the daily purge, are on Trash.
Deleting an asset sends it to the trash. A second delete of the same asset deletes it for good, and so does emptying the trash. The platform also deletes trashed records that outlive the tenant's trash retention (90 days unless the tenant set another), except one that something still blocks.
The first delete fires asset.trashed and a restore asset.restored. asset.deleted fires only once the record is deleted for good. See Deletes and the trash.
- Restore.
POST /v1/products/assets/{id}/restorebrings the asset back from the trash. Nothing else goes to the trash with an asset, so nothing else comes back with it. Everything that pointed at the asset acts again as it did, because nothing was changed when it went. Restoring needs the same right as deleting. The answer lists every record that came back inrestored. - Refused with
409.not_in_trashwhen the asset is not in the trash.parent_in_trashwhen the record it belongs to is in the trash in another group;details.restorenames the call that brings that one back first. - Refused with
404when no asset of this tenant has that id. An asset of another tenant gets the same404, not a403. - Empty the trash.
POST /v1/products/assets/trash/emptydeletes the trashed assets for good, oldest first, up to 200 per call, each through the same checks as a secondDELETE. - A record a blocker holds is listed in
blockedwith its reason and stays in the trash; the rest of the call goes on. deletedlists what this call removed for good.more: truemeans the call stopped at its limit and the trash may hold more: call it again.- A storefront contact gets
403: emptying the trash is back-office work.
GET /v1/products/assets also filters on the four file fields in the table above, each as an exact match.
GET /v1/products/asset_families also filters on active. active is true unless the entry is inactive (false). A list returns both unless you filter, so use ?active=true for a picker. Records that already carry an inactive entry keep it; a write cannot newly assign one (409 entry_inactive). active is also an optional boolean on POST and PUT of an asset family, default true: send false to make it inactive, and anything that is not a boolean answers 400 invalid_active.
Reading and filtering the trash. GET /v1/products/assets leaves trashed assets out unless the call asks with trashed: only returns the trash alone, include returns the live and the trashed assets together. Each asset carries the fields below, and each one is also an exact-match filter on the list call.
| Field | What it holds |
|---|---|
deleted_at | When the asset went to the trash; null while it is live. |
deleted_by | Who moved it there: an e-mail, or system for a scheduled job. A name for the trash view, not an identity to check. |
deleted_by_id | The platform subject of that person, the stable id a privacy request pseudonymises. Null for a scheduled job. |
purge_at | When the daily purge deletes it for good: deleted_at plus the tenant's trash retention (90 days unless the tenant set another). |
trash_batch | The trash action it went with. A restore brings back exactly the records that carry the same value. |
purge_blocked_reason | Why the daily purge could not delete it for good; null when nothing held it. |
The platform sets these fields when an asset is deleted and clears them when it is restored, never through an update, so a client does not normally send them in a write.
Where to go next
- Storage — where the bytes actually live.
- Data model — declaring the media attribute.
- Import and export — binding files to products in bulk.