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: @barter/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
// 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:
{
  "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
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.
  • SDKlistProducts(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:
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"'
# 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.
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
# 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"'
# 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"'
  • SDKretrieveProduct(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).
  • SDKproducts.listSellingPlans(client, idOrHandle).
  • ComponentsPurchaseOptions (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.
# 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
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.
  • SDKlistProductVariants(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
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.
  • SDKretrieveProductVariant(client, variantId, query?).
  • Components — cart line renderer, option picker.
  • Settings — price lists.