# Catalogue

URL: /docs/concepts/catalogue

Products, variants, options, collections, images and prices, and how to filter, sort, include and page through the product list.

The catalogue endpoints give your storefront everything it needs to render listing and product pages. They are read-only, and only published products are ever returned.

## Endpoints [#endpoints]

| Endpoint | Returns |
|---|---|
| `GET /v1/products` | A page of products, with filters, sorts and includes |
| `GET /v1/products/price-bounds` | The lowest and highest price for the current filters, for a price slider |
| `GET /v1/products/{slug}` | One product, with everything included |
| `GET /v1/collections` | A page of collections |
| `GET /v1/collections/{slug}` | One collection |
| `GET /v1/collections/{slug}/products` | A page of the collection's products, with the same filters, sorts and includes as `GET /v1/products` |
| `GET /v1/promotions` | The store's live public offers, for an offers strip or banner |
| `GET /v1/countries` | The countries the store sells and ships to, for address forms |

Catalogue responses are cached by the engine and cleared when the merchant changes the catalogue, so you can call them on every page view.

## The building blocks [#the-building-blocks]

- **Products** have a `name`, `description`, `slug` and `status`. Link product pages by `slug`.
- **Variants** are what a shopper actually buys. Each has an `id`, a `sku`, its `stock` and whether it is `shippable`. Add a variant to a cart by its `id` or its `sku`.
- **Options** describe how variants differ, such as Colour or Size. A product's `options` list the options its live variants use, in the merchant's order, each with the values in use. Each variant's own `options` name its value per option. That is all an option picker needs.
- **Collections** group products. They nest: each has a `parent_id`, and may list its `children`.
- **Attributes** are the merchant's own product fields, such as brand or material. Filterable attributes can narrow a listing with `filter[attr]`.
- **Images** have a `url` and a `thumbnail_url`.
- **Prices** are per variant, as [money objects](/docs/concepts/money).

## A product [#a-product]

```json title="GET /v1/products/oak-desk-lamp (trimmed)"
{
  "data": {
    "id": 101,
    "status": "published",
    "name": "Oak desk lamp",
    "description": "Turned solid oak base with a linen shade.",
    "slug": "oak-desk-lamp",
    "options": [
      { "id": 3, "handle": "finish", "name": "Finish", "label": "Finish",
        "values": [{ "id": 7, "name": "Natural" }, { "id": 8, "name": "Smoked" }] }
    ],
    "variants": [
      {
        "id": 1543,
        "sku": "LAMP-OAK-NAT",
        "stock": 12,
        "shippable": true,
        "price": { "amount": 8900, "currency": "GBP", "formatted": "£89.00" },
        "pricing": {
          "price": { "amount": 8900, "currency": "GBP", "formatted": "£89.00" },
          "sale_price": null,
          "compare_price": null,
          "on_sale": false,
          "saving": null,
          "badges": []
        },
        "options": [{ "option_id": 3, "option": "Finish", "value_id": 7, "value": "Natural" }]
      }
    ],
    "images": [
      { "id": 55, "name": "oak-desk-lamp", "url": "https://…/oak-desk-lamp.webp", "thumbnail_url": "https://…/oak-desk-lamp-thumb.webp" }
    ],
    "collections": [{ "id": 4, "parent_id": null, "name": "Lighting", "slug": "lighting" }],
    "offers": []
  }
}
```

`GET /v1/products/{slug}` always returns the full product, whatever you pass in `include`. Listings return only what you ask for.

### Prices on a variant [#prices-on-a-variant]

| Field | Meaning |
|---|---|
| `pricing.price` | The price for one unit. `price` on the variant is the same amount, kept for older clients. |
| `pricing.sale_price` | The promotional sale price, when a catalogue sale applies and the store shows sale prices. Otherwise `null`. |
| `pricing.compare_price` | The merchant's "was" price, only when it is above the price. |
| `pricing.on_sale` | Whether to present the item as on sale. |
| `pricing.saving` | What a promotion saves, as `amount` and `percent_bp` (basis points). |
| `pricing.badges` | Badges to show, each with a `label` and `kind`. |

When the request carries a signed-in customer's bearer token, prices are that customer's customer-group prices. Otherwise they are the default prices a guest sees.

## List products [#list-products]

```bash tab="cURL"
curl -G https://api.elaraone.app/api/v1/products \
  -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
  -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \
  --data-urlencode "filter[collection]=lighting" \
  --data-urlencode "filter[price_max]=10000" \
  --data-urlencode "sort=-price" \
  --data-urlencode "include=variants.prices,images" \
  --data-urlencode "perPage=24"
```

```ts tab="TypeScript"
const params = new URLSearchParams({
  'filter[collection]': 'lighting',
  'filter[price_max]': '10000', // £100.00 in pence
  sort: '-price',
  include: 'variants.prices,images',
  perPage: '24',
})

const res = await fetch(`https://api.elaraone.app/api/v1/products?${params}`, {
  headers: {
    'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!,
    'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!,
  },
})
const { data, links, meta } = await res.json()
```

```php tab="PHP"
$response = Http::withHeaders([
    'X-Tenant-Key' => env('ELARAONE_TENANT_KEY'),
    'X-Tenant-Secret' => env('ELARAONE_TENANT_SECRET'),
])->get('https://api.elaraone.app/api/v1/products', [
    'filter' => ['collection' => 'lighting', 'price_max' => 10000],
    'sort' => '-price',
    'include' => 'variants.prices,images',
    'perPage' => 24,
]);
```

### Filters [#filters]

| Parameter | Example | Matches |
|---|---|---|
| `filter[collection]` | `filter[collection]=lighting` | Products in the collection with this slug |
| `filter[search]` | `filter[search]=mug` | Products whose name contains the text, ignoring case |
| `filter[price_min]` | `filter[price_min]=2000` | Products with a variant priced at or above this, in integer minor units |
| `filter[price_max]` | `filter[price_max]=5000` | Products with a variant priced at or below this, in integer minor units |
| `filter[attr][handle]` | `filter[attr][material]=linen` | Products whose attribute has this value, ignoring case. Repeat for more attributes; they all have to match. |

Filters combine: every one you send has to match. `filter[search]` is a simple name match. For ranked, typo-tolerant search with facets, use [search](/docs/concepts/search).

### Sorts [#sorts]

`sort` takes one of `name`, `created_at` or `price`. Put `-` in front for descending order, for example `sort=-created_at` for newest first or `sort=price` for cheapest first.

### Includes [#includes]

`include` takes a comma-separated list:

| Include | Adds |
|---|---|
| `variants` | The variants, without prices |
| `variants.prices` | Each variant's `price` and `pricing` |
| `variants.options` | The product's `options` and each variant's `options` |
| `images` | The product's images |
| `collections` | The collections the product is in |
| `offers` | The promotions the product advertises, for badges, banners and bulk-price tables |

Ask only for what the page shows: a listing grid usually needs `variants.prices,images`.

An unknown filter, sort or include is refused with `400`.

### Pages [#pages]

`perPage` sets the page size, from 1 to 100 (default 24), and `page` picks the page. Responses carry `links` and `meta`. See [pagination](/docs/concepts/pagination).

## Price bounds [#price-bounds]

`GET /v1/products/price-bounds` returns the price range of the products a listing would show with the same `filter[collection]`, `filter[search]` and `filter[attr]`. Forward your listing's query string unchanged: `filter[price_min]` and `filter[price_max]` are accepted but ignored, so the slider's range never shrinks to the range the shopper just picked.

```json title="Response"
{
  "data": {
    "currency": "GBP",
    "min": { "amount": 1800, "currency": "GBP", "formatted": "£18.00" },
    "max": { "amount": 8900, "currency": "GBP", "formatted": "£89.00" }
  }
}
```

## Promotions [#promotions]

`GET /v1/promotions` lists the store's live public offers, unpaginated, for an offers strip or banners.

```json title="Response (trimmed)"
{
  "data": [
    {
      "id": 12,
      "kind": "free_shipping",
      "label": "Free delivery over £50",
      "badge": null,
      "banner": { "title": "Free delivery over £50", "body": "On every order to the UK mainland." },
      "ends_at": "2026-11-30T23:59:59+00:00",
      "target": null,
      "scope": "all"
    }
  ]
}
```

`kind` is the rule kind: `product_adjustment`, `cart_adjustment`, `bulk_tiered`, `bundle_set`, `buy_x_get_y` or `free_shipping`. `scope` says what the rule covers: `all` (store-wide), `collection` or `product`.

Offers on a single product come back in its `offers` (always on `GET /v1/products/{slug}`, and on listings with `include=offers`). A bulk rule adds a `bulk_table` of quantity bands, each with `min_qty`, `max_qty`, `unit_price` and `percent_bp`.

## Countries [#countries]

`GET /v1/countries` lists the countries the store sells and ships to. Use it to build the country picker in address forms: the country's `id` is the `country_id` that cart and account addresses take.

```json title="Response (trimmed)"
{
  "data": [
    { "id": 235, "iso2": "GB", "iso3": "GBR", "name": "United Kingdom", "phonecode": "44" }
  ],
  "meta": { "default_country": "GB" }
}
```

<Cards>
  <Card title="Search" href="/docs/concepts/search" description="Instant search, suggestions and facets." />
  <Card title="Carts" href="/docs/concepts/carts" description="Add variants to a cart by id or SKU." />
</Cards>
