# Products

The product endpoints return the authenticated tenant's active products. Both endpoints require a tenant API key with the `commerce:read` scope, sent as `Authorization: Bearer {api_key}`. Tenancy is derived from the key, so no `X-Tenant-Id` header is needed. Requests count against the shared tenant rate limit of 120 requests per minute; exceeding it returns `429 {"message":"Too Many Attempts."}` with `Retry-After` and `X-RateLimit-*` headers.

Only products with status `active` are ever returned. Draft, archived, and soft-deleted products are filtered from the list and return 404 on the detail endpoint.

## Product object

All IDs are UUIDs. All money amounts are integers in minor units (cents) paired with the product's ISO currency code (for example `"EUR"`, so `18900` means 189.00 EUR). Timestamps are ISO 8601 UTC.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (uuid) | always | Product ID. |
| type | string | always | One of `simple`, `variable`, `configurable`, `digital`, `license`, `subscription`. `simple`, `variable`, and `configurable` are stockable and shippable. |
| purchasability | string | always | One of `direct`, `quote`, `both`. `quote` means the product cannot be checked out directly; route the buyer to a quote request. |
| sku | string or null | always | Product-level SKU. |
| slug | string | always | URL slug, unique per tenant. |
| name | string | always | Product name. |
| description | string or null | always | Product description. |
| status | string | always | Always `active` on this API. |
| meta_title | string or null | always | SEO title. |
| meta_description | string or null | always | SEO description. |
| price | object | always | `{amount, currency}`. `amount` is the resolved unit price at quantity 1 for the request's customer context (guest when no `X-Customer-Token` header is sent). `amount` is `null` when the tenant hides prices from guests and the request is a guest request. |
| list_price_amount | integer | conditional | Original (list) price in minor units. Present only when it differs from the resolved price and the tenant shows original prices. |
| quantity_tiers | array | conditional | `[{min_quantity, price_amount}]` rows, present only when the tenant shows tier tables and product-scope tiers with `min_quantity > 1` exist. Customer-group rows shadow public rows at the same `min_quantity`. |
| price_display | object | conditional | `{mode}` where `mode` is `inc` or `ex` (VAT-inclusive or VAT-exclusive display). Resolved from the tenant's `vat_display` setting (`inc`, `ex`, or `auto`; `auto` resolves to `ex` for B2B customers identified via `X-Customer-Token`, `inc` otherwise). |
| attributes | object or array | always | Free-form attribute map set by the merchant. Serialized as `[]` when empty. |
| options | array | detail only | `[{name, values}]`, the product's option definitions (for example `{"name":"Color","values":["Natural","Black"]}`). Only included on `GET /v1/api/commerce/products/{id}`. |
| categories | array | always | Active categories the product belongs to: `[{id, name, slug, is_active}]`. |
| variants | array | always | Active variants, see the variant object below. Empty array when the product has no variants. |
| has_variants | boolean | always | Whether `variants` is non-empty. |
| price_from | object | always | `{amount, currency}`. The lowest resolved variant price, or the product price when there are no variants. `amount` is `null` for guests when the tenant hides guest prices. |
| image | object or null | always | Primary image (the media item flagged primary, or the first one), see the image object below. `null` when the product has no media. |
| images | array | detail only | Full gallery, each item is an image object plus `id`, `is_primary` (boolean), and `sort_order` (integer). Only included on `GET /v1/api/commerce/products/{id}`. |
| stock_status | string | conditional | One of `in_stock`, `low_stock`, `out_of_stock`, `backorder`, `untracked` (`untracked` means no stock rows exist). Omitted entirely when the stock module is not registered on the platform. |
| created_at | string (ISO 8601) | always | Creation timestamp. |
| updated_at | string (ISO 8601) | always | Last update timestamp. |

The client-panel field `stock_available` is never included on this API.

### Variant object

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (uuid) | always | Variant ID. |
| option_values | object | always | Map of option name to selected value, for example `{"Color":"Natural"}`. |
| sku | string or null | always | Variant SKU. |
| price | object | always | `{amount, currency}`. Falls back to the product price when the variant has no own price. `amount` is `null` for guests when the tenant hides guest prices. |
| is_active | boolean | always | Always `true` on this API (inactive variants are filtered out). |
| stock_status | string | conditional | Same enum as the product-level `stock_status`. Present only when the stock module is registered. |

### Image object

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| url | string | always | Original image URL. |
| variants | object | always | On-the-fly webp resize URLs keyed `thumb` (160px wide), `card` (480px wide), `detail` (1200px wide), served via wsrv.nl. |
| alt | string or null | always | Alt text. |

## GET /v1/api/commerce/products

Returns a paginated list of the tenant's active products, newest first (`created_at` descending, not configurable). Each product includes its primary image, active variants, active categories, stock status (when the stock module is registered), and customer-contextual pricing. The list does not include the `options` array or the `images` gallery; fetch the detail endpoint for those. The page size is fixed at 20; there is no `per_page`, sort, or search parameter.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| filter[category] | string | No | Category slug. Matches products in that active category or any of its active descendants. An unknown or inactive slug yields an empty result set (200, not 404). |
| page | integer | No | Page number, default 1. Page size is fixed at 20. |

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| X-Customer-Token | string | No | Storefront customer session token. Switches pricing to that customer's context (group pricing, B2B ex-VAT display, tier tables). Without it, pricing is computed for a guest. |

```bash title="Request"
curl "https://api.borkol.com/v1/api/commerce/products?filter%5Bcategory%5D=tables&page=1" \
  -H "Authorization: Bearer {api_key}" \
  -H "Accept: application/json"
```

```javascript title="Request (JS)"
const res = await fetch(
  "https://api.borkol.com/v1/api/commerce/products?" +
    new URLSearchParams({ "filter[category]": "tables", page: "1" }),
  {
    headers: {
      Authorization: `Bearer ${apiKey}`,
      Accept: "application/json",
    },
  }
);
const { data, links, meta } = await res.json();
```

```json title="Response"
{
  "data": [
    {
      "id": "9c2f1e6a-8b4d-4f2a-9e11-3d5a7b9c0e21",
      "type": "simple",
      "purchasability": "direct",
      "sku": "TABLE-OAK",
      "slug": "oak-table",
      "name": "Oak Table",
      "description": "Solid oak table.",
      "status": "active",
      "meta_title": null,
      "meta_description": null,
      "price": { "amount": 54900, "currency": "EUR" },
      "price_display": { "mode": "inc" },
      "attributes": [],
      "categories": [
        {
          "id": "7a1b3c5d-2e4f-4a6b-8c0d-1e2f3a4b5c6d",
          "name": "Tables",
          "slug": "tables",
          "is_active": true
        }
      ],
      "variants": [],
      "has_variants": false,
      "price_from": { "amount": 54900, "currency": "EUR" },
      "image": {
        "url": "https://cdn.borkol.com/t/9c2f/table.jpg",
        "variants": {
          "thumb": "https://wsrv.nl/?url=...&w=160&we=1&output=webp&q=80",
          "card": "https://wsrv.nl/?url=...&w=480&we=1&output=webp&q=80",
          "detail": "https://wsrv.nl/?url=...&w=1200&we=1&output=webp&q=80"
        },
        "alt": "Oak table"
      },
      "stock_status": "in_stock",
      "created_at": "2026-07-02T09:14:33.000000Z",
      "updated_at": "2026-08-01T15:20:11.000000Z"
    }
  ],
  "links": {
    "first": "https://api.borkol.com/v1/api/commerce/products?page=1",
    "last": "https://api.borkol.com/v1/api/commerce/products?page=3",
    "prev": null,
    "next": "https://api.borkol.com/v1/api/commerce/products?page=2"
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "last_page": 3,
    "links": [
      { "url": null, "label": "&laquo; Previous", "active": false },
      {
        "url": "https://api.borkol.com/v1/api/commerce/products?page=1",
        "label": "1",
        "active": true
      }
    ],
    "path": "https://api.borkol.com/v1/api/commerce/products",
    "per_page": 20,
    "to": 20,
    "total": 47
  }
}
```

The envelope is the standard Laravel resource paginator: a top-level `data` array plus `links` (`first`, `last`, `prev`, `next`, absolute URLs or `null`) and `meta` (`current_page`, `from`, `last_page`, `links`, `path`, `per_page`, `to`, `total`). `per_page` is always 20.

### Errors

| Status | Body | Cause |
| --- | --- | --- |
| 401 | `{"message":"Unauthenticated."}` | Missing or invalid API key. |
| 403 | `{"message":"Invalid ability provided."}` | Key lacks the `commerce:read` scope. |
| 403 | `{"error":"Module not enabled: commerce"}` | Tenant does not have the commerce module enabled. Note the `error` key instead of `message`. |
| 403 | `{"message":"Tenant suspended.","code":"tenant_suspended"}` | Tenant account is suspended. |
| 429 | `{"message":"Too Many Attempts."}` | Rate limit of 120 requests per minute exceeded. |

## GET /v1/api/commerce/products/{id}

Fetches one active product by UUID. Compared to the list endpoint, the detail response additionally includes the `options` array and the full `images` gallery (with `is_primary` and `sort_order`). Stock status and pricing are enriched the same way as on the list: guest pricing unless an `X-Customer-Token` header is present. Returns 404 for draft, archived, soft-deleted, or unknown products.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (uuid) | Yes | Product ID. |

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| X-Customer-Token | string | No | Storefront customer session token. Switches pricing to that customer's group and B2B context. |

```bash title="Request"
curl "https://api.borkol.com/v1/api/commerce/products/9c2f1e6a-8b4d-4f2a-9e11-3d5a7b9c0e21" \
  -H "Authorization: Bearer {api_key}" \
  -H "Accept: application/json"
```

```json title="Response"
{
  "data": {
    "id": "9c2f1e6a-8b4d-4f2a-9e11-3d5a7b9c0e21",
    "type": "variable",
    "purchasability": "direct",
    "sku": "CHAIR-OAK",
    "slug": "oak-dining-chair",
    "name": "Oak Dining Chair",
    "description": "Solid oak chair with linen seat.",
    "status": "active",
    "meta_title": "Oak Dining Chair",
    "meta_description": "Handmade solid oak dining chair.",
    "price": { "amount": 18900, "currency": "EUR" },
    "list_price_amount": 21900,
    "quantity_tiers": [
      { "min_quantity": 5, "price_amount": 17900 },
      { "min_quantity": 10, "price_amount": 16900 }
    ],
    "price_display": { "mode": "inc" },
    "attributes": { "material": "oak" },
    "options": [
      { "name": "Color", "values": ["Natural", "Black"] }
    ],
    "categories": [
      {
        "id": "7a1b3c5d-2e4f-4a6b-8c0d-1e2f3a4b5c6d",
        "name": "Chairs",
        "slug": "chairs",
        "is_active": true
      }
    ],
    "variants": [
      {
        "id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
        "option_values": { "Color": "Natural" },
        "sku": "CHAIR-OAK-NAT",
        "price": { "amount": 18900, "currency": "EUR" },
        "is_active": true,
        "stock_status": "in_stock"
      }
    ],
    "has_variants": true,
    "price_from": { "amount": 18900, "currency": "EUR" },
    "image": {
      "url": "https://cdn.borkol.com/t/9c2f/chair.jpg",
      "variants": {
        "thumb": "https://wsrv.nl/?url=...&w=160&we=1&output=webp&q=80",
        "card": "https://wsrv.nl/?url=...&w=480&we=1&output=webp&q=80",
        "detail": "https://wsrv.nl/?url=...&w=1200&we=1&output=webp&q=80"
      },
      "alt": "Oak dining chair front view"
    },
    "images": [
      {
        "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
        "url": "https://cdn.borkol.com/t/9c2f/chair.jpg",
        "variants": {
          "thumb": "https://wsrv.nl/?url=...&w=160&we=1&output=webp&q=80",
          "card": "https://wsrv.nl/?url=...&w=480&we=1&output=webp&q=80",
          "detail": "https://wsrv.nl/?url=...&w=1200&we=1&output=webp&q=80"
        },
        "alt": "Oak dining chair front view",
        "is_primary": true,
        "sort_order": 0
      }
    ],
    "stock_status": "in_stock",
    "created_at": "2026-07-02T09:14:33.000000Z",
    "updated_at": "2026-08-01T15:20:11.000000Z"
  }
}
```

### Errors

| Status | Body | Cause |
| --- | --- | --- |
| 401 | `{"message":"Unauthenticated."}` | Missing or invalid API key. |
| 403 | `{"message":"Invalid ability provided."}` | Key lacks the `commerce:read` scope. |
| 403 | `{"error":"Module not enabled: commerce"}` | Tenant does not have the commerce module enabled. |
| 403 | `{"message":"Tenant suspended.","code":"tenant_suspended"}` | Tenant account is suspended. |
| 404 | `{"message":"..."}` | Product does not exist, is soft-deleted, or its status is not `active`. |
| 429 | `{"message":"Too Many Attempts."}` | Rate limit of 120 requests per minute exceeded. |
