> ## Documentation Index
> Fetch the complete documentation index at: https://spreecommerce.org/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Catalogs

> Showing different products and prices to different audiences — a wholesale range, a channel-specific selection, or one company's negotiated pricing.

## Overview

Not every shopper should see the same store. A wholesale buyer gets trade prices and a range retail never sees. A negotiated account has its own agreed pricing. A retail till carries a subset of what the website does.

A catalog answers both halves of that at once: **what an audience sees**, and **what they pay**.

```mermaid theme={"theme":"night-owl"}
flowchart LR
    Catalog --> Assortment["Assortment<br/><i>which products</i>"]
    Catalog --> PriceList["Price list<br/><i>what they cost</i>"]
    Catalog --> Audience["Assignments<br/><i>who gets it</i>"]
```

```mermaid theme={"theme":"night-owl"}
erDiagram
    Catalog ||--o{ CatalogProduct : "assortment"
    Catalog ||--o{ CatalogAssignment : "audiences"
    Catalog ||--o| PriceList : "owns (optional)"
    Catalog ||--o{ CatalogQuantityRule : "per-SKU terms"
    Catalog ||--o{ CatalogOrderMinimum : "cart minimum"
    CatalogAssignment }o--|| CustomerGroup : "one of"
    CatalogAssignment }o--|| Company : "one of"

    Catalog {
        string name
        boolean active
        integer position
        integer minimum_order_quantity
        integer order_multiple
    }
```

## The two modes

The single most useful thing to understand about catalogs is that an **empty assortment means something different from a full one** — it's the switch between the two ways a catalog is used.

<CardGroup cols={2}>
  <Card title="Pricing overlay" icon="tag">
    **Assortment empty.** Nothing is hidden — the audience browses the normal store — but the attached price list applies.

    This is how "this company sees everything, just at their negotiated prices" is expressed.
  </Card>

  <Card title="Restricted range" icon="filter">
    **Assortment has products.** The audience sees *only* what's in it.

    This is how a wholesale-only range, or a channel-specific selection, is expressed.
  </Card>
</CardGroup>

That's worth pausing on, because it's the one behaviour that surprises people: adding a product to an empty catalog doesn't add one item to what a customer sees — it switches the catalog into restricting mode, and they now see *only* that item.

## Creating one

<CodeGroup>
  ```typescript Admin SDK theme={"theme":"night-owl"}
  const catalog = await adminClient.catalogs.create({
    name: 'Wholesale',
    price_list: { name: 'Wholesale pricing' },
  })

  await adminClient.catalogs.activate(catalog.id)
  ```

  ```bash cURL theme={"theme":"night-owl"}
  # Create the agreement with its pricing inline
  curl -X POST 'https://api.mystore.com/api/v3/admin/catalogs' \
    -H 'X-Spree-API-Key: sk_xxx' \
    -H 'Content-Type: application/json' \
    -d '{ "name": "Wholesale", "price_list": { "name": "Wholesale pricing" } }'

  # Catalogs are born inactive — going live is its own act
  curl -X PATCH 'https://api.mystore.com/api/v3/admin/catalogs/cat_xxx/activate' \
    -H 'X-Spree-API-Key: sk_xxx'
  ```
</CodeGroup>

The price list is optional. A catalog with an assortment and no price list restricts the range at normal prices; a catalog with a price list and no assortment adjusts prices without hiding anything.

A price list is either **standalone** (matched by its own rules) or **owned by exactly one catalog**. A catalog creates the list it prices through, so the two are configured together on the catalog page — an owned list never appears in the price-lists index, since it has no rules or audience of its own.

Removing a catalog's pricing (`price_list: null`), or deleting the catalog itself, **deletes the owned list** rather than releasing it. A released list would match by its own rules, and an owned list has none — so it would begin pricing every shopper in the store. It is a soft delete, so the prices stay recoverable.

### Filling the assortment

<CodeGroup>
  ```typescript Admin SDK theme={"theme":"night-owl"}
  // Add products (a bare array of IDs)
  await adminClient.catalogs.products.create(catalog.id, ['prod_xxx', 'prod_yyy'])

  const { data: products } = await adminClient.catalogs.products.list(catalog.id)

  await adminClient.catalogs.products.delete(catalog.id, 'prod_xxx')
  ```

  ```bash cURL theme={"theme":"night-owl"}
  # Add products to the assortment
  curl -X POST 'https://api.mystore.com/api/v3/admin/catalogs/cat_xxx/products' \
    -H 'X-Spree-API-Key: sk_xxx' \
    -H 'Content-Type: application/json' \
    -d '{ "product_ids": ["prod_xxx", "prod_yyy"] }'

  # List them
  curl 'https://api.mystore.com/api/v3/admin/catalogs/cat_xxx/products' \
    -H 'X-Spree-API-Key: sk_xxx'
  ```
</CodeGroup>

Membership is all a catalog holds — it decides *what* a buyer sees, never the order they see it in. Presentation order stays with categories and collections.

When a catalog should restrict to exactly what its price list covers, there's a shortcut that copies those products in rather than making someone add them by hand:

<CodeGroup>
  ```typescript Admin SDK theme={"theme":"night-owl"}
  const { added_count } = await adminClient.catalogs.importProducts(catalog.id)
  ```

  ```bash cURL theme={"theme":"night-owl"}
  curl -X POST 'https://api.mystore.com/api/v3/admin/catalogs/cat_xxx/import_products' \
    -H 'X-Spree-API-Key: sk_xxx'
  ```
</CodeGroup>

## Choosing the audience

A catalog is assigned to a buyer audience:

| Assign to                                                | Meaning                                               |
| -------------------------------------------------------- | ----------------------------------------------------- |
| **[Customer group](/docs/developer/core-concepts/customers)** | A segment — wholesale accounts, staff, a loyalty tier |
| **[Company](/docs/developer/core-concepts/companies)**        | One B2B organization                                  |

A [Channel](/docs/developer/core-concepts/channels) is not assigned — it names its catalog directly via `default_catalog_id`, which applies to everyone buying through it when nothing narrower does.

<CodeGroup>
  ```typescript Admin SDK theme={"theme":"night-owl"}
  await adminClient.catalogs.assign(catalog.id, {
    assignable_type: 'customer_group',
    assignable_id: 'cgrp_xxx',
  })
  ```

  ```bash cURL theme={"theme":"night-owl"}
  curl -X POST 'https://api.mystore.com/api/v3/admin/catalogs/cat_xxx/assign' \
    -H 'X-Spree-API-Key: sk_xxx' \
    -H 'Content-Type: application/json' \
    -d '{ "assignable_type": "customer_group", "assignable_id": "cgrp_xxx" }'
  ```
</CodeGroup>

<Note>
  **A company assignment covers the whole subtree.** Assign the group-wide catalog once at the root of a [company tree](/docs/developer/core-concepts/companies) and every division below inherits it — no re-assigning per branch. A branch can still add its own catalog on top.
</Note>

## What a shopper ends up seeing

The three ways of reaching a buyer are tried in turn, and the first one that finds a catalog answers on its own. Once that's settled, everything it found combines:

<Steps>
  <Step title="Find the catalogs that apply">
    For a company buyer, that's the catalogs on their node **and its ancestors**. Otherwise their customer group's catalogs. Otherwise the channel's default catalog, if it has one.
  </Step>

  <Step title="Combine the assortments">
    The shopper sees the union of what those catalogs contain — so a division's extra catalog adds to the group's range rather than replacing it.
  </Step>

  <Step title="Any empty assortment lifts the restriction">
    If one applicable catalog is a pricing overlay, the restriction is off and the shopper sees the full range. An overlay is explicitly "don't hide anything", and that has to win — otherwise adding negotiated pricing would accidentally narrow someone's catalog.
  </Step>
</Steps>

Gated storefront access is checked before any of this — a shopper who has to sign in never reaches catalog resolution.

<Warning>
  **A company buyer never picks up their customer group's catalogs.** As soon as any catalog is assigned to their company or one above it, that is their agreement, and group assignments are not consulted for them.

  So don't express trade tiers as customer groups over a company tree — the tier catalogs would be unreachable for exactly the buyers they were meant for. Model tiers as company assignments: the group-wide range on the root, and each tier's catalog on the member companies or divisions in that tier. One tier catalog can carry as many company assignments as the tier has members, and nearest-first pricing means a buyer's own node beats anything inherited.

  Customer group assignments are for buyers who aren't purchasing for a company at all — a retail loyalty tier, a staff discount.
</Warning>

## How pricing resolves

Prices are checked in order, and the first match wins:

1. Price lists attached to the applicable catalogs, **nearest first** — the buyer's own company node before its parent's
2. Ordinary price lists whose rules match
3. The product's base price

Nearest-first is what lets a subsidiary hold a better-negotiated rate than the group's, without disturbing anyone else. A nearer node answers even when a catalog further up happens to be cheaper — it's the agreement that buyer is on.

<Note>
  **When one company holds several catalogs, the buyer pays the best price among them.** Nothing about two assignments on the same node says which of them a company is on, so neither outranks the other and the cheapest applicable price wins. The order catalogs appear in on the catalogs screen is display order — it never decides money.

  Quantity breaks are part of that comparison: the price compared is the one for the quantity being bought, so a catalog with a deep break at 24 units wins from 24 up and may lose below it.
</Note>

<Warning>
  A price list owned by a catalog applies **because the catalog applies** — its own rules are not consulted, and it's excluded from ordinary rule matching.

  That exclusion is load-bearing: a price list with no rules would otherwise match everyone, and one company's negotiated pricing would leak to the entire storefront. It also means a deactivated catalog's list goes **dormant** — turning the catalog off never releases its pricing to the whole store. Nothing puts an owned list back into rule matching: removing the pricing deletes the list instead, for the same reason.
</Warning>

## Related

* [Companies](/docs/developer/core-concepts/companies) — B2B buyers and the subtree rule
* [Pricing](/docs/developer/core-concepts/pricing) — price lists and rules
* [Products](/docs/developer/core-concepts/products) — the catalog being narrowed
* [Channels](/docs/developer/core-concepts/channels) — per-channel default catalogs
