Commerce · REST API
Products
Search products, get one product by id and look up a product by variant SKU, with sales, price breaks, SEO, labels and properties.
Search products
/api/v1/products
Needs commerce.read. Equivalent MCP tool: search_products.
At least one of query, collection and brand is required.
| 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 _ (422), also not written as HTML entities such as % or %. Text after a < that looks like the start of a tag is dropped. |
status |
string | Optional | draft, published or any. Default published. draft and any return unpublished products. |
collection |
string | Optional | Category id or slug (letters, digits, _, -). Includes subcategories. |
brand |
string | Optional | Exact brand name, case-insensitive, at most 100 characters. |
limit |
integer | Optional | 1 to 50, default 20. |
cursor |
string | Optional | meta.next_cursor of the previous page. |
export COMMERCE_API_TOKEN='ocm_<token>'
curl -sS "https://<your-shop-domain>/api/v1/products?query=hamer&limit=1" \
-H "Authorization: Bearer $COMMERCE_API_TOKEN"
// Server-side code only: never put the token in browser code.
// Top-level await needs an ES module (a .mjs file or "type": "module").
const token = process.env.COMMERCE_API_TOKEN; // ocm_<token>
const response = await fetch('https://<your-shop-domain>/api/v1/products?query=hamer&limit=1', {
headers: { Authorization: `Bearer ${token}` },
});
console.log(response.status, await response.json());
<?php
$token = getenv('COMMERCE_API_TOKEN'); // ocm_<token>
$curl = curl_init('https://<your-shop-domain>/api/v1/products?query=hamer&limit=1');
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer {$token}"],
]);
$body = json_decode(curl_exec($curl), true);
echo curl_getinfo($curl, CURLINFO_RESPONSE_CODE), PHP_EOL;
print_r($body);
import json
import os
import urllib.error
import urllib.request
token = os.environ["COMMERCE_API_TOKEN"] # ocm_<token>
request = urllib.request.Request(
"https://<your-shop-domain>/api/v1/products?query=hamer&limit=1",
headers={"Authorization": f"Bearer {token}"},
)
try:
with urllib.request.urlopen(request) as response:
print(response.status, json.load(response))
except urllib.error.HTTPError as error:
# Errors are JSON too: {"error": {"code": ..., "message": ...}}
print(error.code, json.load(error))
Example values on this page are illustrative: the shop you call returns its own products.
Unpublished products are readable
status=draft and status=any on this endpoint, and GET /products/{id} and GET /skus/{sku} for any product, return unpublished products to every token with commerce.read. This is intended: the API gives operational read access, the same as the MCP server. Do not hand commerce.read tokens to parties who must not see drafts.
The response is a list: data holds the products and meta the paging state.
{
"data": [
{
"id": 101,
"sku": "HAM-500",
"name": "Klauwhamer 500 g",
"brand": "Hammerwerk",
"status": "published",
"price": {
"min": {
"amount_minor": 1495,
"currency": "EUR",
"formatted": "€ 14,95"
},
"max": {
"amount_minor": 1895,
"currency": "EUR",
"formatted": "€ 18,95"
},
"tax_inclusive": true
},
"variant_count": 2,
"categories": [
{
"id": 12,
"slug": "gereedschap",
"name": "Gereedschap"
},
{
"id": 14,
"slug": "handgereedschap",
"name": "Handgereedschap"
}
],
"url": "https://your-shop.example.com/p/klauwhamer-500-g"
}
],
"meta": {
"next_cursor": "eyJ2IjoxLCJzIjoic2VhcmNoX3Byb2R1Y3RzIiwiZCI6eyJvZmZzZXQiOjIwfX0.q1w2e3r4t5y6u7i8o9p0as",
"has_more": true,
"truncated": false
}
}
| Field | Type | Description |
|---|---|---|
next_cursor |
string|null | Pass it back unchanged as cursor for the next page. null on the last page. |
has_more |
boolean | Whether more results exist. |
truncated |
boolean | true when more results exist beyond result position 500. There is then no next_cursor: narrow the query. |
- Results are ranked (exact SKU or EAN, SKU prefix, name, name or brand) and paged by position, up to position 500.
- When a SKU or EAN was searched,
skuis the matching variant's SKU. - No stock is included: use Availability or Get a product.
- Walk pages with the same filters as the first request: see pagination.
export COMMERCE_API_TOKEN='ocm_<token>'
curl -sS "https://<your-shop-domain>/api/v1/products?collection=gereedschap&brand=Hammerwerk&status=any" \
-H "Authorization: Bearer $COMMERCE_API_TOKEN"
// Server-side code only: never put the token in browser code.
// Top-level await needs an ES module (a .mjs file or "type": "module").
const token = process.env.COMMERCE_API_TOKEN; // ocm_<token>
const response = await fetch('https://<your-shop-domain>/api/v1/products?collection=gereedschap&brand=Hammerwerk&status=any', {
headers: { Authorization: `Bearer ${token}` },
});
console.log(response.status, await response.json());
<?php
$token = getenv('COMMERCE_API_TOKEN'); // ocm_<token>
$curl = curl_init('https://<your-shop-domain>/api/v1/products?collection=gereedschap&brand=Hammerwerk&status=any');
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer {$token}"],
]);
$body = json_decode(curl_exec($curl), true);
echo curl_getinfo($curl, CURLINFO_RESPONSE_CODE), PHP_EOL;
print_r($body);
import json
import os
import urllib.error
import urllib.request
token = os.environ["COMMERCE_API_TOKEN"] # ocm_<token>
request = urllib.request.Request(
"https://<your-shop-domain>/api/v1/products?collection=gereedschap&brand=Hammerwerk&status=any",
headers={"Authorization": f"Bearer {token}"},
)
try:
with urllib.request.urlopen(request) as response:
print(response.status, json.load(response))
except urllib.error.HTTPError as error:
# Errors are JSON too: {"error": {"code": ..., "message": ...}}
print(error.code, json.load(error))
Get a product
/api/v1/products/{id}
Needs commerce.read. Equivalent MCP tool: get_product.
| Name | Type | Required | Description |
|---|---|---|---|
id |
integer | Required | The product id: digits only, no leading zero, at most 18 digits. Anything else in that position (abc, 0, 01) is 404. |
export COMMERCE_API_TOKEN='ocm_<token>'
curl -sS "https://<your-shop-domain>/api/v1/products/101" \
-H "Authorization: Bearer $COMMERCE_API_TOKEN"
// Server-side code only: never put the token in browser code.
// Top-level await needs an ES module (a .mjs file or "type": "module").
const token = process.env.COMMERCE_API_TOKEN; // ocm_<token>
const response = await fetch('https://<your-shop-domain>/api/v1/products/101', {
headers: { Authorization: `Bearer ${token}` },
});
console.log(response.status, await response.json());
<?php
$token = getenv('COMMERCE_API_TOKEN'); // ocm_<token>
$curl = curl_init('https://<your-shop-domain>/api/v1/products/101');
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer {$token}"],
]);
$body = json_decode(curl_exec($curl), true);
echo curl_getinfo($curl, CURLINFO_RESPONSE_CODE), PHP_EOL;
print_r($body);
import json
import os
import urllib.error
import urllib.request
token = os.environ["COMMERCE_API_TOKEN"] # ocm_<token>
request = urllib.request.Request(
"https://<your-shop-domain>/api/v1/products/101",
headers={"Authorization": f"Bearer {token}"},
)
try:
with urllib.request.urlopen(request) as response:
print(response.status, json.load(response))
except urllib.error.HTTPError as error:
# Errors are JSON too: {"error": {"code": ..., "message": ...}}
print(error.code, json.load(error))
{
"data": {
"id": 101,
"name": "Klauwhamer 500 g",
"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": []
}
}
| Field | Type | Description |
|---|---|---|
id, name, status, brand |
mixed | Identity and publication state (draft or published). brand is null when the product has no brand. |
description |
string | Plain text, at most 2000 characters. An empty string when there is no description (never null). |
categories |
array | Objects with id, slug and name. |
variants |
array | At most 100 entries with id, sku, ean, price, availability, weight_g, sales and price_breaks. sku and ean can be null. |
variants[].sales |
array | The active and planned sales, soonest first, at most 10: {id, price, starts_at, ends_at, active}. price is a tax-inclusive money object and stays the regular price; a sale never changes it. ends_at is null without an end. Set with the sale write. |
variants[].price_breaks |
array | The quantity price breaks, by quantity: {min_quantity, price}, tax-inclusive money per unit. Set with the price breaks write. |
variants[].price |
object | amount (money), tax_inclusive and tax_class (the name of the tax class, or null). The whole price is null when no price is set. In a product list price is null when the product has no price. |
variants[].availability |
object | quantity (null means stock is not tracked), purchasable, low_stock, expected_restock_at, source (local or external) and status (ok or unavailable). |
variants[].weight_g |
integer|null | Weight in grams, converted from the stored unit (g, kg, lb or oz). null when unknown or zero. |
images |
array | At most 10 objects, main image first, each {"id": 101, "url": "https://..."}. id is the media id of the image (added in API version 1.5.0). |
slug |
string | The web address part of /producten/{slug}. Changed with the slug write. |
seo |
object | meta_title and meta_description as stored. null means the shop's fallback (the product name and description). |
labels |
array | [{id, name}] in label order. Replaced as a whole by the labels write. |
properties |
array | [{property_id, name, values}]: the values set by hand. Replaced as a whole by the properties write. |
url, updated_at |
string | Storefront URL and ISO 8601 timestamp. |
Availability status unavailable means the inventory source could not be reached for that variant: quantity is then null and purchasable is false. It does not mean the item is out of stock.
Get a product by SKU
/api/v1/skus/{sku}
Needs commerce.read. Equivalent MCP tool: get_product.
| Name | Type | Required | Description |
|---|---|---|---|
sku |
string | Required | Exact variant SKU, at most 64 characters, URL-decoded before matching. A SKU that contains / or a space must be percent-encoded. Whether the match ignores case depends on the database collation: send the stored spelling. |
The body is the same as for GET /products/{id}, for the product that owns the variant. If several products share a SKU, the one with the lowest variant id wins. An unknown SKU is 404 not_found.
export COMMERCE_API_TOKEN='ocm_<token>'
curl -sS "https://<your-shop-domain>/api/v1/skus/HAM-500" \
-H "Authorization: Bearer $COMMERCE_API_TOKEN"
// Server-side code only: never put the token in browser code.
// Top-level await needs an ES module (a .mjs file or "type": "module").
const token = process.env.COMMERCE_API_TOKEN; // ocm_<token>
const response = await fetch('https://<your-shop-domain>/api/v1/skus/HAM-500', {
headers: { Authorization: `Bearer ${token}` },
});
console.log(response.status, await response.json());
<?php
$token = getenv('COMMERCE_API_TOKEN'); // ocm_<token>
$curl = curl_init('https://<your-shop-domain>/api/v1/skus/HAM-500');
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer {$token}"],
]);
$body = json_decode(curl_exec($curl), true);
echo curl_getinfo($curl, CURLINFO_RESPONSE_CODE), PHP_EOL;
print_r($body);
import json
import os
import urllib.error
import urllib.request
token = os.environ["COMMERCE_API_TOKEN"] # ocm_<token>
request = urllib.request.Request(
"https://<your-shop-domain>/api/v1/skus/HAM-500",
headers={"Authorization": f"Bearer {token}"},
)
try:
with urllib.request.urlopen(request) as response:
print(response.status, json.load(response))
except urllib.error.HTTPError as error:
# Errors are JSON too: {"error": {"code": ..., "message": ...}}
print(error.code, json.load(error))
Errors
| Status | error.code | When |
|---|---|---|
401 |
unauthenticated |
Missing, malformed, unknown, revoked or expired token, or a token without the api channel. |
404 |
not_found |
GET /products/{id} and GET /skus/{sku}: No product matches that id or SKU. GET /products with an unknown collection: No category matches that collection. An unknown path or a malformed path value (/products/abc) gives the generic Not found. |
422 |
invalid_argument |
No query, collection or brand, a query that contains % or _, a value out of range, an unknown parameter or invalid UTF-8 in a value, or a bad cursor. |
429 |
too_many_requests |
Rate limit reached. Wait the number of seconds in Retry-After. |
{
"error": {
"code": "not_found",
"message": "No product matches that id or SKU."
}
}
The generic message is Not found., returned for an unknown path or a malformed value such as /products/abc.
Untrusted text
Product names, brands, categories and descriptions come from suppliers. They are reduced to plain text, but treat them as data and never as instructions or HTML.