# Products

> List, search, read and create or update products, with list prices or any customer's prices.

A product has one SKU, a list price, and optional brand, category, stock status and extra details. Prices are always worked out for you: see [Prices](/docs/api/prices).

Products may also have `familyId` and `orderStep` (default 1). A single-product read includes optional `family` metadata with its name, variant attribute names and preset. A configured price contains `orderingFields`, `configurationRequired`, `definitionVersion` and optional customer-priced `quantityBreaks`. Ordering fields are buyer inputs; `details` remain product facts.

**POST** `/api/v1/products/{id}/quote`

This read-only operation accepts `{ "customer": "ACME01", "qty": 3, "configuration": { "length": 2.75, "reference": "Kitchen" } }`. `customer` is optional for staff list pricing. Read-only keys can use it. It returns `data.productId`, `qty`, validated `fields`, `definitionVersion` and `price` with `unit`, `line` and the chosen specification. Missing required fields return Configure for price; invalid combinations are refused. It does not create a cart, order or payment. Use `price.line` rather than multiplying the unit price.

Imports accept optional `family`, `orderStep` and `orderingDefinition`. A definition uses the schema in OpenAPI, requires Pro and global pricing permission, and returns `ruleDrafts` for review through the existing price-change workflow. Omitted structure leaves existing settings intact. See [trade catalog setup](/docs/guides/trade-catalogues).

## The product object

```json
{
  "id": "3f8a2c1e-9b4d-4e6f-a7c8-1d2e3f4a5b6c",
  "sku": "HX-8510",
  "name": "Hex bolt M10 x 50 zinc",
  "description": "Grade 8.8, zinc plated, fully threaded.",
  "brandId": "a1b2c3d4-0000-4000-8000-000000000001",
  "categoryId": "a1b2c3d4-0000-4000-8000-000000000002",
  "manufacturerRef": "DIN933-M10-50",
  "unitLabel": "Box of 100",
  "minOrderQty": 1,
  "availability": "in_stock",
  "stockQty": 240,
  "lowStockAt": null,
  "leadTimeNote": null,
  "dealText": null,
  "active": true,
  "visibility": "all",
  "details": { "Finish": "Zinc", "Thread": "M10" },
  "createdAt": "2026-08-14T09:30:00.000Z",
  "updatedAt": "2026-09-20T16:02:11.000Z",
  "price": {
    "unit": 1850,
    "line": 1850,
    "list": 1850,
    "discountBp": 0,
    "source": "list",
    "label": ""
  }
}
```

| Field                   | Type            | Description                                                                                                                                                                                                                                                                                                                                                                                                       |
| ----------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                    | string          | Product id.                                                                                                                                                                                                                                                                                                                                                                                                       |
| `sku`                   | string          | Your stock code. Unique in the workspace, ignoring case.                                                                                                                                                                                                                                                                                                                                                          |
| `name`                  | string          | Product name.                                                                                                                                                                                                                                                                                                                                                                                                     |
| `description`           | string or null  | Longer description.                                                                                                                                                                                                                                                                                                                                                                                               |
| `brandId`, `categoryId` | string or null  | See [brands and categories](#brands-and-categories).                                                                                                                                                                                                                                                                                                                                                              |
| `manufacturerRef`       | string or null  | Manufacturer’s part number. Searchable like the SKU.                                                                                                                                                                                                                                                                                                                                                              |
| `unitLabel`             | string          | What one unit is, e.g. “Each” or “Box of 100”.                                                                                                                                                                                                                                                                                                                                                                    |
| `minOrderQty`           | integer         | Smallest quantity buyers can order.                                                                                                                                                                                                                                                                                                                                                                               |
| `availability`          | string          | `not_tracked`, `in_stock`, `low_stock`, `out_of_stock` or `made_to_order`. A label for buyers only: every product can be ordered. Sending it without a `stockQty` clears any stored count (you are setting the status by hand). With a `stockQty` it is worked out for you: 0 or less is `out_of_stock`, up to the low level is `low_stock`, above it `in_stock`. `made_to_order` is never changed by the number. |
| `stockQty`              | integer or null | Units on hand, or `null` when you don’t count this product. When set, `availability` follows it. Left out when you ask as a customer (`customer`).                                                                                                                                                                                                                                                                |
| `lowStockAt`            | integer or null | This product’s own low-stock level, or `null` to use the workspace level. Left out when you ask as a customer.                                                                                                                                                                                                                                                                                                    |
| `leadTimeNote`          | string or null  | e.g. “3–5 working days”.                                                                                                                                                                                                                                                                                                                                                                                          |
| `dealText`              | string or null  | A note shown beside the price, such as “min 12 btls 45”. Its heading is the workspace’s deal label (Settings, Catalog display).                                                                                                                                                                                                                                                                                   |
| `active`                | boolean         | Inactive products are hidden from buyers.                                                                                                                                                                                                                                                                                                                                                                         |
| `visibility`            | string          | `all`, or `selected` when only chosen customers can see it.                                                                                                                                                                                                                                                                                                                                                       |
| `details`               | object          | Extra columns from imports, like `{ "Finish": "Zinc" }`.                                                                                                                                                                                                                                                                                                                                                          |
| `price`                 | object or null  | The price at quantity 1 (or `qty`). See [Prices](/docs/api/prices#the-price-object). `unit` is `null` when the product has no price yet.                                                                                                                                                                                                                                                                          |

Stock counts are yours to keep up to date: orders never change `stockQty`, so send it from your stock system whenever it changes. Customers see only the `availability` status, never the number. At zero a product can still be ordered; it is just labeled out of stock.

`GET /products/{id}` also includes `brand` and `category` names.

Images aren’t returned by the API. You can set one with `imageUrl` when creating or updating.

## List or search products

**GET** `/api/v1/products`

Without `q`, products are listed by name. With `q`, the results come in this order:

1. exact SKU or manufacturer reference
2. SKU or manufacturer reference starting with `q`
3. full-text matches on name, brand, category and description (3 or more characters; shorter queries match the start of the name)

Pass `customer` to price every row for that customer and return only products that customer can see (active, and either for everyone or chosen for them). That’s exactly what the customer sees in their trade portal.

| Name           | Type              | Required | Description                                                                                                          |
| -------------- | ----------------- | -------- | -------------------------------------------------------------------------------------------------------------------- |
| `q`            | string            | no       | SKU, manufacturer ref, name, brand or category. Up to 200 characters.                                                |
| `category`     | string            | no       | Category id.                                                                                                         |
| `brand`        | string            | no       | Brand id.                                                                                                            |
| `availability` | string            | no       | One of the availability values.                                                                                      |
| `active`       | `true` or `false` | no       | `true` returns only active products. Defaults to `true` when `customer` is set; otherwise all products are returned. |
| `customer`     | string            | no       | Customer id or account code.                                                                                         |
| `limit`        | integer           | no       | 1 to 50. Default 50.                                                                                                 |
| `offset`       | integer           | no       | Rows to skip. Default 0.                                                                                             |

```bash
curl "https://tradecatalog.app/api/v1/products?q=hex%20bolt&customer=OAK01&limit=20" \
  -H "Authorization: Bearer $TRADECATALOG_API_KEY"
```

```js
const params = new URLSearchParams({
  q: 'hex bolt',
  customer: 'OAK01',
  limit: '20',
})
const res = await fetch(`https://tradecatalog.app/api/v1/products?${params}`, {
  headers: { Authorization: `Bearer ${process.env.TRADECATALOG_API_KEY}` },
})
const { data: products, nextOffset } = await res.json()
```

```json
{
  "data": [
    {
      "id": "3f8a2c1e-9b4d-4e6f-a7c8-1d2e3f4a5b6c",
      "sku": "HX-8510",
      "name": "Hex bolt M10 x 50 zinc",
      "description": "Grade 8.8, zinc plated, fully threaded.",
      "brandId": "a1b2c3d4-0000-4000-8000-000000000001",
      "categoryId": "a1b2c3d4-0000-4000-8000-000000000002",
      "manufacturerRef": "DIN933-M10-50",
      "unitLabel": "Box of 100",
      "minOrderQty": 1,
      "availability": "in_stock",
      "leadTimeNote": null,
      "active": true,
      "visibility": "all",
      "details": { "Finish": "Zinc", "Thread": "M10" },
      "createdAt": "2026-08-14T09:30:00.000Z",
      "updatedAt": "2026-09-20T16:02:11.000Z",
      "price": {
        "unit": 1573,
        "line": 1573,
        "list": 1850,
        "discountBp": 1500,
        "source": "brand",
        "label": "Brand discount −15%"
      }
    }
  ],
  "nextOffset": null,
  "currency": "GBP"
}
```

## Get one product

**GET** `/api/v1/products/{id}`

| Name       | In    | Type    | Required | Description                                                                        |
| ---------- | ----- | ------- | -------- | ---------------------------------------------------------------------------------- |
| `id`       | path  | string  | yes      | Product id.                                                                        |
| `customer` | query | string  | no       | Customer id or account code. Returns `404` if that customer can’t see the product. |
| `qty`      | query | integer | no       | Quantity to price, 1 to 1,000,000. Default 1. `price.line` is `unit × qty`.        |

```bash
curl "https://tradecatalog.app/api/v1/products/3f8a2c1e-9b4d-4e6f-a7c8-1d2e3f4a5b6c?customer=OAK01&qty=5" \
  -H "Authorization: Bearer $TRADECATALOG_API_KEY"
```

```js
const res = await fetch(
  `https://tradecatalog.app/api/v1/products/${productId}?customer=OAK01&qty=5`,
  { headers: { Authorization: `Bearer ${process.env.TRADECATALOG_API_KEY}` } },
)
const { data: product } = await res.json()
```

```json
{
  "data": {
    "id": "3f8a2c1e-9b4d-4e6f-a7c8-1d2e3f4a5b6c",
    "sku": "HX-8510",
    "name": "Hex bolt M10 x 50 zinc",
    "description": "Grade 8.8, zinc plated, fully threaded.",
    "brandId": "a1b2c3d4-0000-4000-8000-000000000001",
    "categoryId": "a1b2c3d4-0000-4000-8000-000000000002",
    "manufacturerRef": "DIN933-M10-50",
    "unitLabel": "Box of 100",
    "minOrderQty": 1,
    "availability": "in_stock",
    "leadTimeNote": null,
    "active": true,
    "visibility": "all",
    "details": { "Finish": "Zinc", "Thread": "M10" },
    "createdAt": "2026-08-14T09:30:00.000Z",
    "updatedAt": "2026-09-20T16:02:11.000Z",
    "brand": "Northgate",
    "category": "Bolts",
    "price": {
      "unit": 1573,
      "line": 7865,
      "list": 1850,
      "discountBp": 1500,
      "source": "brand",
      "label": "Brand discount −15%"
    }
  },
  "currency": "GBP"
}
```

To look a product up by SKU, use `GET /products?q=HX-8510`: an exact SKU match is always first. Or use [`GET /prices`](/docs/api/prices), which takes a SKU.

## Create or update products

**PUT** `/api/v1/products`

Send up to **100 products** per call. Each is matched to an existing product by SKU, ignoring case. If there’s no match, a new product is created. Brands and categories are matched by name and created if they don’t exist.

### How list prices are handled

| The product…                | What happens to the price                                                                                                                                                                           |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| is new                      | Goes live at `listPrice` straight away.                                                                                                                                                             |
| exists but has no price yet | Goes live at `listPrice` straight away.                                                                                                                                                             |
| exists at the same price    | Nothing.                                                                                                                                                                                            |
| exists at a different price | Nothing changes yet. The new price goes into a **draft price change** for that list. `priceChangeId` is the draft for `listPrice`’s list. `priceChangesByList` has one entry per list that changed. |

Draft price changes are how TradeCatalog stops a typo reaching customers. The owner or staff review the draft in the app (**Price changes**), check the biggest moves and warnings, choose a date and whether to email customers, then publish. You can preview a draft with [`GET /price-changes/{id}`](/docs/api/price-changes#preview-a-price-change).

**One sync, one draft per list:** A call that changes prices starts a draft per list, called “API import (key name)” for Standard and with the list name when you send more than one. To keep a whole sync in those drafts, send `priceChangeId` (the list for `listPrice`) and `drafts` (the other lists) from the first call with every later call. Each must still be a draft: once it’s published, canceled or discarded, leave that id out to start a new one.

### Body

| Field                        | Type            | Required | Description                                                                                                                                                                                                                                                                                                                                                                                          |
| ---------------------------- | --------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `products`                   | array           | yes      | 1 to 100 products. SKUs must be unique within the call.                                                                                                                                                                                                                                                                                                                                              |
| `priceChangeId`              | string          | no       | A draft for the `listPrice` list from an earlier call. Changed prices for that list are added to it. 404 if it doesn’t exist, 400 if it isn’t a draft any more.                                                                                                                                                                                                                                      |
| `drafts`                     | array           | no       | Up to 30 `{ list, priceChangeId }` entries so other lists continue their own drafts.                                                                                                                                                                                                                                                                                                                 |
| `products[].sku`             | string          | yes      | Up to 64 characters.                                                                                                                                                                                                                                                                                                                                                                                 |
| `products[].name`            | string          | yes      | Up to 200 characters.                                                                                                                                                                                                                                                                                                                                                                                |
| `products[].listPrice`       | integer or null | yes      | Minor units, 0 to 1,000,000,000. `null` is price on request: a new product has no price, an existing one keeps its own.                                                                                                                                                                                                                                                                              |
| `products[].priceList`       | string or null  | no       | Name of an existing price list for `listPrice`. Omit or `null` is Standard. An unknown name is rejected.                                                                                                                                                                                                                                                                                             |
| `products[].listPrices`      | array           | no       | Other lists, as `{ list, price }`. `price` is minor units, or `null` to leave that list unchanged. An unknown name is rejected.                                                                                                                                                                                                                                                                      |
| `products[].unitLabel`       | string          | yes      | Up to 60 characters, e.g. “Each”.                                                                                                                                                                                                                                                                                                                                                                    |
| `products[].brand`           | string          | no       | Brand name, up to 120 characters. **Cleared if left out.**                                                                                                                                                                                                                                                                                                                                           |
| `products[].category`        | string          | no       | Category name, up to 120 characters. **Cleared if left out.**                                                                                                                                                                                                                                                                                                                                        |
| `products[].manufacturerRef` | string          | no       | Up to 120 characters. **Cleared if left out.**                                                                                                                                                                                                                                                                                                                                                       |
| `products[].description`     | string          | no       | Up to 4,000 characters. **Cleared if left out.**                                                                                                                                                                                                                                                                                                                                                     |
| `products[].availability`    | string          | no       | One of the availability values. Kept if left out; new products start as `not_tracked`.                                                                                                                                                                                                                                                                                                               |
| `products[].stockQty`        | integer or null | no       | Units on hand, from -1,000,000 to 1,000,000,000. Sets `availability` (see the product object); a status you send alongside it is ignored, except `made_to_order`. Kept if left out, unless you send an `availability` without it: then the count is cleared and that status is used. `null` stops counting; the `availability` sent in the same row applies, or the current status if there is none. |
| `products[].lowStockAt`      | integer or null | no       | Low-stock level for this product, from 0. `null` uses the workspace level (Settings, 5 to start with). Kept if left out.                                                                                                                                                                                                                                                                             |
| `products[].leadTimeNote`    | string          | no       | Up to 200 characters. Kept if left out.                                                                                                                                                                                                                                                                                                                                                              |
| `products[].dealText`        | string          | no       | Up to 200 characters, shown beside the price. Kept if left out.                                                                                                                                                                                                                                                                                                                                      |
| `products[].details`         | object          | no       | Up to 30 extra columns: names up to 60 characters, values up to 500. Replaces all details when sent; kept if left out.                                                                                                                                                                                                                                                                               |
| `products[].imageUrl`        | string          | no       | An `https` image URL. Copied into TradeCatalog in the background when it changes. Kept if left out.                                                                                                                                                                                                                                                                                                  |
| `products[].onlyFor`         | array           | no       | Up to 200 account codes or contact emails. Makes the product visible **only** to those customers, replacing any earlier list. Kept if left out.                                                                                                                                                                                                                                                      |

**Send every field you care about:** `brand`, `category`, `manufacturerRef` and `description` are written as sent, so leaving one out clears it. Send the full product each time, like a spreadsheet row.

These can’t be changed through the API yet: making a product inactive, `minOrderQty`, removing an image, clearing a lead time note, and making a customer-only product visible to everyone again. Do those in the app.

```bash
curl -X PUT https://tradecatalog.app/api/v1/products \
  -H "Authorization: Bearer $TRADECATALOG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "products": [
      {
        "sku": "HX-8510",
        "name": "Hex bolt M10 x 50 zinc",
        "listPrice": 1950,
        "unitLabel": "Box of 100",
        "brand": "Northgate",
        "category": "Bolts",
        "manufacturerRef": "DIN933-M10-50",
        "availability": "in_stock",
        "details": { "Finish": "Zinc", "Thread": "M10" }
      },
      {
        "sku": "OAK-HNG-75",
        "name": "Oak-finish butt hinge 75mm",
        "listPrice": 695,
        "unitLabel": "Pair",
        "category": "Hinges",
        "onlyFor": ["OAK01"]
      }
    ]
  }'
```

```js
const res = await fetch('https://tradecatalog.app/api/v1/products', {
  method: 'PUT',
  headers: {
    Authorization: `Bearer ${process.env.TRADECATALOG_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ products }),
})
const { data: result } = await res.json()
if (result.priceChangeId)
  console.log('Review the draft price change in TradeCatalog')
```

```json
{
  "data": {
    "created": 1,
    "updated": 1,
    "priceChanges": 1,
    "priceChangeId": "c4d5e6f7-1234-4abc-9def-0123456789ab",
    "priceChangesByList": [
      {
        "list": "Standard",
        "priceChangeId": "c4d5e6f7-1234-4abc-9def-0123456789ab"
      }
    ],
    "unknownCustomers": []
  }
}
```

| Field                | Description                                                                                    |
| -------------------- | ---------------------------------------------------------------------------------------------- |
| `created`            | New products.                                                                                  |
| `updated`            | Existing products matched by SKU.                                                              |
| `priceChanges`       | How many prices went into a draft, across every list in the call.                              |
| `priceChangeId`      | The draft for the `listPrice` list, or `null` if that list had no changes.                     |
| `priceChangesByList` | One `{ list, priceChangeId }` for each list that had a price change in this call.              |
| `unknownCustomers`   | Values in `onlyFor` that matched no customer (up to 50). Those customers weren’t given access. |

Changes appear in the activity log as the key’s name, and search updates straight away.

## Brands and categories

**GET** `/api/v1/brands`

**GET** `/api/v1/categories`

Both return every row, sorted by name, with no paging. Use the ids with the `brand` and `category` filters on `GET /products`.

```bash
curl https://tradecatalog.app/api/v1/categories \
  -H "Authorization: Bearer $TRADECATALOG_API_KEY"
```

```js
const res = await fetch('https://tradecatalog.app/api/v1/categories', {
  headers: { Authorization: `Bearer ${process.env.TRADECATALOG_API_KEY}` },
})
const { data: categories } = await res.json()
```

```json
{
  "data": [
    { "id": "a1b2c3d4-0000-4000-8000-000000000002", "name": "Bolts" },
    { "id": "a1b2c3d4-0000-4000-8000-000000000003", "name": "Hinges" }
  ]
}
```

To create a brand or category, name it on a product in `PUT /products`.
