# Product writes

Create products and change content, SEO, web address, properties, labels, variants, price, sales, price breaks, stock and images with a write token.

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

## Overview

These endpoints change one product, one variant or one image: content, SEO, web address, properties, labels, EAN and tax class, base price, stock, sale prices, quantity price breaks and images, and they create draft products. They need a token with the scope `commerce.write.products` (always together with `commerce.read`) and the `api` channel. In the shop admin that is the edit permission **Products** (*Producten*) in **Settings** > **API tokens**; a token with it lives at most 30 days. See [access levels](https://docs.omniretailconnect.nl/commerce/reference/scopes-and-masking.md#access-levels). Brands, labels, categories and featured products have their own page: [Catalogue structure](https://docs.omniretailconnect.nl/commerce/api/catalogue.md).

- **Same as the MCP tools.** Every endpoint runs the MCP write tool of the same name ([`update_product`](https://docs.omniretailconnect.nl/commerce/mcp/tools.md#update_product), [`set_price`](https://docs.omniretailconnect.nl/commerce/mcp/tools.md#set_price), [`set_stock`](https://docs.omniretailconnect.nl/commerce/mcp/tools.md#set_stock), and the tools in the [catalogue write tools](https://docs.omniretailconnect.nl/commerce/mcp/catalogue-tools.md)), with the same rules. Their `data` equals the structured result of the tool.
- **The URL names the subject.** The product id or the SKU is in the path and must not be repeated in the body. The query string is not read.
- **JSON body.** Send a JSON object with `Content-Type: application/json` (a `charset` parameter is allowed), at most 65536 bytes (262144 for the page writes). Unknown fields are rejected.
- **Result.** `{"data": {...}}` with the result plus `changed` (`false` when nothing differed) and `changes`: per changed field `{"old", "new"}`.
- **Safe to repeat.** Most endpoints set values, so sending the same body again changes nothing and answers `changed: false`. There are no idempotency keys. The ones that add something say so below: `POST /products` (a taken SKU is refused) and `POST /products/{id}/media` (adds an image on every call).
- **Change history.** Every write that really changes something is recorded with the token (its name and id), the channel `api`, the subject and the old and new values. Shop administrators see it under **Change history** (*Wijzigingsgeschiedenis*) on the API tokens page and on the product. Changes are kept for 730 days. A call that changes nothing records nothing.
- **Write limit.** At most 30 write calls per token per minute, on top of the general limit of 60 requests per token per minute, and shared with the MCP write tools. See [the write limit](https://docs.omniretailconnect.nl/commerce/getting-started/limits.md#write-limit).
- **PIM-managed data.** On a product linked to the PIM, fields the PIM owns are refused with `422` `field_owned_by_pim`, because the next import would overwrite them. Sending the value the product already has is accepted.

> **Security — A write token changes the live shop:** Changes take effect immediately and the API cannot undo them; the change history shows the old values. Keep write tokens on the server, give each integration its own, and never feed untrusted text (supplier data, reviews, email) into write calls without a check. See [Write tokens](https://docs.omniretailconnect.nl/commerce/mcp/privacy-and-security.md#write-tokens).

## Change product content

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

Send at least one field. Fields you leave out stay as they are.

*Body fields of PATCH /products/{id}*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | Optional | 1 to 255 characters, one line of 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 `422` `invalid_argument`. `null` or an empty string clears the brand. |
| `collections` | array of integer | Optional | The complete list of category ids the product belongs to (see `GET /categories`); it replaces the current list. At most 50. An unknown id is `422` `invalid_argument`. |

**Request body**

```json
{
    "name": "Klauwhamer 500 g (hickory)",
    "status": "published"
}
```

**Rename and publish a product**

```bash
curl -sS -X PATCH "https://<your-shop-domain>/api/v1/products/101" \
  -H "Authorization: Bearer $COMMERCE_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Klauwhamer 500 g (hickory)", "status": "published"}'
```

The response is the product as [`GET /products/{id}`](https://docs.omniretailconnect.nl/commerce/api/products.md#get-product) returns it after the change, plus `changed` and `changes`:

**Example response · 200**

```json
{
    "data": {
        "id": 101,
        "name": "Klauwhamer 500 g (hickory)",
        "status": "published",
        "brand": "Hammerwerk",
        "description": "Klauwhamer met hickory steel en antislip handgreep.",
        "categories": [
            {
                "id": 12,
                "slug": "gereedschap",
                "name": "Gereedschap"
            },
            {
                "id": 14,
                "slug": "handgereedschap",
                "name": "Handgereedschap"
            }
        ],
        "variants": [
            {
                "id": 201,
                "sku": "HAM-500",
                "ean": "8712345678901",
                "price": {
                    "amount": {
                        "amount_minor": 1495,
                        "currency": "EUR",
                        "formatted": "€ 14,95"
                    },
                    "tax_inclusive": true,
                    "tax_class": "Standaard"
                },
                "availability": {
                    "quantity": 14,
                    "purchasable": true,
                    "low_stock": false,
                    "expected_restock_at": null,
                    "source": "local",
                    "status": "ok"
                },
                "weight_g": 620,
                "sales": [],
                "price_breaks": []
            },
            {
                "id": 202,
                "sku": "HAM-700",
                "ean": null,
                "price": {
                    "amount": {
                        "amount_minor": 1895,
                        "currency": "EUR",
                        "formatted": "€ 18,95"
                    },
                    "tax_inclusive": true,
                    "tax_class": "Standaard"
                },
                "availability": {
                    "quantity": 3,
                    "purchasable": true,
                    "low_stock": true,
                    "expected_restock_at": "2026-10-20T00:00:00+02:00",
                    "source": "local",
                    "status": "ok"
                },
                "weight_g": null,
                "sales": [],
                "price_breaks": []
            }
        ],
        "images": [
            {
                "id": 101,
                "url": "https://your-shop.example.com/storage/101/klauwhamer.jpg"
            }
        ],
        "url": "https://your-shop.example.com/p/klauwhamer-500-g",
        "updated_at": "2026-10-05T14:21:07+02:00",
        "slug": "klauwhamer-500-g",
        "seo": {
            "meta_title": null,
            "meta_description": null
        },
        "labels": [],
        "properties": [],
        "changed": true,
        "changes": {
            "name": {
                "old": "Klauwhamer 500 g",
                "new": "Klauwhamer 500 g (hickory)"
            }
        }
    }
}
```

On a product linked to the PIM, a new `name`, `description` or `brand` is `422` `field_owned_by_pim`; `status` and `collections` are always editable.

## Set the price of a variant

`PUT /api/v1/skus/{sku}/price` — Needs commerce.write.products. Equivalent MCP tool: set_price.

Sets the base price (quantity 1) of the variant with this SKU. The SKU is matched exactly, at most 64 characters; percent-encode a SKU that contains `/` or a space.

*Body fields of PUT /skus/{sku}/price*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `amount_minor` | integer | Required | Tax-inclusive price in cents, 0 to 100000000. For example `1595` for € 15,95. |

**Set a price**

```bash
curl -sS -X PUT "https://<your-shop-domain>/api/v1/skus/HAM-500/price" \
  -H "Authorization: Bearer $COMMERCE_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"amount_minor": 1595}'
```

**Example response · 200**

```json
{
    "data": {
        "sku": "HAM-500",
        "product_id": 101,
        "price": {
            "amount": {
                "amount_minor": 1595,
                "currency": "EUR",
                "formatted": "€ 15,95"
            },
            "tax_inclusive": true,
            "tax_class": "Standaard"
        },
        "changed": true,
        "changes": {
            "price": {
                "old": {
                    "amount_minor": 1495,
                    "currency": "EUR",
                    "formatted": "€ 14,95"
                },
                "new": {
                    "amount_minor": 1595,
                    "currency": "EUR",
                    "formatted": "€ 15,95"
                }
            }
        }
    }
}
```

On a product linked to the PIM the price becomes a manual price: later PIM imports keep it.

## Set the stock of a variant

`PUT /api/v1/skus/{sku}/stock` — Needs commerce.write.products. Equivalent MCP tool: set_stock.

Sets the stored stock quantity of the variant with this SKU to an exact number.

*Body fields of PUT /skus/{sku}/stock*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `quantity` | integer | Required | The new stock quantity, 0 to 1000000. |

**Set the stock**

```bash
curl -sS -X PUT "https://<your-shop-domain>/api/v1/skus/HAM-500/stock" \
  -H "Authorization: Bearer $COMMERCE_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"quantity": 20}'
```

**Example response · 200**

```json
{
    "data": {
        "sku": "HAM-500",
        "product_id": 101,
        "availability": {
            "quantity": 20,
            "purchasable": true,
            "low_stock": false,
            "expected_restock_at": null,
            "source": "local",
            "status": "ok"
        },
        "changed": true,
        "changes": {
            "stock": {
                "old": 14,
                "new": 20
            }
        }
    }
}
```

- `availability` is the entry of [GET /availability](https://docs.omniretailconnect.nl/commerce/api/availability.md) after the change.
- Only while the shop's own stock is authoritative: with an external inventory provider the answer is `409` `conflict`.
- On a product linked to the PIM the answer is `422` `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.

## Create a product

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

Creates a draft product with one variant. Publish it afterwards with `PATCH /products/{id}` and `"status": "published"`.

*Body fields of POST /products*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | Required | At most 255 characters, plain text. |
| `sku` | string | Required | At most 64 characters, new in the shop. |
| `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). |
| `brand` | string\|null | Optional | The name of an existing brand. |

**Create a product**

```bash
curl -sS -X POST "https://<your-shop-domain>/api/v1/products" \
  -H "Authorization: Bearer $COMMERCE_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Klauwhamer 500 g", "sku": "HAM-500", "price_minor": 1495, "brand": "Hammerwerk"}'
```

The response is the new product as `GET /products/{id}` returns it, plus `changed` and `changes`. A second product with the same SKU (compared ignoring case, deleted variants included) is `422`, so a retry never creates a duplicate. A shop that gets its products from the PIM answers `409` `conflict`: new products start in the PIM there.

## Change images

`POST /api/v1/products/{id}/media` — Needs commerce.write.products. Equivalent MCP tool: add_product_media.

`DELETE /api/v1/products/{id}/media/{mediaId}` — Needs commerce.write.products. Equivalent MCP tool: remove_product_media.

`PUT /api/v1/products/{id}/media/{mediaId}/primary` — Needs commerce.write.products. Equivalent MCP tool: set_primary_product_media.

The path names the product and the image (`mediaId` is the `images[].id` of `GET /products/{id}`) and must not be repeated in the body.

*Body fields of POST /products/{id}/media*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `image_url` | string | Required | A public `https` URL of the image. |
| `alt` | string | Optional | Alternative text of the image. |

- The shop downloads the image: `https` only, a public host (no private, loopback or link-local address), no redirects, at most 10 MB, JPEG, PNG, WebP or GIF. Every refusal is `422` `invalid_argument`.
- The result of all three is `product_id`, `media_id` (the new, removed or main image), `images` (`[{id, url, alt, primary}]`), `changed` and `changes`.
- `POST` adds an image on every call. Removing an image the PIM manages is `422` `field_owned_by_pim`; a repeated remove is `404`. Making the main image the main image again changes nothing. The two bodiless endpoints accept an empty JSON object.

## Change SEO and web address

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

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

*Body fields of PATCH /products/{id}/seo*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `meta_title` | string\|null | Optional | At most 255 characters, plain text. `null` or empty removes the own title; the shop then uses the product name. |
| `meta_description` | string\|null | Optional | At most 320 characters, plain text. `null` or empty removes it; the shop then uses the description. |

*Body field of PUT /products/{id}/slug*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `slug` | string | Required | The new address part of `/producten/{slug}`. Made URL-safe (lower case, dashes), at most 255 characters. |

- SEO and the address are the shop's own, so both are allowed on products linked to the PIM.
- The SEO result is `product_id`, `seo` (`meta_title`, `meta_description`), `changed` and `changes`. The slug result is `product_id`, `slug`, `url`, `changed` and `changes`; for a published product `changes.redirect` holds the old path, which keeps working as a permanent redirect. A draft gets none.
- A slug that is empty after cleaning, or used by another product, is `422`. Two changes to the same new slug are serialised; one that waits more than 5 seconds answers `409`.

**Change the web address**

```bash
curl -sS -X PUT "https://<your-shop-domain>/api/v1/products/101/slug" \
  -H "Authorization: Bearer $COMMERCE_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"slug": "klauwhamer-500-gram"}'
```

## Set properties and labels

`PUT /api/v1/products/{id}/properties` — Needs commerce.write.products. Equivalent MCP tool: set_product_properties.

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

Both send the complete list and replace what the product has, so an empty list removes everything. Read the current lists first with `GET /products/{id}` (`properties`, `labels`).

*Body fields of PUT /products/{id}/properties and PUT /products/{id}/labels*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `properties` | array | Optional | `PUT .../properties` only, required. The complete list of `{property_id, values}`, each property once, at most 50, each with at most 50 values of at most 100 characters. |
| `label_ids` | array of integer | Optional | `PUT .../labels` only, required. The complete list of distinct label ids, at most 50. |

- Properties: ids and known values come from [`GET /properties`](https://docs.omniretailconnect.nl/commerce/api/catalogue.md#reference-lists). A known label is reused (ignoring case), a new one becomes a new value of the property, a number property keeps its first value. An unknown property refuses the whole call and stores nothing. Values that come from variants stay. Result: `product_id`, `properties` (`[{property_id, name, values}]`), `changed`, `changes`.
- Labels: ids come from `GET /labels`; an unknown id is `422`. Allowed on products linked to the PIM. Result: `product_id`, `labels` (`[{id, name}]`), `changed`, `changes`.

## Change the EAN or tax class of a variant

`PATCH /api/v1/skus/{sku}` — Needs commerce.write.products. Equivalent MCP tool: update_variant.

*Body fields of PATCH /skus/{sku}*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `ean` | string\|null | Optional | A valid GTIN. `null` or empty clears it. |
| `tax_class_id` | integer | Optional | A tax class id from `GET /tax-classes`. |

Send at least one field. Both are PIM-owned on a PIM-linked product (`422` `field_owned_by_pim`). Changing the tax class keeps the price customers pay; `changes.price` shows a rounding difference when there is one. Result: `sku`, `product_id`, `variant` (`id`, `sku`, `ean`, `tax_class_id`, `tax_class`, `price`), `changed`, `changes`. `PATCH /skus/{sku}` does not match a path ending in `/price`, `/stock`, `/sale` or `/price-breaks`.

## Sale price

`PUT /api/v1/skus/{sku}/sale` — Needs commerce.write.products. Equivalent MCP tool: set_variant_sale.

`DELETE /api/v1/skus/{sku}/sale` — Needs commerce.write.products. Equivalent MCP tool: end_variant_sale.

Puts one variant on sale next to its regular price, which a sale never changes. Amounts are tax-inclusive cents; moments are ISO 8601 with a time and an offset (`2026-11-20T00:00:00+01:00` or `...Z`) and are answered in UTC.

*Body fields of PUT /skus/{sku}/sale*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `amount_minor` | integer | Required | Tax-inclusive sale price in cents, 1 to 100000000, below the regular price. |
| `starts_at` | string\|null | Optional | ISO 8601 with an offset. Omitted, `null` or in the past: now. The price history is never backdated. |
| `ends_at` | string\|null | Optional | ISO 8601 with an offset, exclusive, after the start. Omitted or `null`: no end. |

**Put a variant on sale for a week**

```bash
curl -sS -X PUT "https://<your-shop-domain>/api/v1/skus/HAM-500/sale" \
  -H "Authorization: Bearer $COMMERCE_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"amount_minor": 1195, "starts_at": "2026-11-20T00:00:00+01:00", "ends_at": "2026-11-27T00:00:00+01:00"}'
```

- A sale of the variant that overlaps the period is replaced: a running one ends when the new one starts, a planned one is deleted. The same sale again changes nothing.
- `DELETE` takes no body. It ends the running sale now (it stays in the price history) and deletes planned sales. Without a sale nothing changes.
- Both answer the variant: `sku`, `product_id`, `regular_price`, `sales` and `price_breaks` as `GET /skus/{sku}` shows them, `changed` and `changes`.
- No regular price, a sale price that is not below it, a period that ends before it starts or already ended, or a date that does not exist (30 February) is `422` `invalid_argument`. A sale is a price: on a PIM-linked product whose prices the PIM owns it is `422` `field_owned_by_pim`.
- A sale writes the price history that the optional "lowest price of the last 30 days" line reads. Whether that line is shown is the shop owner's setting and is not changed here.

## Quantity price breaks

`PUT /api/v1/skus/{sku}/price-breaks` — Needs commerce.write.products. Equivalent MCP tool: set_price_breaks.

*Body field of PUT /skus/{sku}/price-breaks*

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `price_breaks` | array | Required | The complete list of `{min_quantity, amount_minor}`: the tax-inclusive price per unit from that quantity on. May be empty. |

Replaces the quantity price breaks (staffelprijzen) of the variant; an empty list removes them all. At most 10 breaks, `min_quantity` 2 to 9999 and unique, every price above zero and below the regular price, and a larger quantity is never dearer per unit; otherwise `422` with the rule in the message. The answer is the same as for the sale. On a PIM-linked product whose prices the PIM owns it is `422` `field_owned_by_pim`.

**Set two price breaks**

```bash
curl -sS -X PUT "https://<your-shop-domain>/api/v1/skus/HAM-500/price-breaks" \
  -H "Authorization: Bearer $COMMERCE_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"price_breaks": [{"min_quantity": 5, "amount_minor": 1395}, {"min_quantity": 20, "amount_minor": 1295}]}'
```

## Errors

*Errors of the write endpoints*

| Status | error.code | When |
| --- | --- | --- |
| `401` | `unauthenticated` | Missing, malformed, unknown, revoked or expired token, or a token without the `api` channel. |
| `403` | `forbidden_scope` | The token does not have `commerce.write.products` (or `commerce.write.collections` where the page says so). The body is not checked first, and nothing changes. |
| `404` | `not_found` | No product, image or variant with that id or SKU, or a malformed id. A repeated image removal is also `404`. |
| `405` | `method_not_allowed` | Another method on a write path. `Allow` is `GET, HEAD, PATCH` on `/products/{id}`, `GET, HEAD, PUT` on `/skus/{sku}/price`, `/skus/{sku}/stock` and `/skus/{sku}/price-breaks`, and `GET, HEAD, PUT, DELETE` on `/skus/{sku}/sale`. |
| `409` | `conflict` | `PUT /skus/{sku}/stock` while an external inventory provider manages stock; `POST /products` in a shop that gets its products from the PIM; or the data changed during the call, or another change of the same data kept it locked. Read again and retry. |
| `422` | `invalid_argument` | The body is not a JSON object, is not sent as `application/json`, is larger than 65536 bytes (262144 for the page writes), repeats a URL parameter (`id` or `sku`), has an unknown field, no field at all, or a value of the wrong type or out of range. |
| `422` | `field_owned_by_pim` | The product is linked to the PIM and the field belongs to the PIM: name, description or brand, and the stock of a PIM product. Change it in the PIM. |
| `429` | `too_many_requests` | A rate limit was reached. The general limits send `Retry-After`. The write limit answers `Too many write requests. Try again in a minute.` and may come without `Retry-After`. The write did not happen. |
| `500` | `internal_error` | `An internal error occurred.` Safe to retry later. |

**Example error · 422 for a PIM-owned field**

```json
{
  "error": {
    "code": "field_owned_by_pim",
    "message": "The field [name] of this product is managed by the PIM and cannot be edited here."
  }
}
```

The message names the field; it is safe to show. Branch on `code`, not on the message.
