# Catalogue structure

Reference lists (brands, labels, tax classes, properties) and writes for brands, labels, categories and featured products.

Section: Commerce · REST API
Canonical URL: https://docs.omniretailconnect.nl/commerce/api/catalogue

## Overview

These endpoints read and change the structure around the products: brands, product labels, the category tree and which products are featured on top of a category. Each write is the MCP tool of the same name over HTTP: the `data` equals the structured result, the change is logged with the token (name, id, channel `api`) and the calls count on the [write limit](https://docs.omniretailconnect.nl/commerce/getting-started/limits.md#write-limit). The path names the subject and must not be repeated in the body. Bodies are JSON objects, at most 65536 bytes. The tools are described in [Catalogue write tools](https://docs.omniretailconnect.nl/commerce/mcp/catalogue-tools.md).

- **Scopes.** Reading needs `commerce.read`. Brands, labels and putting a product in or out of a category need `commerce.write.products` (edit permission **Products**). Creating, renaming and moving categories and featured products need `commerce.write.collections` (**Categories**). The two scopes are separate: a products token cannot edit the tree.
- **Result.** `{"data": {...}}` with the subject plus `changed` (`false` when nothing differed) and `changes` (`{field: {old, new}}`).
- **PIM.** Brand names are owned by the PIM when a PIM-linked product uses the brand. Labels and category placements by the shop itself are always editable; detaching a placement the PIM import made is `422` `field_owned_by_pim`.

## Reference lists

Four lists give the ids the writes take. They need `commerce.read` only.

*Reference lists*

| Request | Tool | Result |
| --- | --- | --- |
| `GET /api/v1/brands` | MCP `list_brands` | Pages by id (`limit`, `cursor`, `meta.next_cursor`). Items: `id`, `name`, `slug`, `url`, `logo_url`, `product_count` (products of any status; a brand in use cannot be deleted) and `pim_managed` (a PIM-linked product uses it, so it cannot be renamed). |
| `GET /api/v1/labels` | MCP `list_labels` | All labels in display order, at most 200 (`meta.truncated`). Items: `id`, `name`, `slug`, `tone` (`neutral`, `primary`, `accent`, `dark`, `success`), `position` (lowest first), `product_count`. |
| `GET /api/v1/tax-classes` | MCP `list_tax_classes` | All tax classes (VAT rates), at most 100 (`meta.truncated`). Items: `id`, `name`, `default`. |
| `GET /api/v1/properties` | MCP `list_properties` | Pages by id. Items: `id`, `name`, `handle`, `type` (`list`: one or more labels per product; `number`: one number), `unit`, `values` (`[{id, label}]`, at most 100, `values_truncated`). |

`GET /api/v1/brands` — Needs commerce.read. Equivalent MCP tool: list_brands.

`GET /api/v1/labels` — Needs commerce.read. Equivalent MCP tool: list_labels.

`GET /api/v1/tax-classes` — Needs commerce.read. Equivalent MCP tool: list_tax_classes.

`GET /api/v1/properties` — Needs commerce.read. Equivalent MCP tool: list_properties.

**List brands**

```bash
curl -s -H "Authorization: Bearer $COMMERCE_API_TOKEN" \
  "https://<your-shop-domain>/api/v1/brands?limit=50"
```

**Example response · 200**

```json
{
  "data": [
    {"id": 7, "name": "Bosch", "slug": "bosch", "url": "https://<your-shop-domain>/merken/bosch",
     "logo_url": null, "product_count": 12, "pim_managed": false}
  ],
  "meta": {"next_cursor": null, "has_more": false}
}
```

## Brands

Needs `commerce.write.products`. Result of the first four and the two logo endpoints: `brand` (`id`, `name`, `slug`, `url`, `logo_url`, `product_count`, `pim_managed`), `changed`, `changes`; delete answers `brand_id`, `deleted`, `changed`, `changes`. Assign a brand to a product by name with `PATCH /products/{id}`.

*Brand writes*

| Request | Tool | Notes |
| --- | --- | --- |
| `POST /api/v1/brands` | create_brand | Body: `name` (at most 255 characters, plain text). A name must be new ignoring case; a repeat is `422`, so a retry never makes a duplicate. |
| `PATCH /api/v1/brands/{id}` | rename_brand | Body: `name`. The address stays. A brand that a PIM-linked product uses is `422` `field_owned_by_pim`. |
| `PUT /api/v1/brands/{id}/slug` | change_brand_slug | Body: `slug` (made URL-safe; empty after that, the name is used). A slug another brand uses is `422`. The old address becomes a permanent redirect. |
| `DELETE /api/v1/brands/{id}` | delete_brand | No body. Only a brand no product uses (of any status); otherwise `422`. Its old address redirects to the brands overview. A repeat is `404`. |
| `PUT /api/v1/brands/{id}/logo` | set_brand_logo | Body: `image_url` (public `https` URL) and `alt`. Downloaded with the product image rules (no redirects, at most 10 MB, no SVG); JPEG and PNG are stored as WebP. Every call stores the image again. |
| `DELETE /api/v1/brands/{id}/logo` | remove_brand_logo | No body. Without a logo nothing changes. |

`POST /api/v1/brands` — Needs commerce.write.products. Equivalent MCP tool: create_brand.

`PATCH /api/v1/brands/{id}` — Needs commerce.write.products. Equivalent MCP tool: rename_brand.

`DELETE /api/v1/brands/{id}` — Needs commerce.write.products. Equivalent MCP tool: delete_brand.

`PUT /api/v1/brands/{id}/slug` — Needs commerce.write.products. Equivalent MCP tool: change_brand_slug.

`PUT /api/v1/brands/{id}/logo` — Needs commerce.write.products. Equivalent MCP tool: set_brand_logo.

`DELETE /api/v1/brands/{id}/logo` — Needs commerce.write.products. Equivalent MCP tool: remove_brand_logo.

**Set a brand logo**

```bash
curl -sS -X PUT "https://<your-shop-domain>/api/v1/brands/7/logo" \
  -H "Authorization: Bearer $COMMERCE_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"image_url": "https://images.example.com/bosch.png", "alt": "Bosch logo"}'
```

## Labels

Needs `commerce.write.products`. Putting labels on a product is `PUT /products/{id}/labels` (see [Product writes](https://docs.omniretailconnect.nl/commerce/api/product-writes.md#properties-labels)). Result: `label` (`id`, `name`, `slug`, `tone`, `position`, `product_count`), `changed`, `changes` (a move names `position:{label id}` for both labels); delete answers `label_id`, `deleted`, `changed`, `changes`.

*Label writes*

| Request | Tool | Notes |
| --- | --- | --- |
| `POST /api/v1/labels` | create_label | Body: `name` (at most 40 characters, new ignoring case) and optional `tone`. The label goes to the end of the label order. A repeat is `422`. |
| `PATCH /api/v1/labels/{id}` | update_label | Body: at least one of `name`, `tone`. The slug is made once and stays on a rename, because themes style by it. |
| `POST /api/v1/labels/{id}/move` | move_label | Body: `direction` (`up` or `down`). Swaps with its neighbour; nothing at either end. Not idempotent: every call moves one more place. |
| `DELETE /api/v1/labels/{id}` | delete_label | No body. Takes the label off its products. A repeat is `404`. |

`POST /api/v1/labels` — Needs commerce.write.products. Equivalent MCP tool: create_label.

`PATCH /api/v1/labels/{id}` — Needs commerce.write.products. Equivalent MCP tool: update_label.

`POST /api/v1/labels/{id}/move` — Needs commerce.write.products. Equivalent MCP tool: move_label.

`DELETE /api/v1/labels/{id}` — Needs commerce.write.products. Equivalent MCP tool: delete_label.

## Categories

Read the tree with [`GET /categories`](https://docs.omniretailconnect.nl/commerce/api/categories.md). Result of the tree writes: `collection` (`id`, `slug`, `name`, `parent_id`, `position`, `description`), `changed`, `changes`. The placement endpoints answer `product_id`, `collection`, `in_collection`, `changed`, `changes`. A category of the hidden PIM import tree, or a parent there, is `422`; an unknown category or product is `404`.

*Category writes*

| Request | Tool | Notes |
| --- | --- | --- |
| `POST /api/v1/categories` | create_collection | `commerce.write.collections`. Body: `name` and optional `parent_id` (omitted or `null`: a new top-level category). Not idempotent: every call adds a category. |
| `PATCH /api/v1/categories/{id}` | update_collection | `commerce.write.collections`. Body: at least one of `name`, `description` (plain text; a blank line starts a paragraph; empty or `null` removes it). |
| `POST /api/v1/categories/{id}/move` | move_collection | `commerce.write.collections`. Body: `operation` (`up`, `down`, `make_root` or `into`) and `target_id` (required for `into`, not allowed otherwise). `up` and `down` are not idempotent. |
| `PUT /api/v1/categories/{id}/products/{productId}` | attach_product_to_collection | `commerce.write.products`. No body (an empty JSON object is accepted). Puts the product in the category. |
| `DELETE /api/v1/categories/{id}/products/{productId}` | detach_product_from_collection | `commerce.write.products`. No body. The product itself is not deleted. |

`POST /api/v1/categories` — Needs commerce.write.collections. Equivalent MCP tool: create_collection.

`PATCH /api/v1/categories/{id}` — Needs commerce.write.collections. Equivalent MCP tool: update_collection.

`POST /api/v1/categories/{id}/move` — Needs commerce.write.collections. Equivalent MCP tool: move_collection.

`PUT /api/v1/categories/{id}/products/{productId}` — Needs commerce.write.products. Equivalent MCP tool: attach_product_to_collection.

`DELETE /api/v1/categories/{id}/products/{productId}` — Needs commerce.write.products. Equivalent MCP tool: detach_product_from_collection.

**Create a category and put a product in it**

```bash
curl -sS -X POST "https://<your-shop-domain>/api/v1/categories" \
  -H "Authorization: Bearer $COMMERCE_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Hanglampen", "parent_id": 30}'

curl -sS -X PUT "https://<your-shop-domain>/api/v1/categories/31/products/101" \
  -H "Authorization: Bearer $COMMERCE_API_TOKEN"
```

## Featured products in a category

Featured products sort above all other products of a category listing. All three need `commerce.write.collections` and the product must already be in the category (`PUT .../products/{productId}`), otherwise `422`. Result: `product_id`, `collection`, `featured`, `featured_product_ids` (top first, at most 100), `changed`, `changes` (`position:{product id}`).

*Featured product writes*

| Request | Tool | Notes |
| --- | --- | --- |
| `PUT /api/v1/categories/{id}/featured/{productId}` | feature_product_in_collection | No body. Puts the product on top of the category listing. Already on top: no change. Featuring another later puts that one on top. |
| `DELETE /api/v1/categories/{id}/featured/{productId}` | unfeature_product_in_collection | No body. The product stays in the category. Not featured: no change. |
| `POST /api/v1/categories/{id}/featured/{productId}/move` | move_featured_product_in_collection | Body: `direction` (`up` or `down`). Swaps with its neighbour among the featured products. A product that is not featured is `422`. Not idempotent. |

`PUT /api/v1/categories/{id}/featured/{productId}` — Needs commerce.write.collections. Equivalent MCP tool: feature_product_in_collection.

`DELETE /api/v1/categories/{id}/featured/{productId}` — Needs commerce.write.collections. Equivalent MCP tool: unfeature_product_in_collection.

`POST /api/v1/categories/{id}/featured/{productId}/move` — Needs commerce.write.collections. Equivalent MCP tool: move_featured_product_in_collection.

## Errors

*Errors of the catalogue writes*

| Status | error.code | When |
| --- | --- | --- |
| `403` | `forbidden_scope` | The token lacks the scope of the endpoint: `commerce.write.products` for brands, labels and category placements, `commerce.write.collections` for the category tree and featured products. |
| `404` | `not_found` | Unknown brand, label, category, product or image; a repeated delete. |
| `409` | `conflict` | Another write holds the category tree, the category product list or the brand address for more than 5 seconds. Retry later. |
| `422` | `invalid_argument` | A name that is taken, a brand that is still in use, a category of the hidden PIM import tree, a product that is not in the category, a refused logo URL, an unknown field or a missing body field. |
| `422` | `field_owned_by_pim` | Renaming a brand that a PIM-linked product uses (the import finds brands by name). |
| `429` | `too_many_requests` | The write limit of 30 calls per token per minute. The write did not happen. |
