Assets

Asset families and assets — how catalog media is classified, the storage/external binding the database enforces, and why delivery_path is never the identifier.

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.

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

Asset family
{
  "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

OperationWhat 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 — platform storage
{
  "asset_family_id": "…",
  "code": "ACME-4711-BLK_1",
  "source": "storage",
  "storage_asset_id": "ast_01hxyz…",
  "attribute_values": { "common": { "alt_text": "Cordless drill, front" } }
}
Asset — external host
{
  "asset_family_id": "…",
  "code": "ACME-4711-BLK_video",
  "source": "external",
  "external_url": "https://cdn.example.com/acme/4711/assembly.mp4"
}
FieldMeaning
codeStable identifier within its family — the value a product's media attribute stores.
asset_family_idWhich family. A create with none falls back to the tenant's default_asset_family setting.
sourceWhere the bytes live: storage (this platform's object store) or external (somebody else's host).
storage_asset_idThe stable ast_… id of the storage object. Required when source is storage.
external_urlAbsolute URL. Required when source is external.
delivery_pathThe path the CDN serves the asset under.
attribute_valuesThe asset's own modelled values, same four buckets as a product.
file_nameThe 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_formatThe format in capitals, such as JPG. Taken from the extension of file_name when the caller sends none.
file_sizeThe size in bytes, as the caller sent it. Null when nobody did.
file_size_labelThe 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.

FieldMeaning
rights_holderWho made the asset or holds its rights: a photographer, an agency, the manufacturer.
rights_originWhere the asset was obtained: the agency, portal or contract it came from.
licenseUnder 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_rightsWhat the licence allows and forbids, in words: media, regions, channels, an embargo.
license_valid_fromThe first day the asset may be used under its licence, as YYYY-MM-DD. Null: no start date.
license_valid_untilThe last day the asset may be used under its licence, as YYYY-MM-DD. Null: no expiry.
copyright_noticeThe 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:

attribute_values
{ "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

OperationWhat 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}/restore brings 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 in restored.
  • Refused with 409. not_in_trash when the asset is not in the trash. parent_in_trash when the record it belongs to is in the trash in another group; details.restore names the call that brings that one back first.
  • Refused with 404 when no asset of this tenant has that id. An asset of another tenant gets the same 404, not a 403.
  • Empty the trash. POST /v1/products/assets/trash/empty deletes the trashed assets for good, oldest first, up to 200 per call, each through the same checks as a second DELETE.
  • A record a blocker holds is listed in blocked with its reason and stays in the trash; the rest of the call goes on.
  • deleted lists what this call removed for good. more: true means 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.

FieldWhat it holds
deleted_atWhen the asset went to the trash; null while it is live.
deleted_byWho 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_idThe platform subject of that person, the stable id a privacy request pseudonymises. Null for a scheduled job.
purge_atWhen the daily purge deletes it for good: deleted_at plus the tenant's trash retention (90 days unless the tenant set another).
trash_batchThe trash action it went with. A restore brings back exactly the records that carry the same value.
purge_blocked_reasonWhy 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

Was this page helpful?