# Customer catalogue and prices

When your plugin works for one signed-in customer, it often needs two answers the plain product API cannot give: which products this customer may see, and what they pay. A B2B buyer can be confined to a restricted catalogue, and can have negotiated prices, group discounts and fixed prices on single variants. The customer catalogue view answers both questions the way the storefront does.

Use it for anything a trade customer builds from the shop's products: quotes, price lists, product sheets, reorder pads.

## Scopes

Every route needs three scopes together: a product scope, `customer:read` and `b2b-catalog:read`. The answer includes the prices that customer pays, negotiated B2B prices included, and those are B2B catalogue data, so the merchant consents to them through `b2b-catalog:read`.

| Route | Scopes |
|---|---|
| `GET /v1/customers/{customerId}/catalog/products` | `product:list`, `customer:read` and `b2b-catalog:read` |
| `GET /v1/customers/{customerId}/catalog/products/{productId}` | `product:read`, `customer:read` and `b2b-catalog:read` |
| `POST /v1/customers/{customerId}/catalog/prices` | `product:read`, `customer:read` and `b2b-catalog:read` |

A token missing any of the three gets `401`, naming all three. A plugin or a store API key can hold them; an AI assistant connection cannot hold `b2b-catalog:read`, so it cannot use these routes. The store does not need the B2B feature: without it, every price is the regular price (`source: "base"`). A customer of another store gets `404`, worded exactly as for a customer that does not exist.

## Prices

Every price in this view has one shape:

```json
{ "amount": 8000, "currency": "GBP", "taxInclusive": false, "source": "b2b" }
```

- `amount` is a whole number of minor units (pence for GBP), excluding VAT, in the store's primary currency. When the store enters its prices including VAT, the VAT is taken out at the rate of the product's tax class in the store's home region, worked out exactly as checkout works it (VAT rounded half up to the penny, net is the price less that VAT). A tax-exempt customer pays no VAT at checkout, so their prices are published as the price they pay, with nothing taken off.
- `source` is `b2b` when the customer's B2B catalogue sets the price (a catalogue price for the product or the variant, or a group pricing rule such as 10% off a category), and `base` when the regular price applies. A running sale is part of the regular price, as it is at checkout.
- A price is `null` when nothing prices the item.

These are the prices checkout charges this customer, not a separate list: when the shop changes a catalogue, the next call shows it.

## Listing what a customer may see

```http
GET /v1/customers/7c2f0d4e-1b6a-4c3d-9e8f-0a1b2c3d4e5f/catalog/products?keyword=oak%20desk&page=1&limit=20 HTTP/1.1
Host: api.myorbitcommerce.net
Authorization: Bearer <token>
```

```json
{
  "statusCode": 200,
  "message": "Success",
  "data": {
    "items": [
      {
        "id": "2b4e6f80-3c1d-4a5b-8e9f-1a2b3c4d5e6f",
        "title": "Oak Desk",
        "imageUrl": "https://cdn.myorbitcommerce.net/stores/.../oak-desk.jpg",
        "variantCount": 2,
        "fromPrice": { "amount": 8000, "currency": "GBP", "taxInclusive": false, "source": "b2b" }
      }
    ],
    "meta": { "page": 1, "limit": 20, "hasMore": false }
  },
  "timestamp": "2026-10-19T10:12:03.511Z"
}
```

- Only active products are listed, and a customer on a restricted catalogue sees only that catalogue's products.
- With `keyword` (up to 100 characters) the list is a storefront search: ranked by relevance, matching what the store's search box matches, and leaving out products the merchant hid from search. If the store's search settings keep this customer out of search, a keyword search answers an empty page. Without it, products are listed newest first.
- `limit` is 1 to 50 (default 20). `meta.hasMore` says whether another page exists; there is no total count.
- `fromPrice` is the price the storefront product card shows this customer, net of VAT as above: their B2B catalogue price for the product when it sets one, else the regular from price. It is for display only, never a line price: a variant the catalogue prices on its own can cost more or less, so read the product or the prices route for what each variant costs.

## One product

```http
GET /v1/customers/7c2f0d4e-1b6a-4c3d-9e8f-0a1b2c3d4e5f/catalog/products/2b4e6f80-3c1d-4a5b-8e9f-1a2b3c4d5e6f HTTP/1.1
Host: api.myorbitcommerce.net
Authorization: Bearer <token>
```

```json
{
  "statusCode": 200,
  "message": "Success",
  "data": {
    "id": "2b4e6f80-3c1d-4a5b-8e9f-1a2b3c4d5e6f",
    "title": "Oak Desk",
    "imageUrl": "https://cdn.myorbitcommerce.net/stores/.../oak-desk.jpg",
    "options": [{ "name": "Width", "values": ["120 cm", "140 cm"] }],
    "variants": [
      {
        "id": "9d01a2b3-c4d5-4e6f-8a9b-0c1d2e3f4a5b",
        "sku": "DESK-120",
        "title": "120 cm",
        "options": [{ "name": "Width", "value": "120 cm" }],
        "imageUrl": "https://cdn.myorbitcommerce.net/stores/.../oak-desk-120.jpg",
        "price": { "amount": 8000, "currency": "GBP", "taxInclusive": false, "source": "b2b" }
      }
    ],
    "truncated": false
  },
  "timestamp": "2026-10-19T10:12:04.020Z"
}
```

- Up to 2,000 variants, in the order the storefront shows them. `truncated` is `true` when the product has more.
- Pick a variant by matching its `options` to what the customer chose.
- `404` when the product is not active, belongs to another store, or is outside the customer's restricted catalogue.

## Refreshing prices you saved

When you keep a customer's price (on a quote, say), refresh it later with one call rather than one per product:

```http
POST /v1/customers/7c2f0d4e-1b6a-4c3d-9e8f-0a1b2c3d4e5f/catalog/prices HTTP/1.1
Host: api.myorbitcommerce.net
Authorization: Bearer <token>
Content-Type: application/json

{ "variantIds": ["9d01a2b3-c4d5-4e6f-8a9b-0c1d2e3f4a5b", "4a7c1e2f-3b4d-4c5e-9f6a-7b8c9d0e1f2a", "ffffffff-0000-4000-8000-000000000000"] }
```

```json
{
  "statusCode": 200,
  "message": "Success",
  "data": {
    "items": [
      {
        "variantId": "9d01a2b3-c4d5-4e6f-8a9b-0c1d2e3f4a5b",
        "productId": "2b4e6f80-3c1d-4a5b-8e9f-1a2b3c4d5e6f",
        "visible": true,
        "price": { "amount": 8000, "currency": "GBP", "taxInclusive": false, "source": "b2b" }
      },
      {
        "variantId": "4a7c1e2f-3b4d-4c5e-9f6a-7b8c9d0e1f2a",
        "productId": "5e11c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d",
        "visible": false,
        "price": null
      },
      {
        "variantId": "ffffffff-0000-4000-8000-000000000000",
        "productId": null,
        "visible": false,
        "price": null
      }
    ]
  },
  "timestamp": "2026-10-19T10:12:05.007Z"
}
```

- 1 to 200 ids; letter case does not matter: every id comes back in lower case, and a repeated id is answered once, at its first position. Items come back in the order you sent them.
- `visible: false` means the customer may no longer see the variant (for example its product is no longer active, or left their catalogue). `productId: null` means the id is not a live variant of this store at all.
- The call answers `200`, writes nothing and counts against the read rate limit.

## Errors

| Status | When |
|---|---|
| `400` | An id that is not a uuid; `keyword` over 100 characters; `page` not a whole number from 1 to 1,000,000,000; `keyword` sent more than once; `limit` not a whole number from 1 to 50; an empty `variantIds`, more than 200, or one that is not a uuid; any other query parameter or body field. |
| `401` | No token, or a token without all three scopes. |
| `404` | The customer is not in this store, or has been erased; on the product route, a product this customer may not see. |

## Filing uploads in a media folder

`POST /v1/media` takes an optional text field `folder` beside `file`. The upload lands in that top-level folder of the store's media library, made the first time a name is used; the same name always means the same folder. Without `folder` the file lands in the library root, as before.

```bash
curl -X POST https://api.myorbitcommerce.net/v1/media \
  -H "Authorization: Bearer <token>" \
  -F folder="Customer logos" \
  -F file=@logo.png
```

The name is 1 to 100 characters (surrounding spaces are trimmed), may not contain `/ \ : * ? " < > |`, and must be sent once. Anything else answers `400` and nothing is stored. The response is unchanged; its `folderId` names the folder. Needs `media:create`.
