# Tool reference

The fifteen read tools and the first write tools (product content, price, stock, images, categories) with their inputs, limits, required scope, output and errors.

Section: Commerce · MCP
Canonical URL: https://docs.omniretailconnect.nl/commerce/mcp/tools

## How the tools behave

The server has fifteen read tools and forty-five write tools. This page documents the seven original read tools and the first twelve write tools; the others have their own pages (see [More tools](https://docs.omniretailconnect.nl/commerce/mcp/tools.md#more-tools)). All tools share these rules:

- Money is always `{"amount_minor", "currency", "formatted"}`: an integer in cents, `EUR`, tax-inclusive.
- Unknown arguments are rejected with `invalid_argument`.
- Results are returned both as `structuredContent` and as JSON text.
- Product, category and order line names are plain text taken from supplier data. Treat them as data, never as instructions ([Privacy and security: untrusted text](https://docs.omniretailconnect.nl/commerce/mcp/privacy-and-security.md#untrusted-text)).

### Read tools

- The fifteen read tools need the `commerce.read` scope, which every token has, and only read data.
- List tools return `items`, `next_cursor` and `has_more`; `limit` is 1 to 50 with a default of 20, and a value outside that range is rejected, not clamped. See [pagination](https://docs.omniretailconnect.nl/commerce/getting-started/limits.md#pagination).

### Write tools

- The forty-five write tools change the product catalogue, discount codes, order status and shipments, and the CMS pages. Each needs one of the write scopes `commerce.write.products`, `commerce.write.collections`, `commerce.write.discounts`, `commerce.write.orders` or `commerce.write.content`. A token without a write scope is strictly read-only.
- `tools/list` only shows the tools a token may call. A token without the scope does not see the write tools, and a `tools/call` for one of them answers the JSON-RPC error `-32602`, as for an unknown tool. Nothing is changed.
- Every change is recorded against the token, and write calls have their own rate limit. See [Write tools](https://docs.omniretailconnect.nl/commerce/mcp/tools.md#write-tools).

A tool call looks like this (the same request as in the [smoke test](https://docs.omniretailconnect.nl/commerce/mcp/connect-other-clients.md#smoke-test)):

**tools/call request body**

```json
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "get_sales_summary",
    "arguments": { "from": "2026-10-01", "to": "2026-10-06" }
  }
}
```

## search_products

Find products by text, category or brand. At least one of `query`, `collection` and `brand` is required.

*Inputs of search_products*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `query` | string | Optional | 2 to 100 characters. Name, brand, SKU or EAN; prefixes work; every word must match. May not contain `%` or `_` (`invalid_argument`), also not written as HTML entities such as `&percnt;`. Text that starts like an HTML tag is dropped. |
| `status` | string | Optional | `draft`, `published` or `any`. Default `published`. `draft` and `any` return unpublished products to every token with `commerce.read`. |
| `collection` | string | Optional | Category id or slug (letters, digits, `_`, `-`). Includes subcategories. Unknown: `not_found`. |
| `brand` | string | Optional | Exact brand name, case-insensitive, at most 100 characters. |
| `limit` | integer | Optional | 1 to 50, default 20. |
| `cursor` | string | Optional | The `next_cursor` of the previous page. |

**Output.** `items` with `id`, `sku`, `name`, `brand`, `status`, `price` (`min`, `max`, `tax_inclusive`), `variant_count`, `categories` and `url`; plus `next_cursor`, `has_more` and `truncated`. Stock is not included: use `get_product` or `check_sku_availability`.

Results are ranked (exact SKU or EAN, SKU prefix, name, name or brand) and paged by position. Paging stops at result position 500: when more results exist beyond it, `truncated` is `true` and there is no `next_cursor`. Narrow the query with more words, a collection or a brand. When a SKU or EAN was searched, `sku` is the matching variant's SKU.

## get_product

One product, by `id` or by the `sku` of one of its variants. Exactly one of the two is required. Unpublished products are returned too, to every token with `commerce.read`. SKU matching is exact; whether it ignores case depends on the database collation, so send the stored spelling.

**Output.** `id`, `name`, `status`, `brand`, `description` (plain text, at most 2000 characters), `categories`, `variants` (at most 100), `images` (at most 10 objects, main image first, each `{"id": 101, "url": "https://..."}`; `id` is the media id that the [media write tools](https://docs.omniretailconnect.nl/commerce/mcp/tools.md#remove_product_media) take), `url` and `updated_at`. Since server 1.4.0 it also returns what the full-list writes replace, so a client can read it before it sends a whole list: `slug` (`change_product_slug`), `seo` (`meta_title` and `meta_description`; `null` means the shop's fallback; `update_product_seo`), `labels` (`[{id, name}]` in label order; `set_labels`) and `properties` (`[{property_id, name, values}]`, the values set by hand; `set_product_properties`). Each variant has `id`, `sku`, `ean`, `price`, `availability`, `weight_g`, `sales` and `price_breaks`. `sales` lists the active and planned sales, soonest first, at most 10 (`{id, price, starts_at, ends_at, active}`; `price` stays the regular price) and `price_breaks` the quantity price breaks (`{min_quantity, price}`), all prices tax-inclusive money objects. `brand`, `sku`, `ean` and `tax_class` can be `null`, `price` is `null` when no price is set, `weight_g` is the weight in grams (converted from g, kg, lb or oz; `null` when unknown or zero), and `description` is an empty string, never `null`.

*Variant availability*

| Field | Type | Description |
| --- | --- | --- |
| `quantity` | integer\|null | `null` means stock is not tracked. |
| `purchasable` | boolean | Whether the variant can be bought. |
| `low_stock` | boolean | `true` when the quantity is at or below the low-stock threshold set for the variant. |
| `expected_restock_at` | string\|null | Expected restock time (ISO 8601), when known. |
| `source` | string | `local` or `external`. |
| `status` | string | `ok` or `unavailable`. |

No match: `not_found`.

## check_sku_availability

Bulk stock check.

*Inputs of check_sku_availability*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `skus` | array of string | Required | 1 to 50 SKUs, each at most 64 characters. |

**Output.** `items`, one per distinct SKU in input order (SKUs are trimmed and exact duplicates are answered once; whether `a` matches `A` depends on the database collation, so send SKUs in their stored spelling), each with `sku`, `found`, `quantity`, `purchasable`, `low_stock`, `expected_restock_at`, `source` and `status`. Names and prices are not included.

- An unknown SKU is `found: false` with status `ok`. It is not an error.
- Status `unavailable` means the inventory source could not be reached for that variant: `quantity` is `null` and `purchasable` is `false`. This does not mean the SKU is out of stock.

## search_orders

Find orders, newest first. All filters are optional.

*Inputs of search_orders*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `reference` | string | Optional | Exact order reference. Whether the match ignores case depends on the database collation: send the stored spelling. |
| `email` | string | Optional | Exact, case-insensitive. Needs the `commerce.read.pii` scope (otherwise `forbidden_scope`). Limited to a 90-day window. |
| `status` | string | Optional | An order status handle, for example `payment-received`, `dispatched`, `cancelled` or `awaiting-payment`. |
| `placed_from` | date | Optional | `YYYY-MM-DD`, Europe/Amsterdam, inclusive. |
| `placed_to` | date | Optional | `YYYY-MM-DD`, Europe/Amsterdam, inclusive. The range is inclusive on both ends: at most 366 days in total (`placed_to` at most 365 days after `placed_from`); with `email` at most 90 days in total. |
| `limit` | integer | Optional | 1 to 50, default 20. |
| `cursor` | string | Optional | The `next_cursor` of the previous page. |

**Output.** Rows with `reference`, `id`, `status`, `placed_at`, `total`, `line_count` and `customer` (`name`, `email`, `masked`). Payment and fulfilment state are only in `get_order`.

- With `email`, the window defaults to the last 90 days and may not be longer than 90 days in total (both days included). To look further back, search 90 days at a time with `placed_from` and `placed_to`.
- Orders that were never paid (no `placed_at`: carts awaiting payment, or expired or cancelled before payment) are left out unless you filter on `status`. Date filters compare `placed_at`, so they exclude them too.
- `placed_to` before `placed_from`, or a range that is too long, is `invalid_argument`.

## get_order

One order, by `reference` or `id`. Exactly one of the two is required.

**Output.** `reference`, `id`, `status`, `placed_at`, `totals` (`sub`, `shipping`, `tax`, `discount`, `total`), `lines`, `payment`, `fulfilment`, `refunds`, `returns`, `internal_notes`, `customer` and `shipping_address`.

*Parts of get_order*

| Field | Type | Description |
| --- | --- | --- |
| `lines` | array | At most 100 lines with `sku`, `name`, `qty`, `unit_price` and `line_total`. Shipping lines are left out. |
| `payment` | object | `state`, `paid`, `refunded`, `paid_at`, `needs_review`, `held` (money of other paid attempts that waits for review) and `review_refunded` (what of that was refunded). |
| `fulfilment` | object | `state` (`unfulfilled`, `partial` or `fulfilled`) and `shipments` (at most 20, each with `id`, `carrier`, `tracking_code`, `tracking_url`, `status` and `shipped_at`). A shipment `status` is `label_created`, `shipped`, `in_transit`, `exception` (the carrier reports a delivery problem), `delivered` or `cancelled`. `carrier`, `tracking_code` and `shipped_at` can be `null`. |
| `internal_notes` | object\|null | Only with `commerce.read.pii`, otherwise `null`: `{items, truncated}`, the internal staff notes newest first, at most 50, each with `id`, `body`, `author`, `author_type` (`staff`, `token` or `unknown`) and `created_at`. Free text that may describe the customer. |
| `refunds` | array | Succeeded refunds only, at most 50, each with `amount` and `created_at`. |
| `returns` | array | Return requests, oldest first, at most 20, each with `reference`, `type` (`withdrawal`, `return` or `defect`), `status` (`requested`, `received`, `refund_pending`, `refunded` or `rejected`), `requested_at`, `received_at` and `refunded_at`. Never the reason or contact email. |

The payment state is derived from payment attempts and refunds: `unpaid`, `paid`, `partially_refunded`, `refunded`, `failed`, `cancelled`, `expired` or `needs_review`. `status` is the order status handle. No match: `not_found`. Customer identity, address details and tracking codes are masked unless the token has `commerce.read.pii`. `shipping_address` is `null` when the order has none; without the pii scope the keys `postcode` and `street` are absent, not `null`.

## list_categories

The product category tree.

*Inputs of list_categories*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `parent` | string | Optional | Category id or slug. Omit it for the top level. Unknown: `not_found`. |
| `depth` | integer | Optional | 1 to 3, default 2. |

**Output.** `categories` (nodes with `id`, `slug`, `name`, `parent_id`, `product_count` and `children`) and `truncated`. `product_count` counts published products in the category and its subcategories. At most 500 nodes are returned; `truncated` is `true` when the tree was cut. Use a category `id` or `slug` as `collection` in `search_products`.

## get_sales_summary

Operational sales metrics for a date range. These are not accounting revenue and will not necessarily reconcile with finance reports.

*Inputs of get_sales_summary*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `from` | date | Required | `YYYY-MM-DD`, Europe/Amsterdam, inclusive. |
| `to` | date | Required | `YYYY-MM-DD`, Europe/Amsterdam, inclusive. Not before `from`; at most 366 days in total. |
| `group_by` | string | Optional | `none` (default), `day`, `week` (ISO, starting Monday) or `month`. |

*Output of get_sales_summary*

| Field | Type | Description |
| --- | --- | --- |
| `currency` | string | `EUR`. |
| `period` | object | `from`, `to` and `timezone`. |
| `orders_count` | integer | Orders placed in the period, by order date. Cancelled orders are excluded. |
| `gross_sales` | money | Sum of the tax-inclusive totals of the counted orders. |
| `refunded` | money | Succeeded refunds, counted by refund date. |
| `net_sales` | money | `gross_sales` minus `refunded`. Can be negative. |
| `average_order_value` | money | `gross_sales` divided by `orders_count`, rounded; 0 without orders. |
| `buckets` | array | Empty for `none`; otherwise one entry per day, week or month with `start`, `orders_count`, `gross_sales`, `refunded` and `net_sales`. |
| `definitions` | array of string | The rules of the metrics as text. |

Orders are counted by order date and refunds by refund date, so a refund can fall in a different period than its order. The output contains no customer data. With `group_by`, buckets are contiguous and zero-filled, and the first bucket starts at `from` even when that is mid-week or mid-month.

## Write tools

Forty-five tools change data. The first twelve change the product catalogue: product content, base price, stored stock, new products, product images, category placements and the category tree. Nine need `commerce.write.products`; the three category tree tools need `commerce.write.collections`. The write scopes are only issued together with `commerce.read`, see [Scopes and masking](https://docs.omniretailconnect.nl/commerce/reference/scopes-and-masking.md).

*The first twelve write tools*

| Tool | Scope | Purpose |
| --- | --- | --- |
| `update_product` | `commerce.write.products` | Edit name, description, status, brand and categories of one product. |
| `set_price` | `commerce.write.products` | Set the base price of one variant, by SKU. |
| `set_stock` | `commerce.write.products` | Set the stored stock quantity of one variant, by SKU. |
| `create_product` | `commerce.write.products` | Create a draft product with one variant. |
| `attach_product_to_collection` | `commerce.write.products` | Put one product in one category. |
| `detach_product_from_collection` | `commerce.write.products` | Take one product out of one category. |
| `add_product_media` | `commerce.write.products` | Add one image to a product from an `https` URL. |
| `remove_product_media` | `commerce.write.products` | Remove one image from a product. |
| `set_primary_product_media` | `commerce.write.products` | Make one image the main image of a product. |
| `create_collection` | `commerce.write.collections` | Add a category to the category tree. |
| `update_collection` | `commerce.write.collections` | Rename a category or set its description. |
| `move_collection` | `commerce.write.collections` | Move a category within its tree. |

### More tools

- [Catalogue write tools](https://docs.omniretailconnect.nl/commerce/mcp/catalogue-tools.md): the reference lists `list_brands`, `list_labels`, `list_tax_classes` and `list_properties`, and the write tools `update_product_seo`, `change_product_slug`, `update_variant`, `set_product_properties`, `set_labels`, `set_variant_sale`, `end_variant_sale`, `set_price_breaks`, the brand and label tools, and the featured product tools (`commerce.write.products`, featured products `commerce.write.collections`).
- [Discount tools](https://docs.omniretailconnect.nl/commerce/mcp/discount-tools.md): `list_discounts`, `get_discount`, `create_discount`, `update_discount`, `deactivate_discount` and `delete_discount` (`commerce.write.discounts`).
- [Order tools](https://docs.omniretailconnect.nl/commerce/mcp/order-tools.md): `confirm_offline_payment`, `cancel_unpaid_order`, `add_order_note`, `add_shipment` and `update_shipment_tracking` (`commerce.write.orders`).
- [Content tools](https://docs.omniretailconnect.nl/commerce/mcp/content-tools.md): `list_pages`, `get_page`, `create_page`, `update_page` and `delete_page` (`commerce.write.content`).

> **Security — A write token can change the live shop:** Changes take effect immediately, also on the storefront. There is no undo. Give write tokens only to clients that need them, and let a person confirm each write in the client. See [Privacy and security: write tokens](https://docs.omniretailconnect.nl/commerce/mcp/privacy-and-security.md#write-tokens).

### Common rules

- **Same rules as the admin.** Each tool runs the same catalogue action as the matching screen in the shop admin: the same validation, the same PIM rules and the same stored format. Only the authorisation (the token scope) and the attribution (the token instead of a staff member) differ.
- **Recorded against the token.** Every call that really changes something is recorded in the change history with the token's name, the tool, the subject and the old and new value of every changed field. Shop administrators see it per token (**Change history**, *Wijzigingsgeschiedenis*, on the API tokens page) and per product. A call that changes nothing records no change.
- **All or nothing.** A call is one database transaction together with its change-history entry: if the entry cannot be written, the change is rolled back.
- **Result.** Every result has `changed` (`false` when nothing differed) and `changes`: per changed field `{"old", "new"}`, only for fields that really changed. Long text is cut to 200 characters there; the change history keeps the full value.
- **Retries.** There are no idempotency keys (`add_shipment` has its own required `idempotency_key`). Most tools set fields to the given values, so repeating the call changes nothing and a retry is safe. `create_product`, `create_collection` and `add_product_media` add something on every call, and `move_collection` with `up` or `down` moves one more place per call: check with a read tool before you retry them.
- **Concurrent changes.** A change of the same data in the admin or by another token waits for the call. If the data changed during the call, or another change keeps it locked for 5 seconds, the answer is `conflict`: read again and retry. Writes are not conditional otherwise: when two clients set the same field, the last write wins, and both are in the change history.
- **PIM-managed data.** On a product that is linked to the PIM, fields, images and category placements that the PIM manages are refused with `field_owned_by_pim`, because the next import would overwrite them. Sending the value the product already has is not a change and is accepted. A product without a PIM link is fully editable.
- **Plain text.** Names are reduced to plain text on one line and markup is removed from descriptions before they are stored. A blank name is `invalid_argument`.
- **Rate limit.** At most 30 write calls per token per minute, on top of the general limit of the token and shared with the REST API. Over it, the call does not run and answers `too_many_requests`. See [the write limit](https://docs.omniretailconnect.nl/commerce/getting-started/limits.md#write-limit).
- **What is out of reach.** No tool deletes a product, variant, price or category, no tool refunds money or cancels a paid order, and no tool changes customers. The removals are images, category placements, brands that no product uses, labels, discount codes that were never used, and CMS pages other than the home page.

### Tool annotations

Each write tool advertises MCP tool annotations in `tools/list`, so a client can decide which calls need confirmation:

*Annotations of the write tools*

| Annotation | Value | Meaning |
| --- | --- | --- |
| `readOnlyHint` | `false` for every write tool | Every write tool changes data. |
| `destructiveHint` | `true` for `remove_product_media`, `delete_brand`, `remove_brand_logo`, `delete_label`, `set_labels`, `set_product_properties`, `end_variant_sale`, `set_price_breaks`, `delete_discount`, `cancel_unpaid_order`, `update_page` and `delete_page` | They delete something, replace a whole list (an empty list wipes it), end planned sales, rewrite the blocks of a page, or cancel an order, which cannot be undone. |
| `idempotentHint` | `false` for `create_product`, `create_collection`, `add_product_media`, `create_brand`, `create_label`, `set_brand_logo`, `create_discount`, `add_order_note`, `create_page`, and for the moves (`move_collection`, `move_label`, `move_featured_product_in_collection`) | These add or move something on every call, or refuse a repeat. All other write tools set values, so a repeat changes nothing. |
| `openWorldHint` | `true` for `add_product_media`, `set_brand_logo`, `create_page`, `update_page`, `confirm_offline_payment` and `add_shipment` | The first four download the image URLs the client names. The last two send an e-mail to the customer. No other write tool makes an outbound request. |

### Errors of the write tools

Write tools fail like read tools: a `tools/call` result with `isError: true` and a JSON `code` and `message`. See [tool errors](https://docs.omniretailconnect.nl/commerce/getting-started/errors.md#tool-errors).

*Error codes of the write tools*

| Code | When |
| --- | --- |
| `invalid_argument` | Failed validation: an unknown argument, a value out of range, an unknown brand or category id in the input, a taken SKU, name, code or slug, a blank name, a refused image URL, or a category of the hidden PIM import tree. The message names the rule. |
| `not_found` | The product, SKU, image, category or target category of the call does not exist. |
| `field_owned_by_pim` | The product is linked to the PIM and the field, image or category placement belongs to the PIM: the next import would overwrite it. Change it in the PIM. |
| `conflict` | `set_stock` while an external inventory provider manages stock; `create_product` in a shop that gets its products from the PIM; an order, discount or page that is in a state the action does not allow (see the pages of those tools); `update_page` with an out-of-date `expected_updated_at`; or, for any write tool, the data changed during the call or another change of the same data kept it locked for 5 seconds. Read again and retry. |
| `too_many_requests` | The token is over its write limit of 30 write calls per minute: `Too many write requests. Try again in a minute.` The call did not run. |
| `internal_error` | An unexpected failure. The message is always `An internal error occurred.` |

`forbidden_scope` does not occur for write tools over MCP: without the scope the tool does not exist for the token, and a call answers JSON-RPC `-32602`.

**tools/call request body · set_price**

```json
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "set_price",
    "arguments": { "sku": "HAM-500", "amount_minor": 1595 }
  }
}
```

## update_product

Edit the content of one product. Scope `commerce.write.products`. At least one field besides `id` is required; a field that is not given is left alone.

*Inputs of update_product*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | integer | Required | Product id. |
| `name` | string | Optional | At most 255 characters, plain text (markup is removed). The Dutch product name. |
| `description` | string | Optional | At most 5000 characters, plain text (markup is removed). The Dutch product description. |
| `status` | string | Optional | `draft` hides the product from the storefront, `published` shows it. |
| `brand` | string\|null | Optional | The name of an existing brand, case-insensitive, at most 191 characters. An unknown brand is `invalid_argument`. `null` clears the brand. |
| `collections` | array of integer | Optional | The complete list of category ids the product belongs to (see `list_categories`); categories not listed are removed. At most 50. An unknown id is `invalid_argument`. |

**Output.** The product as `get_product` returns it after the change, plus `changed` and `changes`. Repeating the call changes nothing.

**Errors.** An unknown product is `not_found`. On a product linked to the PIM, a new `name`, `description` or `brand` is `field_owned_by_pim`; `status` and `collections` are always editable, because categories belong to the shop.

## set_price

Set the base price (quantity 1) of one variant, found by SKU. Scope `commerce.write.products`.

*Inputs of set_price*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `sku` | string | Required | SKU of the variant, at most 64 characters. |
| `amount_minor` | integer | Required | Tax-inclusive price in cents, 0 to 100000000. For example `1999` for € 19,99. |

**Output.** `sku`, `product_id`, `price` (`amount` as money, `tax_inclusive` and `tax_class`), `changed` and `changes` (the old and new price as money objects). Setting the current price again changes nothing.

On a product linked to the PIM the price becomes a manual price: later PIM imports keep it. An unknown SKU is `not_found`.

## set_stock

Set the stored stock quantity of one variant, found by SKU, to an exact number. Scope `commerce.write.products`.

*Inputs of set_stock*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `sku` | string | Required | SKU of the variant, at most 64 characters. |
| `quantity` | integer | Required | The new stock quantity, 0 to 1000000. |

**Output.** `sku`, `product_id`, `availability` (as an entry of `check_sku_availability`, after the change), `changed` and `changes` (`stock` with the old and new quantity). Setting the current quantity again changes nothing.

- Only while the shop's own stock is authoritative. With an external inventory provider the call is `conflict`, because the stored quantity would be ignored or overwritten.
- On a product linked to the PIM the call is `field_owned_by_pim`, because every PIM import overwrites its stock. The quantity already stored is accepted as a no-op.
- The stock change is recorded in the shop's inventory history with the token name, and back-in-stock notifications to customers run as for a change in the admin.
- An unknown SKU is `not_found`.

## create_product

Create one product as a draft, with a single variant. Scope `commerce.write.products`. A shop that gets its products from the PIM answers `conflict`: new products start in the PIM there.

*Inputs of create_product*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | Required | At most 255 characters, plain text (markup is removed). |
| `sku` | string | Required | At most 64 characters. Must be new: it is compared, ignoring case, with every SKU that exists or ever existed in the shop. A taken SKU is `invalid_argument`. |
| `price_minor` | integer | Required | Tax-inclusive price in cents, 0 to 100000000. |
| `ean` | string\|null | Optional | A valid GTIN: 8, 12, 13 or 14 digits with a correct check digit. Another product may have the same EAN. |
| `brand` | string\|null | Optional | The name of an existing brand, case-insensitive, at most 191 characters. An unknown brand is `invalid_argument`. |

**Output.** The new product as `get_product` returns it (`status` `draft`), plus `changed` (`true`) and `changes` (the price as a money object). The shop's default tax class is used and the URL is made from the name.

- Not idempotent: a repeat with the same SKU is `invalid_argument`, because the SKU is taken. Check with `check_sku_availability` or `search_products` before you retry.
- Fill in the rest afterwards: description, categories and status with `update_product`, categories also with `attach_product_to_collection`, and images with `add_product_media`. Publish with `update_product` `status` `published`.

## attach_product_to_collection and detach_product_from_collection

Put one product in one category, at the end of its regular order (never as a featured product), or take it out. Scope `commerce.write.products`. `update_product` with `collections` makes the same change for all categories at once.

*Inputs of attach_product_to_collection and detach_product_from_collection*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `collection_id` | integer | Required | Category id (see `list_categories`). |
| `product_id` | integer | Required | Product id. |

**Output.** `product_id`, `collection` (`id`, `slug`, `name`, `parent_id`, `position`, `description`), `in_collection` (whether the product is in the category after the call), `changed` and `changes`.

- Attaching a product that is in the category already, or detaching one that is not, changes nothing (`changed: false`).
- Detaching a placement that the PIM import made is `field_owned_by_pim`: the next import would restore it.
- A category of the hidden PIM import tree is `invalid_argument`. An unknown product or category is `not_found`.

## add_product_media

Add one image to a product from a URL. Scope `commerce.write.products`. The client sends a URL, not the image bytes, because a request body is at most 64 KiB. Allowed on products linked to the PIM.

*Inputs of add_product_media*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `product_id` | integer | Required | Product id. |
| `image_url` | string | Required | An `https` URL of the image itself, at most 2048 characters. See the URL rules below. |
| `alt` | string | Required | Alternative text that describes the image, at most 255 characters. Whitespace is collapsed. |

### Image URL rules

- `https` only. No user name or password in the URL, and no host name that ends with a dot.
- The host must resolve to public internet addresses only. Private, loopback, link-local and other internal or reserved addresses are refused, also when only one of the host's addresses is not public.
- Redirects are never followed: a `3xx` answer is refused, so give the final URL of the image itself. Any other answer outside `2xx` is refused too.
- At most 10 MB. The connection must open within 5 seconds and the download must finish within 15 seconds.
- The format is taken from the content, never from the URL or the `Content-Type` header. Only JPEG, PNG, WebP, GIF and AVIF are accepted. SVG (also when served as `image/png`), HTML and other content is refused.
- At most 8000 pixels on a side and 40 megapixels.

Every refusal is `invalid_argument` with a message that names the rule, for example `The image URL redirects; redirects are not followed, give the final URL.`

The image is then stored exactly like an upload in the admin:

- JPEG, PNG, WebP and AVIF are re-encoded as WebP and scaled down to the shop's maximum product image size. Re-encoding removes metadata such as EXIF and GPS location. GIF images are stored as received.
- The file gets a name made from the product name; the file name in the URL is not used.
- The first image of a product without a main image becomes the main image.

*Output of add_product_media, remove_product_media and set_primary_product_media*

| Field | Type | Description |
| --- | --- | --- |
| `product_id` | integer | The product. |
| `media_id` | integer | The image this call was about (for `add_product_media` the new image). |
| `images` | array | Every image of the product after the call, main image first, each `{"id", "url", "alt", "primary"}`. `alt` can be `null`. |
| `changed` | boolean | `false` when nothing changed. |
| `changes` | object | Per changed field `{"old", "new"}`: `image`, `alt` and `primary`, with image file names as values. |

Not idempotent: every call adds an image. Check the product's `images` before you retry.

## remove_product_media

Remove one image from a product, with its file and sizes. Scope `commerce.write.products`. This cannot be undone.

*Inputs of remove_product_media*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `product_id` | integer | Required | Product id. |
| `media_id` | integer | Required | Image id, from the `images` of `get_product` or of a media write tool. |

- **Output.** As `add_product_media`, with the remaining images.
- When the main image is removed, the next image in gallery order becomes the main image.
- An image that the PIM manages is `field_owned_by_pim`: the next import would bring it back.
- An image that is not one of the product's is `not_found`, so a repeat of a successful call answers `not_found`.
- It is the only tool that advertises `destructiveHint: true`.

## set_primary_product_media

Make one image the main image of a product; every other image stops being the main image. Scope `commerce.write.products`. Allowed for images the PIM manages too.

*Inputs of set_primary_product_media*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `product_id` | integer | Required | Product id. |
| `media_id` | integer | Required | Image id, from the `images` of `get_product` or of a media write tool. |

- **Output.** As `add_product_media`.
- Repeating the call changes nothing.
- An image that is not one of the product's is `not_found`. `conflict` means the main image changed during the call.

## create_collection

Add a category to the category tree. Scope `commerce.write.collections`.

*Inputs of create_collection*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | Required | At most 255 characters, plain text (markup is removed). |
| `parent_id` | integer\|null | Optional | Id of the parent category. Omit it for a top-level category. |

- Without `parent_id` the category becomes the last top-level category of the tree that `list_categories` shows; with it, the last child of that parent.
- The storefront address is made from the name.
- A parent in the hidden PIM import tree is `invalid_argument`; an unknown parent is `not_found`.
- Not idempotent: every call adds a category. Check `list_categories` before you retry.

*Output of create_collection, update_collection and move_collection*

| Field | Type | Description |
| --- | --- | --- |
| `collection` | object | `id`, `slug`, `name`, `parent_id`, `position` (among its siblings, from 0) and `description` (plain text). |
| `changed` | boolean | `false` when nothing changed. |
| `changes` | object | Per changed field `{"old", "new"}`. Long text is cut. |

## update_collection

Rename a category and/or set its description. Scope `commerce.write.collections`. At least one of `name` and `description` is required; a field that is not given is left alone.

*Inputs of update_collection*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | integer | Required | Category id (see `list_categories`). |
| `name` | string | Optional | At most 255 characters, plain text (markup is removed). |
| `description` | string\|null | Optional | At most 5000 characters, plain text: markup is removed, a blank line starts a new paragraph and a single line break stays a line break. Empty or `null` removes the description. |

- **Output.** As `create_collection`, with `collection.description` as plain text. The description is stored as HTML paragraphs, the way the admin editor stores it, so `changes.description` shows the stored HTML.
- Repeating the call changes nothing.
- A category of the hidden PIM import tree is `invalid_argument`; an unknown category is `not_found`; `conflict` means someone changed it during the call.

**result.structuredContent of update_collection (illustrative values)**

```json
{
  "collection": {
    "id": 12,
    "slug": "gereedschap",
    "name": "Handgereedschap",
    "parent_id": null,
    "position": 0,
    "description": ""
  },
  "changed": true,
  "changes": {
    "name": { "old": "Gereedschap", "new": "Handgereedschap" }
  }
}
```

## move_collection

Move a category within its tree. Scope `commerce.write.collections`.

*Inputs of move_collection*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | integer | Required | Category id (see `list_categories`). |
| `operation` | string | Required | `up` or `down` (one place among its siblings; nothing changes on the first or last one), `make_root` (to the end of the top level) or `into` (the last child of `target_id`). |
| `target_id` | integer\|null | Optional | Required for `into`, not allowed otherwise. The new parent, a category of the same tree. |

- **Output.** As `create_collection`; `changes` has `parent_id` and `position` as far as they changed.
- Moving a category into itself, into one of its own subcategories or into another tree is `invalid_argument`. An unknown category or target is `not_found`. `conflict` means someone else moved it during the call.
- `up` and `down` are not idempotent: each call moves one more place.
