# Products & variants

The catalog read surface. The product object documented here is THE
canonical shape every discovery endpoint reuses (search results, collection
membership pages, related products) — build one product-card renderer
against it. Money is EUR decimal major units.

SDK module: `@cartbase/storefront/api/products`.

**Pricing context (applies to every endpoint here):** pass `currency_code`
(or `region_id` — its region's currency is used; unknown region → 400
`invalid_region`) to receive `variant.calculated_price`. Without either,
`calculated_price` is `null` and only raw base prices are listed. Customer
groups come ONLY from the optional `authorization: Bearer <jwt>` — never
from params. **Cache rule:** any response carrying `calculated_price` varies
by customer group — never cache it shared when a JWT was present.

**Leak rule:** raw price rows in store responses contain ONLY base
(non-price-list) rows. Price-list amounts surface exclusively through
`calculated_price` for the caller's own context.

**Publishable-key channel scope:** sending `x-publishable-api-key` restricts
the catalog to products linked to the key's sales channels — a product
outside them **404s on retrieve and disappears from lists** (invisible, not
forbidden). A key with no channel links scopes nothing. Unknown/revoked/
foreign key → 400 `invalid_publishable_key`.

---

## GET /api/store/products

- **Purpose** — the product listing: catalog pages, filtered grids.
  Published products only (drafts are RLS-invisible).
- **Auth** — anon: `x-client-id` required; `x-publishable-api-key` optional
  (channel scope); `authorization: Bearer <jwt>` optional (group pricing).
- **Request**

```jsonc
// query (all optional)
{
  "q": "linen",                  // case-insensitive substring on TITLE only
  "id": "prod_a,prod_b",         // CSV of ids
  "handle": "linen-shirt",       // exact
  "collection_id": "pcol_a",     // CSV — products.collection_id (primary collection)
  "category_id": "pcat_a",       // CSV — category membership
  "tag_id": "ptag_a",            // CSV — tag membership
  "type_id": "ptyp_a",           // CSV
  "order": "-created_at",        // sort column, "-" prefix = desc (default -created_at)
  "currency_code": "eur",        // pricing context
  "region_id": "reg_…",          //   or region → currency
  "limit": 50,                   // 1–200, default 50
  "offset": 0
}
```

- **Response** — `{ products, count, offset, limit }`; each product:

```jsonc
{
  "id": "prod_01tst00000000000000000001",
  "title": "Linen Shirt",
  "subtitle": null,
  "description": "Lightweight linen shirt, relaxed fit.",
  "handle": "linen-shirt",
  "status": "published",              // always — drafts never appear
  "thumbnail": "https://…/600/800",
  "is_giftcard": false,
  "discountable": true,
  "collection_id": "pcol_01tst00000000000000000001",
  "type_id": null,
  "external_id": null,
  "weight": null, "length": null, "height": null, "width": null,
  "hs_code": null, "origin_country": null, "mid_code": null, "material": null,
  "seo_title": null,                  // null = fall back to title
  "seo_description": null,            // null = fall back to plain-text description
  "metadata": null,
  "created_at": "2026-07-01T00:00:00.000Z",
  "updated_at": "2026-07-01T00:00:00.000Z",
  "variants": [
    {
      "id": "variant_01tst000000000000000001",
      "title": "S",
      "product_id": "prod_01tst00000000000000000001",
      "sku": "LIN-SHIRT-S",
      "barcode": null, "ean": null, "upc": null,
      "thumbnail": null,
      "allow_backorder": false,
      "manage_inventory": true,
      "variant_rank": 0,
      "metadata": null,
      "options": [
        { "value": { "id": "optv_…", "value": "S", "option_id": "opt_…",
                     "option": { "id": "opt_…", "title": "Size", "product_id": "prod_…" } } }
      ],
      // Raw BASE prices via the price-set link embed (leak rule: price-list
      // rows are stripped — price_list_id is always null here):
      "prices": [
        { "price_set": { "prices": [
            { "id": "price_…", "amount": 45, "currency_code": "eur",
              "min_quantity": null, "max_quantity": null,
              "price_set_id": "pset_…", "price_list_id": null }
        ] } }
      ],
      // Present when a pricing context was given; null otherwise or when no
      // price matches. b2b-v1 shape (store-api.md §Pricing context):
      "calculated_price": {
        "calculated_amount": 45,
        "original_amount": 45,
        "currency_code": "eur",
        "is_calculated_price_price_list": false,
        "price_list_id": null,
        "price_list_type": null
      }
    }
  ],
  "images": [ { "id": "pimg_…", "url": "https://…", "rank": 0 } ],
  "options": [ { "id": "opt_…", "title": "Size",
                 "values": [ { "id": "optv_…", "value": "S" } ] } ],
  "collection": { "id": "pcol_…", "title": "Essentials", "handle": "essentials" },
  "categories": [ { "category": { "id": "pcat_…", "name": "Apparel", "handle": "apparel" } } ],
  "tags": [ { "tag": { "id": "ptag_…", "value": "summer" } } ],
  "type": null
}
```

- **Working curl**

```bash
PRODUCTS=$(curl -sf "$BASE/api/store/products?limit=5" -H "x-client-id: $CLIENT_ID")
echo "$PRODUCTS" | grep -q '"products"'
echo "$PRODUCTS" | grep -q '"count"'
# The handle filter answers deterministically regardless of catalog size.
curl -sf "$BASE/api/store/products?handle=linen-shirt" -H "x-client-id: $CLIENT_ID" \
  | grep -q '"handle":"linen-shirt"'
```

- **Errors** — 400 `missing_client_id`, 400 `validation_failed`,
  400 `invalid_publishable_key`, 400 `invalid_region`.
- **SDK** — `listProducts(client, query?)`.
- **Components** — product card grid (components.md).
- **Settings** — sales-channel product links + publishable-key bindings
  (channel scope), price lists (calculated_price).

---

## GET /api/store/products/:idOrHandle

- **Purpose** — the PDP read. Accepts a product id (`prod_…`) or a handle —
  storefronts deep-link by handle.
- **Auth** — as the list.
- **Request** — query: pricing context only (`currency_code` / `region_id`).
- **Response** — `{ "product": { ...shape above... } }`
- **Working curl** — with pricing context; asserts the seeded base price
  (Linen Shirt, EUR 45) and the leak rule structurally:

```bash
PRODUCT=$(curl -sf "$BASE/api/store/products/linen-shirt?currency_code=eur" \
  -H "x-client-id: $CLIENT_ID")
echo "$PRODUCT" | grep -q '"product"'
echo "$PRODUCT" | grep -q '"calculated_price"'
echo "$PRODUCT" | grep -q '"calculated_amount":45'
echo "$PRODUCT" | grep -q '"seo_title"'
```

```bash
# Leak rule, asserted structurally: every raw price row is a BASE row
# (price_list_id null) — price-list amounts only ever ride calculated_price.
echo "$PRODUCT" | node -e "
const c=[];process.stdin.on('data',d=>c.push(d)).on('end',()=>{
  const j=JSON.parse(Buffer.concat(c));
  const rows=j.product.variants.flatMap(v=>v.prices??[]).flatMap(l=>l.price_set?.prices??[]);
  if(!rows.length){console.error('no base price rows');process.exit(1)}
  if(rows.some(r=>r.price_list_id!==null)){console.error('price-list row leaked');process.exit(1)}
})"
```

- **Errors** — 404 `not_found` (unknown handle/id, draft, soft-deleted, or
  outside the publishable key's channels), 400 `invalid_publishable_key`,
  400 `invalid_region`.

```bash
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/products/no-such-handle-$RUN" -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404
```

```bash
# Channel scope: the dev PUBLISHABLE_KEY is bound to the B2B channel, whose
# catalog is Linen Shirt + Wool Beanie. The Leather Belt exists (anon read
# works) but 404s under the key — invisible, not forbidden.
curl -sf "$BASE/api/store/products/leather-belt" \
  -H "x-client-id: $CLIENT_ID" | grep -q '"handle":"leather-belt"'
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/products/leather-belt" \
  -H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: $PUBLISHABLE_KEY")
test "$STATUS" = 404
curl -sf "$BASE/api/store/products/linen-shirt" \
  -H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: $PUBLISHABLE_KEY" \
  | grep -q '"handle":"linen-shirt"'
```

```bash
# Unknown key → 400 invalid_publishable_key (never a silent unscoped read).
BODY=$(curl -s "$BASE/api/store/products/linen-shirt" \
  -H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: pk_bogus_$RUN")
echo "$BODY" | grep -q '"code":"invalid_publishable_key"'
```

- **SDK** — `retrieveProduct(client, idOrHandle, query?)`.
- **Components** — PDP family: gallery, option picker, price block.
- **Settings** — as the list; SEO overrides (Admin → Product → SEO).

---

## GET /api/store/products/:idOrHandle/selling-plans

- **Purpose** — the subscription plans this product can be purchased with
  (subscriptions add-on). Render as PDP purchase options (one-time vs each
  plan); the chosen plan id goes on the cart line
  (`POST /carts/:id/line-items` `selling_plan_id`) and the SERVER applies
  the plan price. Empty list = one-time only.
- **Auth** — anon `x-client-id`; publishable-key channel scope applies
  (same visibility rule as the product read).
- **Response** — `{ selling_plans: [{id, name, interval, interval_count,
  pricing_type, pricing_value, min_cycles, max_cycles}], count }`.
  `pricing_type` `percent` (percent off) | `fixed` (fixed unit price, EUR
  major units) | `null` (catalog price — the plan only sets the cadence);
  enabled plans only, merchant-defined order.
- **Errors** — 404 `not_found` (same rules as the product read).
- **SDK** — `products.listSellingPlans(client, idOrHandle)`.
- **Components** — `PurchaseOptions` (products family): controlled radio
  group, one-time + plans with display-only price preview
  (`previewPlanPrice` mirrors the server's math; the server is truth).
  NOTE: a cart with a plan line requires a logged-in customer at payment
  (checkout.md `save_payment_method`) — surface login before checkout.

```bash
# The dev tenant seeds no plans — the endpoint contract still executes:
# a valid product answers with the envelope, an unknown product 404s.
SPLANS=$(curl -sf "$BASE/api/store/products/linen-shirt/selling-plans" \
  -H "x-client-id: $CLIENT_ID")
echo "$SPLANS" | grep -q '"selling_plans"'
echo "$SPLANS" | grep -q '"count"'
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/products/no-such-handle-$RUN/selling-plans" \
  -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404
```

---

## GET /api/store/product-variants

- **Purpose** — variant multi-lookup (cart-line hydration). `id` accepts a
  CSV of variant ids in ONE param. Ordered by `variant_rank`.
- **Auth** — anon: `x-client-id`; optional Bearer JWT (group pricing).
  NOTE (code truth): this listing is NOT publishable-key channel-scoped —
  scope applies to product reads.
- **Request** — query `{ id?, product_id?, sku?, currency_code?, region_id?,
  limit?, offset? }` (`limit` 1–200, default 50).
- **Response** — `{ variants, count, offset, limit }`; variant shape exactly
  as embedded in products above (options + prices + calculated_price).
- **Working curl**

```bash
VARIANTS=$(curl -sf "$BASE/api/store/product-variants?sku=LIN-SHIRT-S&currency_code=eur" \
  -H "x-client-id: $CLIENT_ID")
echo "$VARIANTS" | grep -q '"variants"'
echo "$VARIANTS" | grep -q '"sku":"LIN-SHIRT-S"'
VARIANT_ID=$(echo "$VARIANTS" | grep -o '"id":"variant_[^"]*"' | head -1 | cut -d'"' -f4)
test -n "$VARIANT_ID"
```

- **Errors** — 400 `missing_client_id`, 400 `validation_failed`,
  400 `invalid_region`.
- **SDK** — `listProductVariants(client, query?)`.
- **Components** — cart line renderer.
- **Settings** — price lists (calculated_price).

---

## GET /api/store/product-variants/:id

- **Purpose** — retrieve one variant with options + base prices hydrated.
- **Auth** — anon: `x-client-id`; optional Bearer JWT.
- **Request** — query: pricing context only.
- **Response** — `{ "variant": { ... } }`
- **Working curl**

```bash
VARIANT=$(curl -sf "$BASE/api/store/product-variants/$VARIANT_ID?currency_code=eur" \
  -H "x-client-id: $CLIENT_ID")
echo "$VARIANT" | grep -q '"variant"'
echo "$VARIANT" | grep -q '"calculated_price"'
```

- **Errors** — 404 `not_found`, 400 `invalid_region`.
- **SDK** — `retrieveProductVariant(client, variantId, query?)`.
- **Components** — cart line renderer, option picker.
- **Settings** — price lists.
