Collections & membership listings

Curated product groupings (manual or smart). The membership listing — /collections/:id/products — is the collection page's data source: it reads the membership JOIN (multi-collection products appear in every collection they belong to), honors the collection's default_sort, and accepts a per-request order override. Money is EUR decimal major units.

SDK module: @barter/storefront/api/collections.

Channel scope (Shopify publish-to-channel semantics): a collection with sales-channel links is visible ONLY on those channels; a collection with no links is visible everywhere. Pass your channel as sales_channel_id — the list excludes scoped-away collections, and the membership listing 404s them.


GET /api/store/collections

  • Purpose — list collections (navigation, collection index pages).
  • Auth — anon: x-client-id required.
  • Request
// query (all optional)
{
  "q": "essen",                 // case-insensitive substring on title
  "handle": "essentials",       // exact — THE handle lookup (no /:handle route)
  "sales_channel_id": "sc_…",   // channel scope (see above)
  "limit": 50,                  // 1–200, default 50
  "offset": 0
}
  • Response{ collections, count, offset, limit }, ordered by title:
{
  "collections": [
    {
      "id": "pcol_01tst00000000000000000001",
      "title": "Essentials",
      "handle": "essentials",
      "type": "manual",             // "manual" | "smart"
      "description": null,
      "image_url": null,
      "default_sort": "manual",     // used by /products when no order override
      "conditions": [],             // smart-collection rules (admin-authored)
      "match": "all",               // smart matching: "all" | "any"
      "seo_title": null,            // null = fall back to title
      "seo_description": null,      // null = fall back to description
      "metadata": null,
      "created_at": "2026-07-01T00:00:00.000Z",
      "updated_at": "2026-07-01T00:00:00.000Z"
    }
  ],
  "count": 1, "offset": 0, "limit": 50
}
  • Working curl — the seeded catalog carries the essentials collection:
COLLECTIONS=$(curl -sf "$BASE/api/store/collections?handle=essentials" \
  -H "x-client-id: $CLIENT_ID")
echo "$COLLECTIONS" | grep -q '"collections"'
echo "$COLLECTIONS" | grep -q '"handle":"essentials"'
COL_ID=$(echo "$COLLECTIONS" | grep -o '"id":"pcol_[^"]*"' | head -1 | cut -d'"' -f4)
test -n "$COL_ID"
  • Errors — 400 missing_client_id, 400 validation_failed.
  • SDKlistCollections(client, query?).
  • Components — navigation, collection index grid.
  • Settings — collection channel links; smart-collection conditions (membership recomputes on rule/product change).

GET /api/store/collections/:id

  • Purpose — retrieve one collection (header/SEO block of a collection page). By-handle lookup goes through the list (?handle=).
  • Auth — anon: x-client-id required.
  • Request — no query. NOTE (code truth): the single read takes no sales_channel_id — channel scope applies to the list and the membership listing, not here.
  • Response{ "collection": { ...same shape as list rows... } }
  • Working curl
COLLECTION=$(curl -sf "$BASE/api/store/collections/$COL_ID" -H "x-client-id: $CLIENT_ID")
echo "$COLLECTION" | grep -q '"collection"'
echo "$COLLECTION" | grep -q '"default_sort"'
  • Errors — 404 not_found.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/collections/pcol_doesnotexist$RUN" -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404
  • SDKretrieveCollection(client, collectionId).
  • Components — collection page header.
  • Settings — SEO overrides.

GET /api/store/collections/:id/products

  • Purpose — the collection page's product grid: membership join, published products only, ordered by the collection's default_sort with an optional order override.
  • Auth — anon: x-client-id; optional Bearer JWT (group pricing).
  • Request
// query (all optional)
{
  "order": "price_asc",       // override: manual | title_asc | title_desc |
                              //   price_asc | price_desc | newest | oldest |
                              //   best_selling (90-day aggregate).
                              //   Unknown values are IGNORED (default_sort used).
  "sales_channel_id": "sc_…", // a collection scoped to OTHER channels 404s
  "currency_code": "eur",     // pricing context → calculated_price
  "region_id": "reg_…",
  "limit": 50,                // 1–100, default 50
  "offset": 0
}
  • Response{ products, count, offset, limit } — products carry the FULL canonical product shape (see products.md), incl. calculated_price when a pricing context is given. count is the visible membership size.
  • Working curl — seeded membership (backfilled from products.collection_id) contains the three fixture products:
MEMBERS=$(curl -sf "$BASE/api/store/collections/$COL_ID/products?currency_code=eur" \
  -H "x-client-id: $CLIENT_ID")
echo "$MEMBERS" | grep -q '"products"'
echo "$MEMBERS" | grep -q '"handle":"linen-shirt"'
echo "$MEMBERS" | grep -q '"calculated_price"'
# Sort override: price_asc puts the Wool Beanie (EUR 23) before the Linen
# Shirt (EUR 45) — asserted as RELATIVE order so unrelated rows can't break it.
curl -sf "$BASE/api/store/collections/$COL_ID/products?order=price_asc" \
  -H "x-client-id: $CLIENT_ID" | node -e "
const c=[];process.stdin.on('data',d=>c.push(d)).on('end',()=>{
  const j=JSON.parse(Buffer.concat(c));
  const h=j.products.map(p=>p.handle);
  const a=h.indexOf('wool-beanie'), b=h.indexOf('linen-shirt');
  if(a<0||b<0||a>b){console.error('price_asc order wrong: '+h.join(','));process.exit(1)}
})"
  • Errors — 404 not_found (unknown collection, or scoped away from the given sales_channel_id), 400 validation_failed, 400 invalid_region.
  • SDKlistCollectionProducts(client, collectionId, query?).
  • Components — product card grid + sort dropdown (emit the order values above).
  • Settings — collection default_sort + manual position order (drag-reorder in admin); price lists; channel links.