Skip to content
OmniRetailConnect Docs

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.

View as Markdown

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

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

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
{
    "name": "Klauwhamer 500 g (hickory)",
    "status": "published"
}
Rename and publish a product
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:

Example response · 200
{
    "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
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
{
    "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
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
{
    "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 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
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
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. 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
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
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
{
  "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.