# Producten

De product-endpoints geven de actieve producten van de geauthenticeerde tenant terug. Beide endpoints vereisen een tenant-API-key met de scope `catalog:read` of `commerce:read`, meegestuurd als `Authorization: Bearer {api_key}`, en de catalog-module moet voor de winkel ingeschakeld zijn. Tenancy wordt afgeleid uit de key, dus een `X-Tenant-Id`-header is niet nodig. Requests tellen mee voor de gedeelde tenant-rate-limit van 120 requests per minuut; overschrijding geeft `429 {"message":"Too Many Attempts."}` terug met `Retry-After`- en `X-RateLimit-*`-headers.

Alleen producten met status `active` worden ooit teruggegeven. Concept-, gearchiveerde en soft-deleted producten worden uit de lijst gefilterd en geven 404 terug op het detail-endpoint.

## Productobject

Alle ID's zijn UUID's. Alle geldbedragen zijn integers in minor units (centen), gekoppeld aan de ISO-valutacode van het product (bijvoorbeeld `"EUR"`, dus `18900` betekent 189.00 EUR). Timestamps zijn ISO 8601 UTC.

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| id | string (uuid) | altijd | Product-ID. |
| capability | string | altijd | Hoe het product verkocht wordt: `standard` (een gewoon product of een product met varianten, in de cart gezet met `product_id` en, bij varianten, `variant_id`) of `made_to_measure` (geprijsd door de configurator, in de cart gezet met een `configuration`). Behandel de enum als open en toon een onbekende waarde neutraal. |
| capability_active | boolean | altijd | Of de capability van het product nu verkocht kan worden: `standard` heeft de commerce-module nodig, `made_to_measure` de configurator-module. Bij `false` weigert een cart-add het product met `CAPABILITY_INACTIVE`; verberg de koopknop. |
| purchasability | string | altijd | Eén van `direct`, `quote`, `both`; `direct` wanneer de merchant niets heeft ingesteld. `quote` betekent dat het product niet direct afgerekend kan worden; leid de koper naar een offerteaanvraag. Het veld wordt ook gerapporteerd wanneer de winkel de sales-module uit heeft staan, maar alleen een winkel met sales ingeschakeld (en offerteaanvragen aan) weigert er de checkout voor. |
| unit | string | altijd | Eenheid waarin het product verkocht wordt: `piece`, `kg`, `g`, `l`, `m`, `m2`, `m3` of `hour`. Een `piece`-product accepteert alleen hele cart-hoeveelheden; elke andere eenheid accepteert tot drie decimalen (bijvoorbeeld `0.75` kg). |
| sku | string of null | altijd | SKU op productniveau. |
| slug | string | altijd | URL-slug, uniek per tenant. |
| name | string | altijd | Productnaam. |
| description | string of null | altijd | Productbeschrijving. |
| status | string | altijd | Altijd `active` op deze API. |
| meta_title | string of null | altijd | SEO-titel. |
| meta_description | string of null | altijd | SEO-beschrijving. |
| compare_at_amount | integer of null | altijd | Door de merchant ingestelde vergelijkingsprijs (van-prijs) in minor units, altijd hoger dan de productprijs. `null` wanneer niet ingesteld. Los van `list_price_amount`, dat de prijs vóór klantgroep- of staffelprijzen rapporteert. |
| barcode | string of null | altijd | Barcode (EAN, UPC of een eigen code van de merchant). |
| weight_grams | integer of null | altijd | Verzendgewicht in grammen. |
| requires_shipping | boolean | altijd | Of het product fysiek verzonden wordt. Een cart heeft bij de checkout alleen een verzendmethode nodig wanneer er minstens één regel in zit waarvan het product verzending vereist. |
| track_stock | boolean | altijd | Of de winkel voorraad bijhoudt voor dit product. Bij `false` is `stock_status` `untracked` en raakt het product nooit uitverkocht. |
| returnable | boolean | altijd | Of de merchant retouren accepteert. `made_to_measure`-producten worden op bestelling gemaakt en zijn na bestelling nooit retourneerbaar, wat deze vlag ook zegt (zie `attributes.returnable` op de orderregel). |
| lead_time_days | integer of null | altijd | Productie- of levertijd in dagen, 0 tot 365. `null` wanneer niet ingesteld. |
| tags | array van string | altijd | Tags van de merchant. Lege array wanneer er geen zijn. |
| social_image | object of null | altijd | Afbeelding voor social sharing (Open Graph), zie het afbeeldingsobject hieronder, plus de `id`. `null` wanneer niet ingesteld; val dan terug op `image`. |
| price | object | altijd | `{amount, currency}`. `amount` is de opgeloste stuksprijs bij hoeveelheid 1 voor de klantcontext van de request (gast wanneer geen `X-Customer-Token`-header wordt meegestuurd). `amount` is `null` wanneer de tenant prijzen voor gasten verbergt en de request een gastrequest is. |
| list_price_amount | integer | conditioneel | Oorspronkelijke (list)prijs in minor units. Alleen aanwezig wanneer hij afwijkt van de opgeloste prijs en de tenant oorspronkelijke prijzen toont. |
| quantity_tiers | array | conditioneel | `[{min_quantity, price_amount}]`-rijen, alleen aanwezig wanneer de tenant staffeltabellen toont en er product-scope staffels met `min_quantity > 1` bestaan. Klantgroeprijen overschaduwen publieke rijen bij dezelfde `min_quantity`. |
| price_display | object | conditioneel | `{mode}` waarbij `mode` `inc` of `ex` is (weergave inclusief of exclusief btw). Opgelost uit de `vat_display`-instelling van de tenant (`inc`, `ex` of `auto`; `auto` lost op naar `ex` voor B2B-klanten geïdentificeerd via `X-Customer-Token`, anders `inc`). |
| attributes | object of array | altijd | Vrije-vorm attributenmap ingesteld door de merchant. Geserialiseerd als `[]` wanneer leeg. |
| options | array | alleen detail | `[{name, values}]`, de optiedefinities van het product (bijvoorbeeld `{"name":"Color","values":["Natural","Black"]}`). Alleen opgenomen op `GET /v1/api/commerce/products/{id}`. |
| categories | array | altijd | Actieve categorieën waar het product bij hoort: `[{id, name, slug, is_active}]`. |
| variants | array | altijd | Actieve varianten, zie het variantobject hieronder. Lege array wanneer het product geen varianten heeft. |
| has_variants | boolean | altijd | Of `variants` niet-leeg is. |
| price_from | object | altijd | `{amount, currency}`. De laagste opgeloste variantprijs, of de productprijs wanneer er geen varianten zijn. `amount` is `null` voor gasten wanneer de tenant gastprijzen verbergt. |
| image | object of null | altijd | Primaire afbeelding (het media-item dat als primair is gemarkeerd, of het eerste), zie het afbeeldingsobject hieronder. `null` wanneer het product geen media heeft. |
| images | array | alleen detail | Volledige galerij, elk item is een afbeeldingsobject plus `id`, `is_primary` (boolean) en `sort_order` (integer). Alleen opgenomen op `GET /v1/api/commerce/products/{id}`. |
| stock_status | string | conditioneel | Eén van `in_stock`, `low_stock`, `out_of_stock`, `backorder`, `untracked`. `untracked` betekent dat de winkel dit product niet telt: `track_stock` is `false`, er bestaan geen voorraadrijen, of de winkel heeft de stock-module uitgeschakeld. Volledig weggelaten wanneer de stock-module niet op het platform is geregistreerd. |
| created_at | string (ISO 8601) | altijd | Aanmaaktimestamp. |
| updated_at | string (ISO 8601) | altijd | Timestamp van laatste wijziging. |

Het client-panelveld `stock_available` wordt nooit opgenomen op deze API.

### Variantobject

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| id | string (uuid) | altijd | Variant-ID. |
| option_values | object | altijd | Map van optienaam naar gekozen waarde, bijvoorbeeld `{"Color":"Natural"}`. |
| sku | string of null | altijd | Variant-SKU. |
| barcode | string of null | altijd | Barcode van de variant. |
| weight_grams | integer of null | altijd | Verzendgewicht van de variant in grammen; `null` betekent dat de `weight_grams` van het product geldt. |
| price | object | altijd | `{amount, currency}`. Valt terug op de productprijs wanneer de variant geen eigen prijs heeft. `amount` is `null` voor gasten wanneer de tenant gastprijzen verbergt. |
| is_active | boolean | altijd | Altijd `true` op deze API (inactieve varianten worden weggefilterd). |
| image_id | string (uuid) of null | altijd | De eigen afbeelding van de variant: de `id` van een van de media-items van het product, dus die komt overeen met een item in `images` op het detail-endpoint. Toon hem als een klant deze variant kiest. `null` als de variant geen eigen afbeelding heeft; val dan terug op de `image` van het product. |
| image | object of null | altijd | De eigen afbeelding van de variant als afbeeldingsobject (zie hieronder), zonder `id`. `null` als de variant geen eigen afbeelding heeft. |
| stock_status | string | conditioneel | Zelfde enum als de `stock_status` op productniveau; elke variant van een product met `track_stock` `false` is `untracked`. Alleen aanwezig wanneer de stock-module is geregistreerd. |

### Afbeeldingsobject

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| url | string | altijd | URL van de originele afbeelding. |
| variants | object | altijd | On-the-fly webp-resize-URL's met de keys `thumb` (160px breed), `card` (480px breed), `detail` (1200px breed), geserveerd via wsrv.nl. |
| alt | string of null | altijd | Alt-tekst. |

## GET /v1/api/commerce/products

Geeft een gepagineerde lijst terug van de actieve producten van de tenant, nieuwste eerst (`created_at` aflopend, niet instelbaar). Elk product bevat zijn primaire afbeelding, actieve varianten, actieve categorieën, voorraadstatus (wanneer de stock-module is geregistreerd) en klantcontextuele prijzen. De lijst bevat niet de `options`-array of de `images`-galerij; haal daarvoor het detail-endpoint op. De paginagrootte staat vast op 20; er is geen `per_page`-, sorteer- of zoekparameter.

### Queryparameters

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| filter[category] | string | Nee | Categorieslug. Matcht producten in die actieve categorie of in elk van haar actieve afstammelingen. Een onbekende of inactieve slug levert een lege resultaatset op (200, geen 404). |
| page | integer | Nee | Paginanummer, standaard 1. Paginagrootte staat vast op 20. |

### Headers

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| X-Customer-Token | string | Nee | Storefront-klantsessietoken. Schakelt de prijsberekening naar de context van die klant (groepsprijzen, B2B ex-btw-weergave, staffeltabellen). Zonder dit token wordt de prijs voor een gast berekend. |

```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",
      "capability": "standard",
      "capability_active": true,
      "purchasability": "direct",
      "unit": "piece",
      "sku": "TABLE-OAK",
      "slug": "oak-table",
      "name": "Oak Table",
      "description": "Solid oak table.",
      "status": "active",
      "meta_title": null,
      "meta_description": null,
      "compare_at_amount": null,
      "barcode": null,
      "weight_grams": 32000,
      "requires_shipping": true,
      "track_stock": true,
      "returnable": true,
      "lead_time_days": null,
      "tags": [],
      "social_image": 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
  }
}
```

De envelope is de standaard Laravel resource-paginator: een top-level `data`-array plus `links` (`first`, `last`, `prev`, `next`, absolute URL's of `null`) en `meta` (`current_page`, `from`, `last_page`, `links`, `path`, `per_page`, `to`, `total`). `per_page` is altijd 20.

### Errors

| Status | Body | Oorzaak |
| --- | --- | --- |
| 401 | `{"message":"Unauthenticated."}` | Ontbrekende of ongeldige API-key. |
| 403 | `{"message":"Invalid ability provided."}` | Key heeft noch de scope `catalog:read` noch `commerce:read`. |
| 403 | `{"error":"Module not enabled: catalog"}` | Tenant heeft de catalog-module niet ingeschakeld. Let op de `error`-key in plaats van `message`. |
| 403 | `{"message":"Tenant suspended.","code":"tenant_suspended"}` | Tenant-account is geschorst. |
| 429 | `{"message":"Too Many Attempts."}` | Rate limit van 120 requests per minuut overschreden. |

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

Haalt één actief product op via UUID. Vergeleken met het lijst-endpoint bevat de detailresponse daarnaast de `options`-array en de volledige `images`-galerij (met `is_primary` en `sort_order`). Voorraadstatus en prijzen worden op dezelfde manier verrijkt als op de lijst: gastprijzen tenzij een `X-Customer-Token`-header aanwezig is. Geeft 404 terug voor concept-, gearchiveerde, soft-deleted of onbekende producten.

### Padparameters

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| id | string (uuid) | Ja | Product-ID. |

### Headers

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| X-Customer-Token | string | Nee | Storefront-klantsessietoken. Schakelt de prijsberekening naar de groep en B2B-context van die klant. |

```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",
    "capability": "standard",
    "capability_active": true,
    "purchasability": "direct",
    "unit": "piece",
    "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.",
    "compare_at_amount": 22900,
    "barcode": "8712345678906",
    "weight_grams": 6500,
    "requires_shipping": true,
    "track_stock": true,
    "returnable": true,
    "lead_time_days": 5,
    "tags": ["oak", "dining"],
    "social_image": {
      "id": "3c4d5e6f-7a8b-4c9d-0e1f-2a3b4c5d6e7f",
      "url": "https://cdn.borkol.com/t/9c2f/chair-social.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 in a dining room"
    },
    "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",
        "barcode": "8712345678913",
        "weight_grams": null,
        "price": { "amount": 18900, "currency": "EUR" },
        "is_active": true,
        "image_id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
        "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"
        },
        "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 | Oorzaak |
| --- | --- | --- |
| 401 | `{"message":"Unauthenticated."}` | Ontbrekende of ongeldige API-key. |
| 403 | `{"message":"Invalid ability provided."}` | Key heeft noch de scope `catalog:read` noch `commerce:read`. |
| 403 | `{"error":"Module not enabled: catalog"}` | Tenant heeft de catalog-module niet ingeschakeld. |
| 403 | `{"message":"Tenant suspended.","code":"tenant_suspended"}` | Tenant-account is geschorst. |
| 404 | `{"message":"..."}` | Product bestaat niet, is soft-deleted, of zijn status is niet `active`. |
| 429 | `{"message":"Too Many Attempts."}` | Rate limit van 120 requests per minuut overschreden. |
