Commerce · REST API
Product writes
Create products and change content, SEO, web address, properties, labels, variants, price, sales, price breaks, stock and images with a write token.
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. Brands, labels, categories and featured products have their own page: Catalogue structure.
- Same as the MCP tools. Every endpoint runs the MCP write tool of the same name (
update_product,set_price,set_stock, and the tools in the catalogue write tools), with the same rules. Theirdataequals 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(acharsetparameter is allowed), at most 65536 bytes (262144 for the page writes). Unknown fields are rejected. - Result.
{"data": {...}}with the result pluschanged(falsewhen nothing differed) andchanges: 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) andPOST /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.
- PIM-managed data. On a product linked to the PIM, fields the PIM owns are refused with
422field_owned_by_pim, because the next import would overwrite them. Sending the value the product already has is accepted.
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.
Change product content
/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.
| 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. |
{
"name": "Klauwhamer 500 g (hickory)",
"status": "published"
}
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} returns it after the change, plus changed and changes:
{
"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
/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.
| Name | Type | Required | Description |
|---|---|---|---|
amount_minor |
integer | Required | Tax-inclusive price in cents, 0 to 100000000. For example 1595 for € 15,95. |
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}'
{
"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
/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.
| Name | Type | Required | Description |
|---|---|---|---|
quantity |
integer | Required | The new stock quantity, 0 to 1000000. |
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}'
{
"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
}
}
}
}
availabilityis the entry of GET /availability after the change.- Only while the shop's own stock is authoritative: with an external inventory provider the answer is
409conflict. - On a product linked to the PIM the answer is
422field_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
/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".
| 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. |
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
/api/v1/products/{id}/media
Needs commerce.write.products. Equivalent MCP tool: add_product_media.
/api/v1/products/{id}/media/{mediaId}
Needs commerce.write.products. Equivalent MCP tool: remove_product_media.
/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.
| 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:
httpsonly, a public host (no private, loopback or link-local address), no redirects, at most 10 MB, JPEG, PNG, WebP or GIF. Every refusal is422invalid_argument. - The result of all three is
product_id,media_id(the new, removed or main image),images([{id, url, alt, primary}]),changedandchanges. POSTadds an image on every call. Removing an image the PIM manages is422field_owned_by_pim; a repeated remove is404. Making the main image the main image again changes nothing. The two bodiless endpoints accept an empty JSON object.
Change SEO and web address
/api/v1/products/{id}/seo
Needs commerce.write.products. Equivalent MCP tool: update_product_seo.
/api/v1/products/{id}/slug
Needs commerce.write.products. Equivalent MCP tool: change_product_slug.
| 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. |
| 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),changedandchanges. The slug result isproduct_id,slug,url,changedandchanges; for a published productchanges.redirectholds 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 answers409.
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
/api/v1/products/{id}/properties
Needs commerce.write.products. Equivalent MCP tool: set_product_properties.
/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).
| 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. 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 is422. Allowed on products linked to the PIM. Result:product_id,labels([{id, name}]),changed,changes.
Change the EAN or tax class of a variant
/api/v1/skus/{sku}
Needs commerce.write.products. Equivalent MCP tool: update_variant.
| 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
/api/v1/skus/{sku}/sale
Needs commerce.write.products. Equivalent MCP tool: set_variant_sale.
/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.
| 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. |
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.
DELETEtakes 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,salesandprice_breaksasGET /skus/{sku}shows them,changedandchanges. - 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
422invalid_argument. A sale is a price: on a PIM-linked product whose prices the PIM owns it is422field_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
/api/v1/skus/{sku}/price-breaks
Needs commerce.write.products. Equivalent MCP tool: set_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.
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
| 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. |
{
"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.