# Products

Search products, get one product by id and look up a product by variant SKU, with sales, price breaks, SEO, labels and properties.

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

## Search products

`GET /api/v1/products` — Needs commerce.read. Equivalent MCP tool: search_products.

At least one of `query`, `collection` and `brand` is required.

*Query parameters of GET /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 `_` (`422`), also not written as HTML entities such as `&percnt;` or `&#37;`. 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. |

**Search products · curl**

```bash
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"
```

**Search products · JavaScript**

```javascript
// 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());
```

**Search products · PHP**

```php
<?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);
```

**Search products · Python**

```python
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.

> **Warning — 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.

**Example response · 200**

```json
{
    "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
    }
}
```

*Fields of meta*

| 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, `sku` is the matching variant's SKU.
- No stock is included: use [Availability](https://docs.omniretailconnect.nl/commerce/api/availability.md) or [Get a product](https://docs.omniretailconnect.nl/commerce/api/products.md#get-product).
- Walk pages with the same filters as the first request: see [pagination](https://docs.omniretailconnect.nl/commerce/getting-started/limits.md#pagination).

**Filter by collection and brand · curl**

```bash
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"
```

**Filter by collection and brand · JavaScript**

```javascript
// 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());
```

**Filter by collection and brand · PHP**

```php
<?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);
```

**Filter by collection and brand · Python**

```python
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

`GET /api/v1/products/{id}` — Needs commerce.read. Equivalent MCP tool: get_product.

*Path parameter of GET /products/{id}*

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

**Get a product · curl**

```bash
export COMMERCE_API_TOKEN='ocm_<token>'

curl -sS "https://<your-shop-domain>/api/v1/products/101" \
  -H "Authorization: Bearer $COMMERCE_API_TOKEN"
```

**Get a product · JavaScript**

```javascript
// 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());
```

**Get a product · PHP**

```php
<?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);
```

**Get a product · Python**

```python
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))
```

**Example response · 200**

```json
{
    "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": []
    }
}
```

*Fields of a product*

| 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

`GET /api/v1/skus/{sku}` — Needs commerce.read. Equivalent MCP tool: get_product.

*Path parameter of GET /skus/{sku}*

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

**Get a product by SKU · curl**

```bash
export COMMERCE_API_TOKEN='ocm_<token>'

curl -sS "https://<your-shop-domain>/api/v1/skus/HAM-500" \
  -H "Authorization: Bearer $COMMERCE_API_TOKEN"
```

**Get a product by SKU · JavaScript**

```javascript
// 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());
```

**Get a product by SKU · PHP**

```php
<?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);
```

**Get a product by SKU · Python**

```python
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

*Errors of the product endpoints*

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

**Example error · 404 for an unknown product or SKU**

```json
{
  "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`.

> **Note — 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.
