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-idrequired;x-publishable-api-keyoptional (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, 400validation_failed, 400invalid_publishable_key, 400invalid_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:
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), 400invalid_publishable_key, 400invalid_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"'- 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-itemsselling_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_typepercent(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 (previewPlanPricemirrors the server's math; the server is truth). NOTE: a cart with a plan line requires a logged-in customer at payment (checkout.mdsave_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" = 404GET /api/store/product-variants
- Purpose — variant multi-lookup (cart-line hydration).
idaccepts a CSV of variant ids in ONE param. Ordered byvariant_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? }(limit1–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¤cy_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, 400validation_failed, 400invalid_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
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, 400invalid_region. - SDK —
retrieveProductVariant(client, variantId, query?). - Components — cart line renderer, option picker.
- Settings — price lists.