# TradeCatalog developer docs

> Connect your systems and AI apps to TradeCatalog with the REST API, the MCP server and LLM-ready docs.

TradeCatalog keeps a trade supplier’s catalogue, customer prices, orders and price changes in one place. These docs cover the ways software can work with that data.

## Three ways in

### REST API

For your own systems: ERP, stock control, accounts, a nightly script. Owners make an API key in **Settings → API**. The key works on one workspace with staff rights.

- Read products with any customer’s prices, customers, orders, price changes and the activity log.
- Create and update products and customers in batches of 100.
- Move orders on (acknowledged, dispatched, completed, cancelled).

Start with the [quickstart](/docs/quickstart), then read the [REST API overview](/docs/api).

### MCP server for AI apps

For people using Claude, ChatGPT, Cursor or any MCP client. Each person signs in with their own TradeCatalog login and approves read-only access. The AI app then sees exactly what that person sees in the app: staff see their workspaces, buyers see their own prices.

See [Connect an AI app](/docs/mcp) and the [MCP tools](/docs/mcp/tools).

### Docs for LLMs

Every page here is also plain markdown. Add `.md` to any docs URL, or fetch [/llms-full.txt](/llms-full.txt) for all of them in one file. Every page has **Copy page** and **Open in Claude** buttons. See [AI and LLM resources](/docs/guides/llms).

## Which one should I use?

| You want to…                                              | Use                               |
| --------------------------------------------------------- | --------------------------------- |
| Sync products and customers from your ERP every night     | [REST API](/docs/guides/erp-sync) |
| Pull new orders into your accounts or dispatch system     | [REST API](/docs/api/orders)      |
| Ask “what does Oakfield Joinery pay for HX-8510?” in chat | [MCP](/docs/mcp)                  |
| Let a buyer check their own prices from their AI app      | [MCP](/docs/mcp)                  |
| Generate a typed client for your code                     | [OpenAPI](/docs/api/openapi)      |

## What isn’t here yet

- **Webhooks.** Poll [`GET /orders?status=new`](/docs/api/orders) every few minutes instead.
- **Publishing price changes by API.** Changed list prices go into a draft that you review and publish in the app. See [Price changes](/docs/api/price-changes).
- **Deleting products or customers by API.** Make a product inactive or remove it in the app.
- **Keys for buyers.** Buyers use [MCP](/docs/mcp) or download their price list as CSV from their trade portal.

Something missing that you need? Email support from your workspace (Support in the menu) and tell us what you’re building.

Source: https://tradecatalog.app/docs

---

# Quickstart

> Make an API key and make your first TradeCatalog API calls in five minutes.

You need to be the **owner** of a TradeCatalog workspace with an active subscription, a free trial or a pilot account. Demo workspaces can’t use the API.

## 1. Make an API key

1. Open your workspace and go to **Settings → API**.
2. Give the key a name you’ll recognise later, like “Sage stock sync”.
3. Choose **Read only** to look things up, or **Read & write** to also update products, customers and orders.
4. Choose **Create key** and copy it. It starts with `tc_live_`.

**Copy it now:** We only show a key once, and we only keep a hash of it. If you lose it, revoke it and make a new one.

Put the key in an environment variable so it stays out of your code:

```bash
export TRADECATALOG_API_KEY="tc_live_…"
```

## 2. Check the key works

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

```json
{
  "data": {
    "id": "0b6c1f5e-6f7a-4a57-9a0c-2f4d4c1e8a11",
    "slug": "northgate",
    "name": "Northgate Fixings",
    "currency": "GBP",
    "timezone": "Europe/London",
    "taxLabel": "ex VAT",
    "key": { "name": "Sage stock sync", "scope": "write" }
  }
}
```

## 3. Search products

```bash
curl "https://tradecatalog.app/api/v1/products?q=hinge&limit=2" \
  -H "Authorization: Bearer $TRADECATALOG_API_KEY"
```

Money is always in **minor units** of the workspace currency, so `1850` is £18.50. See [Conventions](/docs/api/conventions).

## 4. Get a customer’s price

Pass a customer’s account code (or id) and a SKU:

```bash
curl "https://tradecatalog.app/api/v1/prices?customer=OAK01&sku=HX-8510&qty=10" \
  -H "Authorization: Bearer $TRADECATALOG_API_KEY"
```

```json
{
  "data": {
    "customer": {
      "id": "7d2e4f0a-1b3c-4d5e-8f90-a1b2c3d4e5f6",
      "accountCode": "OAK01",
      "name": "Oakfield Joinery"
    },
    "sku": "HX-8510",
    "name": "Hex bolt M10 x 50 zinc",
    "qty": 10,
    "unit": 1573,
    "line": 15730,
    "list": 1850,
    "discountBp": 1500,
    "source": "brand",
    "label": "Brand discount −15%"
  },
  "currency": "GBP"
}
```

[How prices are worked out](/docs/api/prices) explains `source` and `label`.

## 5. Create or update a product

This needs a **Read & write** key.

```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-8512",
        "name": "Hex bolt M12 x 50 zinc",
        "listPrice": 2240,
        "unitLabel": "Box of 100",
        "brand": "Northgate",
        "category": "Bolts"
      }
    ]
  }'
```

```json
{
  "data": {
    "created": 1,
    "updated": 0,
    "priceChanges": 0,
    "priceChangeId": null,
    "unknownCustomers": []
  }
}
```

New products go live at their list price straight away. If you send a different price for a product that already has one, the price doesn’t change yet. It goes into a draft price change that you review and publish in the app. See [Products](/docs/api/products#create-or-update-products).

## The same in JavaScript

```js
const api = (path, init = {}) =>
  fetch(`https://tradecatalog.app/api/v1${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${process.env.TRADECATALOG_API_KEY}`,
      'Content-Type': 'application/json',
      ...init.headers,
    },
  }).then(async (res) => {
    const body = await res.json()
    if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`)
    return body
  })

const { data: workspace } = await api('/workspace')
const { data: price } = await api('/prices?customer=OAK01&sku=HX-8510&qty=10')
console.log(`${workspace.name}: ${price.name} is ${price.unit / 100} each`)
```

## Next steps

- [Authentication](/docs/api/authentication): scopes, revoking keys, keeping them safe.
- [Errors](/docs/api/errors): every error code and how to retry.
- [Sync with your ERP](/docs/guides/erp-sync): a complete nightly sync and order poller.
- [OpenAPI](/docs/api/openapi): generate a typed client.

Source: https://tradecatalog.app/docs/quickstart

---

# REST API

> What the TradeCatalog REST API covers, who can use it and every endpoint at a glance.

The REST API gives your own systems the same data your staff see in TradeCatalog: products, customer prices, customers, orders, price changes and the activity log. It uses JSON over HTTPS and one API key per system.

## Base URL

```text
https://tradecatalog.app/api/v1
```

Every request needs an API key in the `Authorization` header. See [Authentication](/docs/api/authentication).

## Who can use it

- **Owners** make and revoke keys. Staff and buyers can’t.
- The workspace needs an **active subscription**, a **free trial** or a **pilot** account to make keys.
- **Demo workspaces** can’t use the API at all.
- If a subscription lapses and the workspace goes read-only, existing keys can still read but can’t write.

A key belongs to one workspace. It acts with owner rights on that workspace, so it can see every customer’s prices.

## Endpoints

| Method | Path                                                                    | What it does                                               | Scope |
| ------ | ----------------------------------------------------------------------- | ---------------------------------------------------------- | ----- |
| GET    | [`/workspace`](/docs/api/workspace)                                     | The workspace this key belongs to                          | read  |
| GET    | [`/products`](/docs/api/products#list-or-search-products)               | List or search products, optionally at a customer’s prices | read  |
| GET    | [`/products/{id}`](/docs/api/products#get-one-product)                  | One product with its price                                 | read  |
| PUT    | [`/products`](/docs/api/products#create-or-update-products)             | Create or update up to 100 products by SKU                 | write |
| GET    | [`/brands`](/docs/api/products#brands-and-categories)                   | All brands                                                 | read  |
| GET    | [`/categories`](/docs/api/products#brands-and-categories)               | All categories                                             | read  |
| GET    | [`/prices`](/docs/api/prices)                                           | What a customer pays for a SKU, and why                    | read  |
| GET    | [`/customers`](/docs/api/customers#list-customers)                      | Trade customers                                            | read  |
| GET    | [`/customers/{id}`](/docs/api/customers#get-one-customer)               | One customer’s terms, rules, agreed prices and buyers      | read  |
| PUT    | [`/customers`](/docs/api/customers#create-or-update-customers)          | Create or update up to 100 customers                       | write |
| GET    | [`/orders`](/docs/api/orders#list-orders)                               | Recent orders, newest first                                | read  |
| GET    | [`/orders/{id}`](/docs/api/orders#get-one-order)                        | One order with lines and status history                    | read  |
| PATCH  | [`/orders/{id}`](/docs/api/orders#move-an-order-on)                     | Move an order on                                           | write |
| GET    | [`/price-changes`](/docs/api/price-changes#list-price-changes)          | Price changes, newest first                                | read  |
| GET    | [`/price-changes/{id}`](/docs/api/price-changes#preview-a-price-change) | Preview one price change                                   | read  |
| GET    | [`/activity`](/docs/api/activity)                                       | Who changed what                                           | read  |

The full machine-readable description is at [`/api/v1/openapi.json`](/docs/api/openapi).

## Before you build

- Read [Conventions](/docs/api/conventions) for money, paging, dates and how `PUT` works.
- Read [Errors](/docs/api/errors) for status codes and retries.
- Keep keys on your server. The API doesn’t allow browser (CORS) requests.

## Not in the API yet

- Webhooks: poll `GET /orders?status=new` instead.
- Publishing, scheduling or cancelling price changes: do that in the app.
- Discount rules and agreed prices: read them with `GET /customers/{id}`, change them in the app.
- Deleting products or customers, uploading images directly, inviting buyers, placing orders as a buyer.

Source: https://tradecatalog.app/docs/api

---

# Authentication

> API keys, read and write scopes, revoking keys and keeping them safe.

The API uses secret API keys. Each key belongs to one workspace.

## Send the key

Put the key in the `Authorization` header as a bearer token:

```http
GET /api/v1/workspace HTTP/1.1
Host: tradecatalog.app
Authorization: Bearer tc_live_Xk2…
```

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

Keys always start with `tc_live_`. There are no test keys: try things out on a workspace with sample data, or read before you write.

## Make a key

Only the workspace **owner** can make keys, in **Settings → API**.

| Setting      | What it means                                                                  |
| ------------ | ------------------------------------------------------------------------------ |
| Name         | Your label, e.g. “Sage stock sync”. Shown in Settings and in the activity log. |
| Read only    | `GET` requests only.                                                           |
| Read & write | Also `PUT /products`, `PUT /customers` and `PATCH /orders/{id}`.               |

The key is shown **once**. We store only a SHA-256 hash, so nobody at TradeCatalog can see it again. Settings shows the first few characters (like `tc_live_Xk2m…`) so you can tell keys apart.

A workspace can have up to **20 live keys**. Give each system its own key, so you can revoke one without breaking the others.

## Who can have keys

The workspace needs one of these to make keys:

- an active subscription
- a free trial
- a pilot account (arranged with Happy Webs)

Demo workspaces can’t make or use keys.

If the subscription lapses, you get 14 days’ grace. After that the workspace is read-only: existing keys can still read, but writes fail with `403 read_only` until the subscription is active again.

## What a key can see

A key acts with **owner rights** on its workspace. It can read every customer, every customer’s prices, every order and the activity log. Treat it like the owner’s password.

## Last used and revoking

Settings shows when each key was last used (updated at most once an hour). Choose **Revoke** to stop a key straight away. The next request with it gets `401 unauthorized`. Revoked keys can’t be restored.

## Who the changes are logged as

Writes appear in the workspace’s activity log as `API key “Sage stock sync”`. Order status changes are recorded against the owner who made the key.

## Keep keys safe

- Keep keys on your server, in environment variables or a secrets manager.
- Never put a key in a web page, browser extension or mobile app. The API doesn’t allow browser (CORS) requests for this reason.
- Never commit a key to git. If you do, revoke it and make a new one.
- Use a read-only key wherever you only need to read.

## Errors

| Status | Code                 | Why                                                                                                                                      |
| ------ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| 401    | `unauthorized`       | No `Authorization` header, a malformed one, an unknown or revoked key, or a demo workspace. The response has `WWW-Authenticate: Bearer`. |
| 403    | `insufficient_scope` | A read-only key tried to write.                                                                                                          |
| 403    | `read_only`          | The workspace is read-only: its free trial or subscription has ended.                                                                    |

See [Errors](/docs/api/errors) for the full list.

**AI apps use a different sign-in:** Claude, ChatGPT and other MCP clients don’t use API keys. Each person signs in with their own login through OAuth. See [MCP](/docs/mcp).


Source: https://tradecatalog.app/docs/api/authentication

---

# Conventions

> Response shape, paging, money, percentages, dates, ids, upserts, rate limits and versioning.

## Requests and responses

- Requests and responses are JSON. Send `Content-Type: application/json` with a body.
- Field names are camelCase: `listPrice`, `accountCode`, `nextOffset`.
- Query parameters are plain strings: `?limit=20&status=new`.

Every successful response is an object with `data`:

```json
{
  "data": [],
  "nextOffset": 50,
  "currency": "GBP"
}
```

| Field        | When it’s there                                 | Meaning                                                            |
| ------------ | ----------------------------------------------- | ------------------------------------------------------------------ |
| `data`       | Always                                          | The object or list you asked for.                                  |
| `nextOffset` | Lists that page                                 | Pass it as `offset` to get the next page. `null` on the last page. |
| `currency`   | Responses with prices in the workspace currency | ISO 4217 code for every money field in `data`.                     |

Orders carry their own `currency` field, fixed when the order was placed.

## Paging

Lists take `limit` and `offset`:

| Parameter | Default     | Maximum                                  |
| --------- | ----------- | ---------------------------------------- |
| `limit`   | the maximum | 50 for products, 100 for everything else |
| `offset`  | 0           | none                                     |

Keep requesting with `offset=nextOffset` until `nextOffset` is `null`:

```js
let offset = 0
const all = []
while (offset !== null) {
  const res = await fetch(
    `https://tradecatalog.app/api/v1/products?limit=50&offset=${offset}`,
    {
      headers: { Authorization: `Bearer ${process.env.TRADECATALOG_API_KEY}` },
    },
  )
  const page = await res.json()
  all.push(...page.data)
  offset = page.nextOffset
}
```

On `/products`, `nextOffset` is set whenever a page is full. If the catalogue size is an exact multiple of `limit`, the last request returns an empty `data` list with `nextOffset: null`.

Some lists are capped. `GET /orders` covers the 200 most recent matching orders, and `GET /price-changes` the 100 most recent.

## Money

Money is an **integer in minor units** of the currency, so there’s no rounding error:

| Value  | Currency | Means                |
| ------ | -------- | -------------------- |
| `1234` | GBP      | £12.34               |
| `1850` | EUR      | €18.50               |
| `500`  | JPY      | ¥500 (no minor unit) |

Divide by 10 to the power of the currency’s decimal places to display it. In JavaScript, `Intl.NumberFormat` does it for you:

```js
const format = (minor, currency) => {
  const f = new Intl.NumberFormat('en-GB', { style: 'currency', currency })
  const digits = f.resolvedOptions().maximumFractionDigits
  return f.format(minor / 10 ** digits)
}
format(1234, 'GBP') // "£12.34"
```

Prices are before tax. The workspace’s wording for this (for example “ex VAT”) is `taxLabel` on [`GET /workspace`](/docs/api/workspace).

## Percentages

Percentages are **basis points**: hundredths of a percent, as integers.

| Value   | Means |
| ------- | ----- |
| `1250`  | 12.5% |
| `3500`  | 35%   |
| `10000` | 100%  |

## Dates

Dates are ISO 8601 strings in UTC, like `2026-09-27T08:15:00.000Z`. The workspace’s own timezone is `timezone` on [`GET /workspace`](/docs/api/workspace).

## Ids

Ids are UUID strings. Wherever an endpoint takes a **customer**, you can pass the customer’s id or their account code (case-insensitive), whichever your system has:

```bash
curl "https://tradecatalog.app/api/v1/prices?customer=OAK01&sku=HX-8510" \
  -H "Authorization: Bearer $TRADECATALOG_API_KEY"
```

## Creating and updating (PUT)

`PUT /products` and `PUT /customers` create or update in one call, up to 100 rows at a time:

- Products are matched by **SKU** (case-insensitive).
- Customers are matched by **account code**, else **email**, else **name**.

Both are safe to repeat. Sending the same rows twice gives the same result; the second call only reports them as updated.

A `PUT` writes the fields you send. Some optional product fields are cleared when you leave them out, others are kept. Each endpoint’s page lists which.

## Rate limits

Each key can make **120 requests a minute**. Over that, you get `429 rate_limited` with `Retry-After: 60`. Wait and try again. [Errors](/docs/api/errors#retrying) has a retry helper.

Batch writes where you can: one `PUT` with 100 products counts as one request.

## Versioning

The version is in the path: `/api/v1`.

- Within v1 we only make **additive** changes: new endpoints, new optional parameters, new fields in responses. Write your code to ignore fields it doesn’t know.
- A breaking change would get a new version (`/api/v2`), with v1 kept running and the change announced in the [changelog](/docs/changelog).

Source: https://tradecatalog.app/docs/api/conventions

---

# Errors

> The error shape, every error code and how to retry safely.

Errors use normal HTTP status codes and a JSON body with a stable `code` you can check in your code, and a `message` written for people.

```json
{
  "error": {
    "code": "validation_error",
    "message": "Some fields are invalid.",
    "issues": [
      {
        "path": "products.0.listPrice",
        "message": "Too small: expected number to be >=0"
      }
    ]
  }
}
```

`issues` is only there for `validation_error`. Each issue’s `path` points at the field, with list positions counted from 0.

## Error codes

| Status | Code                 | What happened                                                                            | What to do                                                               |
| ------ | -------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| 400    | `validation_error`   | A query parameter or body field is missing or invalid.                                   | Fix the fields listed in `issues`.                                       |
| 400    | `invalid_json`       | The body isn’t valid JSON.                                                               | Send JSON with `Content-Type: application/json`.                         |
| 400    | `invalid_request`    | The request is valid but not allowed right now, e.g. marking a new order as completed.   | Read `message`; don’t retry unchanged.                                   |
| 401    | `unauthorized`       | Missing, unknown or revoked API key.                                                     | Check the `Authorization: Bearer` header and that the key is still live. |
| 403    | `insufficient_scope` | A read-only key tried to write.                                                          | Use a Read & write key.                                                  |
| 403    | `read_only`          | The workspace’s free trial or subscription has ended, so it’s read-only.                 | The owner subscribes in Settings. Reads still work.                      |
| 403    | `forbidden`          | Not allowed for this key.                                                                | Contact support if you think this is wrong.                              |
| 404    | `not_found`          | No such endpoint, or no such product, customer, order or price change in this workspace. | Check the id, SKU or account code.                                       |
| 405    | `method_not_allowed` | The path exists but not with this method.                                                | Use a method from the `Allow` header.                                    |
| 429    | `rate_limited`       | More than 120 requests a minute with this key.                                           | Wait for `Retry-After` seconds (60), then retry.                         |
| 500    | `internal_error`     | Something went wrong on our side.                                                        | Retry with backoff. If it keeps happening, contact support.              |

Ids from another workspace always give `404 not_found`, never someone else’s data.

## Retrying

Retry `429` and `5xx` responses with backoff. Don’t retry other `4xx` responses: they’ll fail the same way.

All API writes are safe to repeat: `PUT` matches rows by SKU or account code, and `PATCH /orders/{id}` fails with `invalid_request` if the order has already moved on. So a retry after a network timeout won’t duplicate anything.

```js
async function api(path, init = {}, attempt = 0) {
  const res = await fetch(`https://tradecatalog.app/api/v1${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${process.env.TRADECATALOG_API_KEY}`,
      'Content-Type': 'application/json',
      ...init.headers,
    },
  })
  if ((res.status === 429 || res.status >= 500) && attempt < 5) {
    const wait = Number(res.headers.get('Retry-After')) || 2 ** attempt
    await new Promise((resolve) => setTimeout(resolve, wait * 1000))
    return api(path, init, attempt + 1)
  }
  const body = await res.json()
  if (!res.ok) {
    const error = new Error(`${body.error.code}: ${body.error.message}`)
    error.status = res.status
    error.code = body.error.code
    error.issues = body.error.issues
    throw error
  }
  return body
}
```

## Common messages

| Request                                        | Status and message                                          |
| ---------------------------------------------- | ----------------------------------------------------------- |
| `GET /prices` with an unknown SKU              | `404` “No product with SKU HX-9999”                         |
| Any endpoint with an unknown customer          | `404` “Customer OAK99 not found”                            |
| `PATCH /orders/{id}` new → completed           | `400` “An order that is received can’t be marked completed” |
| `PATCH /orders/{id}` to cancelled with no note | `400` “Give a reason for cancelling”                        |
| `PUT /products` with the same SKU twice        | `400` validation error “Duplicate SKU hx-8510”              |

Source: https://tradecatalog.app/docs/api/errors

---

# Workspace

> Check which workspace a key belongs to, and read its currency, timezone and tax wording.

## Get the workspace

**GET** `/api/v1/workspace`

Returns the workspace the key belongs to, plus the key’s own name and scope. Call it once when your integration starts, to check the key and pick up the currency.

No parameters.

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

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

```json
{
  "data": {
    "id": "0b6c1f5e-6f7a-4a57-9a0c-2f4d4c1e8a11",
    "slug": "northgate",
    "name": "Northgate Fixings",
    "currency": "GBP",
    "timezone": "Europe/London",
    "taxLabel": "ex VAT",
    "key": { "name": "Sage stock sync", "scope": "write" }
  }
}
```

| Field       | Type   | Description                                                                        |
| ----------- | ------ | ---------------------------------------------------------------------------------- |
| `id`        | string | The workspace id.                                                                  |
| `slug`      | string | The workspace address. The trade portal is at `https://tradecatalog.app/p/{slug}`. |
| `name`      | string | The name customers know the business by.                                           |
| `currency`  | string | ISO 4217 code. Every price is in minor units of this currency.                     |
| `timezone`  | string | IANA timezone, e.g. `Europe/London`. Dates in the API are UTC.                     |
| `taxLabel`  | string | Wording shown next to prices, e.g. “ex VAT”. Prices are before tax.                |
| `key.name`  | string | The name the owner gave this key.                                                  |
| `key.scope` | string | `read` or `write`.                                                                 |

Source: https://tradecatalog.app/docs/api/workspace

---

# 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).

## 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",
  "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": 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.        |
| `leadTimeNote`          | string or null | e.g. “3–5 working days”.                                                                                                                 |
| `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. |

`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** and its id is returned as `priceChangeId`. |

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:** A call that changes prices starts a draft called “API import (key name)”. To keep a whole sync in one draft, send the `priceChangeId` from the first call with every later call. It must still be a draft: once it’s published, cancelled or discarded, leave `priceChangeId` 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 price change from an earlier call. Changed prices are added to it instead of a new draft. 404 if it doesn’t exist, 400 if it isn’t a draft any more. |
| `products[].sku`             | string  | yes      | Up to 64 characters.                                                                                                                                         |
| `products[].name`            | string  | yes      | Up to 200 characters.                                                                                                                                        |
| `products[].listPrice`       | integer | yes      | Minor units, 0 to 1,000,000,000.                                                                                                                             |
| `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[].leadTimeNote`    | string  | no       | Up to 200 characters. 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",
    "unknownCustomers": []
  }
}
```

| Field              | Description                                                                                    |
| ------------------ | ---------------------------------------------------------------------------------------------- |
| `created`          | New products.                                                                                  |
| `updated`          | Existing products matched by SKU.                                                              |
| `priceChanges`     | Products whose new list price went into the draft.                                             |
| `priceChangeId`    | The draft’s id, or `null` if no prices changed.                                                |
| `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`.

Source: https://tradecatalog.app/docs/api/products

---

# Prices

> What a customer pays for a SKU at a quantity, and exactly which rule set that price.

TradeCatalog works out every customer price in one place, the same way for the app, the trade portal, MCP and the API. You never calculate prices yourself.

## How prices are worked out

The most specific rule wins, and **discounts never stack**:

1. **Agreed price.** If the customer has an agreed (fixed) price for the product, that’s the price.
2. Otherwise the **list price minus one percentage**, the first that applies:
   1. the customer’s discount for the product’s **category**
   2. the customer’s discount for the product’s **brand**
   3. the customer’s own **account discount**
3. Otherwise the **list price**.

A category rule of 10% and an account discount of 5% give 10% off, not 15%.

The discounted unit price is rounded half up to a whole minor unit. The line total is `unit × qty`, so a line never has fractions of a penny.

List prices and agreed prices are versioned by date. The API always gives the price valid **now**. Future prices from a scheduled price change apply from their date.

## The price object

Every product’s `price` has this shape. `GET /prices` returns the same fields at the top level of `data`.

```json
{
  "unit": 1573,
  "line": 15730,
  "list": 1850,
  "discountBp": 1500,
  "source": "brand",
  "label": "Brand discount −15%"
}
```

| Field        | Type            | Description                                                             |
| ------------ | --------------- | ----------------------------------------------------------------------- |
| `unit`       | integer or null | Price for one unit, in minor units. `null` if the product has no price. |
| `line`       | integer or null | `unit × qty`.                                                           |
| `list`       | integer or null | The list price now.                                                     |
| `discountBp` | integer         | The discount applied, in basis points. `0` for agreed and list prices.  |
| `source`     | string          | Which rule set the price: see below.                                    |
| `label`      | string          | Wording buyers see next to the price.                                   |

| `source`   | Meaning                      | Example `label`          |
| ---------- | ---------------------------- | ------------------------ |
| `fixed`    | The customer’s agreed price  | Agreed price             |
| `category` | Category discount            | Category discount −12.5% |
| `brand`    | Brand discount               | Brand discount −15%      |
| `account`  | The customer’s own discount  | Your discount −5%        |
| `list`     | List price, no discount      | (empty)                  |
| `unpriced` | The product has no price yet | (empty)                  |

## Check a customer price

**GET** `/api/v1/prices`

What a customer pays for one SKU at a quantity, and why. Use it to answer “what does Oakfield pay for 10 boxes of HX-8510?”.

| Name       | Type    | Required | Description                  |
| ---------- | ------- | -------- | ---------------------------- |
| `customer` | string  | yes      | Customer id or account code. |
| `sku`      | string  | yes      | The SKU, ignoring case.      |
| `qty`      | integer | no       | 1 to 1,000,000. Default 1.   |

```bash
curl "https://tradecatalog.app/api/v1/prices?customer=OAK01&sku=HX-8510&qty=10" \
  -H "Authorization: Bearer $TRADECATALOG_API_KEY"
```

```js
const params = new URLSearchParams({
  customer: 'OAK01',
  sku: 'HX-8510',
  qty: '10',
})
const res = await fetch(`https://tradecatalog.app/api/v1/prices?${params}`, {
  headers: { Authorization: `Bearer ${process.env.TRADECATALOG_API_KEY}` },
})
const { data: price, currency } = await res.json()
```

```json
{
  "data": {
    "customer": {
      "id": "7d2e4f0a-1b3c-4d5e-8f90-a1b2c3d4e5f6",
      "accountCode": "OAK01",
      "name": "Oakfield Joinery"
    },
    "sku": "HX-8510",
    "name": "Hex bolt M10 x 50 zinc",
    "qty": 10,
    "unit": 1573,
    "line": 15730,
    "list": 1850,
    "discountBp": 1500,
    "source": "brand",
    "label": "Brand discount −15%"
  },
  "currency": "GBP"
}
```

| Status | Code               | When                                                |
| ------ | ------------------ | --------------------------------------------------- |
| 400    | `validation_error` | `customer` or `sku` missing, or `qty` out of range. |
| 404    | `not_found`        | No such customer, or no product with that SKU.      |

This endpoint checks the price only. It doesn’t check whether the customer can see the product. For that, use [`GET /products/{id}?customer=…`](/docs/api/products#get-one-product).

## Prices for many products

To price a whole page of products for one customer, use `GET /products?customer=OAK01`. Every row’s `price` is that customer’s price, and customer-only products for other customers are left out. See [Products](/docs/api/products#list-or-search-products).

## Changing prices

- **List prices** change through price changes, so customers get notice. Send new prices with [`PUT /products`](/docs/api/products#create-or-update-products); they go into a draft that you publish in the app.
- **Discounts and agreed prices** are set per customer in the app. Read them with [`GET /customers/{id}`](/docs/api/customers#get-one-customer).
- A customer’s **account discount** can be set with [`PUT /customers`](/docs/api/customers#create-or-update-customers) (`discountBp`).

Source: https://tradecatalog.app/docs/api/prices

---

# Customers

> List trade customers, read one customer's discounts, agreed prices and buyers, and create or update customers.

A customer is a trade account: a business you sell to, with an account code, an optional account discount, and the people (buyers) who can sign in to order for it.

## The customer object

```json
{
  "id": "7d2e4f0a-1b3c-4d5e-8f90-a1b2c3d4e5f6",
  "accountCode": "OAK01",
  "name": "Oakfield Joinery",
  "legalName": "Oakfield Joinery Ltd",
  "email": "accounts@oakfieldjoinery.co.uk",
  "discountBp": 500,
  "active": true,
  "newsOptOut": false,
  "createdAt": "2026-07-02T10:12:44.000Z"
}
```

| Field         | Type            | Description                                                                   |
| ------------- | --------------- | ----------------------------------------------------------------------------- |
| `id`          | string          | Customer id.                                                                  |
| `accountCode` | string          | Your account code. Unique in the workspace.                                   |
| `name`        | string          | Trading name.                                                                 |
| `legalName`   | string or null  | Registered name, when different.                                              |
| `email`       | string or null  | Main contact email, lowercased.                                               |
| `discountBp`  | integer or null | The customer’s own discount on everything, in basis points. `null` for none.  |
| `active`      | boolean         | Inactive customers can’t sign in or order.                                    |
| `newsOptOut`  | boolean         | The customer turned off offers and news emails. Price-change emails still go. |
| `createdAt`   | string          | When the customer was added.                                                  |

Anywhere the API takes a customer, you can use the `id` or the `accountCode`.

## List customers

**GET** `/api/v1/customers`

Every customer, sorted by name.

| Name     | Type    | Required | Description              |
| -------- | ------- | -------- | ------------------------ |
| `limit`  | integer | no       | 1 to 100. Default 100.   |
| `offset` | integer | no       | Rows to skip. Default 0. |

```bash
curl "https://tradecatalog.app/api/v1/customers?limit=100" \
  -H "Authorization: Bearer $TRADECATALOG_API_KEY"
```

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

```json
{
  "data": [
    {
      "id": "7d2e4f0a-1b3c-4d5e-8f90-a1b2c3d4e5f6",
      "accountCode": "OAK01",
      "name": "Oakfield Joinery",
      "legalName": "Oakfield Joinery Ltd",
      "email": "accounts@oakfieldjoinery.co.uk",
      "discountBp": 500,
      "active": true,
      "newsOptOut": false,
      "createdAt": "2026-07-02T10:12:44.000Z"
    }
  ],
  "nextOffset": null
}
```

## Get one customer

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

The customer plus everything that affects their prices, and who can sign in for them.

| Name | In   | Type   | Required | Description                  |
| ---- | ---- | ------ | -------- | ---------------------------- |
| `id` | path | string | yes      | Customer id or account code. |

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

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

```json
{
  "data": {
    "id": "7d2e4f0a-1b3c-4d5e-8f90-a1b2c3d4e5f6",
    "accountCode": "OAK01",
    "name": "Oakfield Joinery",
    "legalName": "Oakfield Joinery Ltd",
    "email": "accounts@oakfieldjoinery.co.uk",
    "discountBp": 500,
    "active": true,
    "newsOptOut": false,
    "createdAt": "2026-07-02T10:12:44.000Z",
    "discountRules": [
      {
        "id": "e1f2a3b4-5c6d-4e7f-8a9b-0c1d2e3f4a5b",
        "scope": "brand",
        "discountBp": 1500,
        "target": "Northgate"
      },
      {
        "id": "f2a3b4c5-6d7e-4f80-9a1b-2c3d4e5f6a7b",
        "scope": "category",
        "discountBp": 1250,
        "target": "Hinges"
      }
    ],
    "agreedPrices": [
      {
        "id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
        "price": 1450,
        "note": "Contract price to March 2027",
        "sku": "HX-8512",
        "name": "Hex bolt M12 x 50 zinc"
      }
    ],
    "buyers": [{ "name": "Sam Patel", "email": "sam@oakfieldjoinery.co.uk" }],
    "pendingInvitations": [{ "email": "orders@oakfieldjoinery.co.uk" }]
  },
  "currency": "GBP"
}
```

| Field                | Description                                                                           |
| -------------------- | ------------------------------------------------------------------------------------- |
| `discountRules`      | Category and brand discounts. `scope` is `category` or `brand`; `target` is its name. |
| `agreedPrices`       | Agreed (fixed) prices valid now, per SKU, in minor units.                             |
| `buyers`             | People who can sign in and order for this customer.                                   |
| `pendingInvitations` | Invitations sent but not yet accepted, and not expired.                               |

See [how prices are worked out](/docs/api/prices#how-prices-are-worked-out) for how these combine.

## Create or update customers

**PUT** `/api/v1/customers`

Send up to **100 customers** per call. Each row is matched to an existing customer like this:

1. If `accountCode` is set, by account code. No match means a new customer with that code.
2. Otherwise, if `email` is set, by email. No match means a new customer.
3. Otherwise by name, ignoring case.

New customers without an account code get one made from their name.

### Body

| Field                     | Type            | Required | Description                                                       |
| ------------------------- | --------------- | -------- | ----------------------------------------------------------------- |
| `customers`               | array           | yes      | 1 to 100 customers. Account codes must be unique within the call. |
| `customers[].accountCode` | string or null  | yes      | Up to 50 characters. `null` to match by email or name.            |
| `customers[].name`        | string          | yes      | Trading name, up to 160 characters.                               |
| `customers[].email`       | string or null  | yes      | Main contact email. `null` clears it.                             |
| `customers[].discountBp`  | integer or null | yes      | Account discount, 0 to 10,000. `null` clears it.                  |

Every field is required, and written as sent: `null` clears `email` and `discountBp`. That way a missing field in your code can’t silently leave an old discount in place.

Updating a customer doesn’t email anyone or invite buyers. Invite buyers from the customer’s page in the app. Registered name, active status, discount rules and agreed prices are changed in the app too.

```bash
curl -X PUT https://tradecatalog.app/api/v1/customers \
  -H "Authorization: Bearer $TRADECATALOG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "customers": [
      {
        "accountCode": "OAK01",
        "name": "Oakfield Joinery",
        "email": "accounts@oakfieldjoinery.co.uk",
        "discountBp": 500
      },
      {
        "accountCode": "BRK07",
        "name": "Brookside Builders",
        "email": null,
        "discountBp": null
      }
    ]
  }'
```

```js
const res = await fetch('https://tradecatalog.app/api/v1/customers', {
  method: 'PUT',
  headers: {
    Authorization: `Bearer ${process.env.TRADECATALOG_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ customers }),
})
const { data } = await res.json()
console.log(`${data.created} new, ${data.updated} updated`)
```

```json
{
  "data": { "created": 1, "updated": 1 }
}
```

The change appears in the activity log as the key’s name.

Source: https://tradecatalog.app/docs/api/customers

---

# Orders

> Read orders with their lines and history, and move them on from new to acknowledged, dispatched and completed.

Buyers place orders in their trade portal. TradeCatalog doesn’t take payment: you invoice as usual. Use the API to pull orders into your ERP or accounts system and to keep their status up to date.

## Order statuses

| Status         | Meaning                              | Can move to                 |
| -------------- | ------------------------------------ | --------------------------- |
| `new`          | Just placed                          | `acknowledged`, `cancelled` |
| `acknowledged` | You’ve seen it and it’s being picked | `dispatched`, `cancelled`   |
| `dispatched`   | On its way                           | `completed`                 |
| `completed`    | Done                                 | nothing                     |
| `cancelled`    | Cancelled, with a reason             | nothing                     |

## List orders

**GET** `/api/v1/orders`

Orders newest first. The list covers the **200 most recent** orders that match your filters. To pick up new orders, poll with `status=new`.

| Name       | Type    | Required | Description                  |
| ---------- | ------- | -------- | ---------------------------- |
| `status`   | string  | no       | One of the statuses above.   |
| `customer` | string  | no       | Customer id or account code. |
| `limit`    | integer | no       | 1 to 100. Default 100.       |
| `offset`   | integer | no       | Rows to skip. Default 0.     |

```bash
curl "https://tradecatalog.app/api/v1/orders?status=new" \
  -H "Authorization: Bearer $TRADECATALOG_API_KEY"
```

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

```json
{
  "data": [
    {
      "id": "5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e",
      "reference": "SO-000142",
      "status": "new",
      "total": 31460,
      "currency": "GBP",
      "poNumber": "PO-88231",
      "createdAt": "2026-09-27T07:48:12.000Z",
      "customer": "Oakfield Joinery"
    }
  ],
  "nextOffset": null
}
```

| Field       | Type           | Description                                                  |
| ----------- | -------------- | ------------------------------------------------------------ |
| `id`        | string         | Order id.                                                    |
| `reference` | string         | Order number buyers see, like `SO-000142`.                   |
| `status`    | string         | See [statuses](#order-statuses).                             |
| `total`     | integer        | Order total before tax, in minor units of `currency`.        |
| `currency`  | string         | Fixed when the order was placed.                             |
| `poNumber`  | string or null | The buyer’s purchase order number.                           |
| `createdAt` | string         | When it was placed.                                          |
| `customer`  | string         | Customer name. Get the account code from `GET /orders/{id}`. |

## Get one order

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

The order with its lines and status history.

| Name | In   | Type   | Required | Description |
| ---- | ---- | ------ | -------- | ----------- |
| `id` | path | string | yes      | Order id.   |

```bash
curl https://tradecatalog.app/api/v1/orders/5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e \
  -H "Authorization: Bearer $TRADECATALOG_API_KEY"
```

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

```json
{
  "data": {
    "id": "5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e",
    "customerAccountId": "7d2e4f0a-1b3c-4d5e-8f90-a1b2c3d4e5f6",
    "placedBy": "Fq3L0cZ7tN1pW9aYx2Rk",
    "reference": "SO-000142",
    "poNumber": "PO-88231",
    "notes": "Deliver to the Hebden Road site, gate 2.",
    "status": "new",
    "cancelReason": null,
    "currency": "GBP",
    "total": 31460,
    "createdAt": "2026-09-27T07:48:12.000Z",
    "updatedAt": "2026-09-27T07:48:12.000Z",
    "customer": { "name": "Oakfield Joinery", "accountCode": "OAK01" },
    "lines": [
      {
        "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
        "orderId": "5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e",
        "productId": "3f8a2c1e-9b4d-4e6f-a7c8-1d2e3f4a5b6c",
        "sku": "HX-8510",
        "name": "Hex bolt M10 x 50 zinc",
        "unitLabel": "Box of 100",
        "qty": 20,
        "unitPrice": 1573,
        "listPrice": 1850,
        "priceLabel": "Brand discount −15%",
        "lineTotal": 31460
      }
    ],
    "events": [
      {
        "status": "new",
        "note": null,
        "createdAt": "2026-09-27T07:48:12.000Z",
        "actor": "Sam Patel"
      }
    ]
  }
}
```

| Field                | Description                                                                                                                                  |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `customerAccountId`  | The customer’s id.                                                                                                                           |
| `placedBy`           | Id of the buyer who placed it.                                                                                                               |
| `notes`              | The buyer’s delivery or order notes.                                                                                                         |
| `cancelReason`       | Why it was cancelled, if it was.                                                                                                             |
| `lines`              | What was ordered at what price. A snapshot: later price changes never alter it. `productId` is `null` if the product has since been deleted. |
| `lines[].priceLabel` | The rule that set the price, as on [Prices](/docs/api/prices#the-price-object).                                                              |
| `events`             | Status history, oldest first. `actor` is the person’s name, or `null`.                                                                       |

## Move an order on

**PATCH** `/api/v1/orders/{id}`

Changes an order’s status. Returns the updated order in the same shape as `GET /orders/{id}`.

- Only the moves in the [status table](#order-statuses) are allowed. Anything else is `400 invalid_request`.
- Cancelling needs a `note`, which is shown to the buyer as the reason.
- The buyer who placed the order gets an email each time, with the note if you send one.
- The change is recorded in the order’s history against the owner who made the key.

| Name     | In   | Type   | Required | Description                                       |
| -------- | ---- | ------ | -------- | ------------------------------------------------- |
| `id`     | path | string | yes      | Order id.                                         |
| `status` | body | string | yes      | The new status.                                   |
| `note`   | body | string | no       | Up to 1,000 characters. Required when cancelling. |

```bash
curl -X PATCH https://tradecatalog.app/api/v1/orders/5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e \
  -H "Authorization: Bearer $TRADECATALOG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "dispatched", "note": "DPD tracking 1554 2210 0931" }'
```

```js
const res = await fetch(`https://tradecatalog.app/api/v1/orders/${orderId}`, {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${process.env.TRADECATALOG_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ status: 'acknowledged' }),
})
const { data: order } = await res.json()
```

| Status | Code                 | When                                                                                                              |
| ------ | -------------------- | ----------------------------------------------------------------------------------------------------------------- |
| 400    | `invalid_request`    | The move isn’t allowed, e.g. “An order that is received can’t be marked completed”, or cancelling without a note. |
| 403    | `insufficient_scope` | The key is read-only.                                                                                             |
| 404    | `not_found`          | No such order in this workspace.                                                                                  |

Retrying a `PATCH` that already succeeded returns `400 invalid_request`, because the order has moved on. Check the order’s status before treating that as a failure.

## Not available

The API can’t place orders for a buyer, or change an order’s lines, quantities or prices. Buyers place orders in their trade portal; changes to an order are agreed with the customer and invoiced in your own system.

Source: https://tradecatalog.app/docs/api/orders

---

# Price changes

> List price changes and preview one, including what a customer will pay before and after.

List prices never change silently in TradeCatalog. New list prices go into a **price change**: a draft that staff review, date and publish, optionally emailing every customer their own new prices.

## How price changes are made

- **From the API:** [`PUT /products`](/docs/api/products#create-or-update-products) with a new `listPrice` for an existing product adds it to a draft and returns the draft’s `priceChangeId`.
- **From the app:** spreadsheet imports, and bulk changes like “+4% on the Bolts category”.

Publishing, scheduling and cancelling happen in the app, under **Price changes**. There’s no API for them: a person always checks a price change before customers see it.

## Statuses

| Status      | Meaning                                                   |
| ----------- | --------------------------------------------------------- |
| `draft`     | Not published. No prices have changed.                    |
| `scheduled` | Published with a future date. Prices change on that date. |
| `live`      | In effect.                                                |
| `cancelled` | Cancelled before it took effect.                          |

## The price change object

```json
{
  "id": "c4d5e6f7-1234-4abc-9def-0123456789ab",
  "source": "import",
  "title": "API import (Sage stock sync)",
  "supplierNote": null,
  "status": "draft",
  "effectiveAt": null,
  "notifyCustomers": true,
  "raiseFixedPrices": false,
  "createdBy": "Fq3L0cZ7tN1pW9aYx2Rk",
  "publishedAt": null,
  "cancelledAt": null,
  "createdAt": "2026-09-27T02:00:14.000Z",
  "items": 38
}
```

| Field              | Type           | Description                                                                    |
| ------------------ | -------------- | ------------------------------------------------------------------------------ |
| `source`           | string         | `import` (spreadsheet or API) or `bulk` (a percentage change made in the app). |
| `title`            | string         | Shown to staff. API drafts are titled “API import (key name)”.                 |
| `supplierNote`     | string or null | Message to customers in the price-change email.                                |
| `status`           | string         | See [statuses](#statuses).                                                     |
| `effectiveAt`      | string or null | When the new prices apply.                                                     |
| `notifyCustomers`  | boolean        | Whether customers are emailed their new prices.                                |
| `raiseFixedPrices` | boolean        | Whether agreed prices rise by the same percentage.                             |
| `createdBy`        | string or null | Id of the person who made it. For API drafts, the owner who made the key.      |
| `items`            | integer        | How many products it changes. Only in the list.                                |

## List price changes

**GET** `/api/v1/price-changes`

Newest first. Covers the **100 most recent** price changes.

| Name     | Type    | Required | Description              |
| -------- | ------- | -------- | ------------------------ |
| `limit`  | integer | no       | 1 to 100. Default 100.   |
| `offset` | integer | no       | Rows to skip. Default 0. |

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

```js
const res = await fetch('https://tradecatalog.app/api/v1/price-changes', {
  headers: { Authorization: `Bearer ${process.env.TRADECATALOG_API_KEY}` },
})
const { data: changes } = await res.json()
const drafts = changes.filter((change) => change.status === 'draft')
```

## Preview a price change

**GET** `/api/v1/price-changes/{id}`

Everything staff see before publishing: how many prices go up and down, the biggest moves, warnings, and how many agreed prices it touches. Pass `customer` to see that customer’s own prices before and after.

| Name       | In    | Type   | Required | Description                  |
| ---------- | ----- | ------ | -------- | ---------------------------- |
| `id`       | path  | string | yes      | Price change id.             |
| `customer` | query | string | no       | Customer id or account code. |

```bash
curl "https://tradecatalog.app/api/v1/price-changes/c4d5e6f7-1234-4abc-9def-0123456789ab?customer=OAK01" \
  -H "Authorization: Bearer $TRADECATALOG_API_KEY"
```

```js
const res = await fetch(
  `https://tradecatalog.app/api/v1/price-changes/${priceChangeId}?customer=OAK01`,
  { headers: { Authorization: `Bearer ${process.env.TRADECATALOG_API_KEY}` } },
)
const { data: preview } = await res.json()
if (preview.warnings.length)
  console.warn('Check these before publishing', preview.warnings)
```

```json
{
  "data": {
    "change": {
      "id": "c4d5e6f7-1234-4abc-9def-0123456789ab",
      "source": "import",
      "title": "API import (Sage stock sync)",
      "supplierNote": null,
      "status": "draft",
      "effectiveAt": null,
      "notifyCustomers": true,
      "raiseFixedPrices": false,
      "createdBy": "Fq3L0cZ7tN1pW9aYx2Rk",
      "publishedAt": null,
      "cancelledAt": null,
      "createdAt": "2026-09-27T02:00:14.000Z"
    },
    "counts": { "total": 38, "up": 36, "down": 2 },
    "biggest": [
      {
        "productId": "3f8a2c1e-9b4d-4e6f-a7c8-1d2e3f4a5b6c",
        "oldPrice": 1850,
        "newPrice": 1950,
        "sku": "HX-8510",
        "name": "Hex bolt M10 x 50 zinc",
        "changeBp": 541
      }
    ],
    "warnings": [],
    "fixedAffected": 3,
    "customer": {
      "name": "Oakfield Joinery",
      "rows": [
        {
          "sku": "HX-8510",
          "name": "Hex bolt M10 x 50 zinc",
          "before": 1573,
          "after": 1658,
          "label": "Brand discount −15%"
        }
      ]
    }
  },
  "currency": "GBP"
}
```

| Field           | Description                                                                                                     |
| --------------- | --------------------------------------------------------------------------------------------------------------- |
| `counts`        | Products in the change, and how many go up and down.                                                            |
| `biggest`       | Up to 20 items with the largest percentage move. `changeBp` is the change in basis points (541 = +5.41%).       |
| `warnings`      | Items that move by more than 30% either way, or go to zero. Usually typos.                                      |
| `fixedAffected` | Agreed prices on these products that are valid now. They only change if the change raises agreed prices.        |
| `customer`      | With `customer`: that customer’s unit prices before and after, for up to the first 50 items. `null` without it. |

`404 not_found` if there’s no such price change in this workspace.

Source: https://tradecatalog.app/docs/api/price-changes

---

# Activity

> The workspace's activity log of who changed customers, discounts and prices, newest first.

TradeCatalog records a plain-English line for every staff change to customers, discounts and prices. Changes made through the API are recorded under the key’s name, like `API key “Sage stock sync”`.

## List activity

**GET** `/api/v1/activity`

Newest first. Pass `customer` for one customer’s history.

| Name       | Type    | Required | Description                  |
| ---------- | ------- | -------- | ---------------------------- |
| `customer` | string  | no       | Customer id or account code. |
| `limit`    | integer | no       | 1 to 100. Default 100.       |

This list doesn’t page: it returns the most recent `limit` entries.

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

```js
const res = await fetch(
  'https://tradecatalog.app/api/v1/activity?customer=OAK01&limit=20',
  {
    headers: { Authorization: `Bearer ${process.env.TRADECATALOG_API_KEY}` },
  },
)
const { data: entries } = await res.json()
```

```json
{
  "data": [
    {
      "id": "d0e1f2a3-b4c5-4d6e-8f7a-9b0c1d2e3f4a",
      "actorName": "Jo Hartley",
      "summary": "Set Northgate brand discount to 15% (was 12.5%)",
      "createdAt": "2026-09-26T14:21:03.000Z",
      "customerAccountId": "7d2e4f0a-1b3c-4d5e-8f90-a1b2c3d4e5f6",
      "customerName": "Oakfield Joinery"
    },
    {
      "id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
      "actorName": "API key “Sage stock sync”",
      "summary": "Imported customers: 0 new, 42 updated",
      "createdAt": "2026-09-26T02:00:09.000Z",
      "customerAccountId": null,
      "customerName": null
    }
  ]
}
```

| Field               | Type           | Description                                                        |
| ------------------- | -------------- | ------------------------------------------------------------------ |
| `actorName`         | string         | Who made the change. Kept even if that person later leaves.        |
| `summary`           | string         | What changed, in plain English. Meant for people, not for parsing. |
| `createdAt`         | string         | When.                                                              |
| `customerAccountId` | string or null | The customer it was about, if any.                                 |
| `customerName`      | string or null | That customer’s current name.                                      |

Order status changes aren’t in this log; they’re in each order’s `events`. See [Orders](/docs/api/orders#get-one-order).

Source: https://tradecatalog.app/docs/api/activity

---

# OpenAPI

> The OpenAPI 3.1 description of the TradeCatalog API, and how to use it with Postman, typed clients and API explorers.

The whole API is described in one OpenAPI 3.1 document:

```text
https://tradecatalog.app/api/v1/openapi.json
```

It’s public (no key needed) and generated from the same schemas the API uses to check requests, so it can’t drift from what the API accepts. It includes every path, parameter, request body, limit and enum value.

## Import into Postman or Insomnia

1. In Postman, choose **Import** and paste the URL above. In Insomnia, choose **Import** then **From URL**.
2. Set the collection’s auth to **Bearer Token** with your API key.

## Generate TypeScript types

[openapi-typescript](https://openapi-ts.dev) turns it into types:

```bash
npx openapi-typescript https://tradecatalog.app/api/v1/openapi.json -o tradecatalog.d.ts
```

Then use them with `openapi-fetch` for a typed client:

```js
import createClient from 'openapi-fetch'

const client = createClient({
  baseUrl: 'https://tradecatalog.app/api/v1',
  headers: { Authorization: `Bearer ${process.env.TRADECATALOG_API_KEY}` },
})

const { data, error } = await client.GET('/prices', {
  params: { query: { customer: 'OAK01', sku: 'HX-8510', qty: 10 } },
})
```

The document describes request parameters and bodies in full. Response bodies are described as the shared `{ data, nextOffset, currency }` envelope; the field-by-field response shapes are on each endpoint’s page in these docs.

## Other languages

Any OpenAPI generator works, for example:

```bash
npx @openapitools/openapi-generator-cli generate \
  -i https://tradecatalog.app/api/v1/openapi.json \
  -g python -o tradecatalog-client
```

## Browse it

Paste the URL into [Scalar](https://scalar.com), [Swagger Editor](https://editor.swagger.io) or any OpenAPI viewer to browse the endpoints and try requests with your key.

## For AI agents

The OpenAPI document is listed in the API catalog at `/.well-known/api-catalog`, so agents can find it on their own. See [AI and LLM resources](/docs/guides/llms).

Source: https://tradecatalog.app/docs/api/openapi

---

# Connect an AI app

> Connect Claude, ChatGPT, Cursor, VS Code or any MCP client to TradeCatalog with your own login.

TradeCatalog runs a remote [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server. Connect it to your AI app and ask things like:

- “What does Oakfield Joinery pay for 20 boxes of HX-8510?”
- “Any new orders today?”
- “Who changed Brookside’s discount, and when?”
- “Find me a zinc hex bolt, M10, and my price for it.” (as a buyer)

## How it works

| Setting        | Value                                                                           |
| -------------- | ------------------------------------------------------------------------------- |
| Server URL     | `https://tradecatalog.app/mcp`                                                  |
| Transport      | Streamable HTTP                                                                 |
| Sign-in        | OAuth 2.1: you sign in with your normal TradeCatalog login and choose **Allow** |
| Access         | **Read-only**. The AI app can look things up but can’t change anything.         |
| Who it acts as | You. It sees exactly what you can see in TradeCatalog.                          |

- **Staff** see their workspaces: products, any customer’s prices, customers, orders and the activity log.
- **Buyers** see the suppliers they buy from, with their own prices and their own orders only.

Every tool call is checked with the same rules as the app. See [MCP tools](/docs/mcp/tools) for what each tool does.

## Claude (web, desktop and mobile)

1. Open **Settings**, then **Connectors**, and choose **Add custom connector**.
2. Name it “TradeCatalog” and paste `https://tradecatalog.app/mcp`.
3. Choose **Connect**. Sign in to TradeCatalog with your email code and choose **Allow**.

Connectors you add on the web also appear in Claude Desktop and the mobile apps. On Team and Enterprise plans an owner may need to add the connector for the organisation first.

## Claude Code

```bash
claude mcp add --transport http tradecatalog https://tradecatalog.app/mcp
```

Then run `/mcp` in Claude Code and choose **tradecatalog** to sign in.

## ChatGPT

1. Open **Settings**, then **Apps & Connectors**. If your plan needs it, turn on **Developer mode** under **Advanced**.
2. Create a connector with the URL `https://tradecatalog.app/mcp` and OAuth authentication.
3. Sign in to TradeCatalog and choose **Allow**.

## Cursor

Add this to `~/.cursor/mcp.json` (or `.cursor/mcp.json` in a project):

```json
{
  "mcpServers": {
    "tradecatalog": { "url": "https://tradecatalog.app/mcp" }
  }
}
```

Cursor opens the TradeCatalog sign-in page the first time you use it.

## VS Code

Add this to `.vscode/mcp.json`:

```json
{
  "servers": {
    "tradecatalog": { "type": "http", "url": "https://tradecatalog.app/mcp" }
  }
}
```

## Other clients

Any client that supports remote MCP over Streamable HTTP with OAuth works. For clients that only run local (stdio) servers, use the `mcp-remote` bridge:

```json
{
  "mcpServers": {
    "tradecatalog": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://tradecatalog.app/mcp"]
    }
  }
}
```

Building your own client? See [OAuth for MCP clients](/docs/mcp/oauth).

## Disconnect an app

See every AI app you’ve approved, and disconnect any of them, at [tradecatalog.app/oauth/connections](/oauth/connections). Removing the connector in your AI app stops that app using it. Disconnecting here also revokes its access on our side, straight away.

## MCP or the REST API?

|                   | MCP                               | REST API                               |
| ----------------- | --------------------------------- | -------------------------------------- |
| For               | People chatting in an AI app      | Your own systems and scripts           |
| Signs in as       | Each person, with their own login | The workspace, with an owner’s API key |
| Buyers can use it | Yes, with their own prices        | No                                     |
| Can change data   | No, read-only                     | Yes, with a Read & write key           |
| Set up by         | Each person, in their AI app      | The owner, in Settings                 |
| Docs              | [Tools](/docs/mcp/tools)          | [REST API](/docs/api)                  |

Source: https://tradecatalog.app/docs/mcp

---

# MCP tools

> Every tool on the TradeCatalog MCP server, its inputs, who can use it and what it returns.

The server has six tools. All are read-only (`readOnlyHint: true`) and only touch TradeCatalog (`openWorldHint: false`). Results come back as JSON text, with prices already formatted as money, like `£15.73`.

Most tools take a `workspace`: the workspace address from `list_workspaces`, like `northgate`. The AI app normally calls `list_workspaces` first on its own.

When a tool can’t do something, for example you aren’t staff at that workspace or the customer doesn’t exist, it returns an error message the AI app can read and act on, like “No customer "OAK99". Use list_customers for account codes.”

## list_workspaces

Supplier workspaces you work in, and trade portals where you buy. Start here.

**Who:** anyone signed in. **Inputs:** none.

```json
{
  "supplierWorkspaces": [
    { "workspace": "northgate", "name": "Northgate Fixings", "role": "owner" }
  ],
  "tradePortals": [
    {
      "workspace": "hartley-timber",
      "supplier": "Hartley Timber",
      "yourAccount": "Northgate Fixings"
    }
  ]
}
```

## search_products

Search a catalogue by SKU, manufacturer reference, name, brand or category. Returns up to **25** products.

**Who:** staff and buyers.

| Input       | Type   | Required | Description                                                                                                        |
| ----------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------ |
| `workspace` | string | yes      | Workspace address.                                                                                                 |
| `query`     | string | yes      | What to search for. Empty lists products by name.                                                                  |
| `customer`  | string | no       | Staff only: a customer’s account code or name, to see their prices and only what they can see. Ignored for buyers. |

- **Buyers** always see their own account’s prices and only active products they can see.
- **Staff** see list prices and every product, or a named customer’s view.

```json
{
  "supplier": "Northgate Fixings",
  "pricesFor": "Oakfield Joinery",
  "taxNote": "ex VAT",
  "products": [
    {
      "sku": "HX-8510",
      "name": "Hex bolt M10 x 50 zinc",
      "unit": "Box of 100",
      "availability": "in_stock",
      "price": "£15.73",
      "listPrice": "£18.50",
      "why": "Brand discount −15%"
    }
  ]
}
```

Try: “Search Northgate for M10 hex bolts at Oakfield’s prices.”

## check_price

What a customer pays for a SKU at a quantity, and which rule set the price.

**Who:** staff only.

| Input       | Type    | Required | Description                |
| ----------- | ------- | -------- | -------------------------- |
| `workspace` | string  | yes      | Workspace address.         |
| `customer`  | string  | yes      | Account code or name.      |
| `sku`       | string  | yes      | The SKU.                   |
| `qty`       | integer | no       | 1 to 1,000,000. Default 1. |

```json
{
  "customer": "Oakfield Joinery",
  "sku": "HX-8510",
  "name": "Hex bolt M10 x 50 zinc",
  "qty": 20,
  "unitPrice": "£15.73",
  "lineTotal": "£314.60",
  "listPrice": "£18.50",
  "why": "Brand discount −15%",
  "taxNote": "ex VAT"
}
```

Try: “What does OAK01 pay for 20 of HX-8510, and why?”

## list_customers

Trade customers with account codes, contact emails, active status and account discount.

**Who:** staff only. **Inputs:** `workspace`.

```json
[
  {
    "accountCode": "OAK01",
    "name": "Oakfield Joinery",
    "email": "accounts@oakfieldjoinery.co.uk",
    "active": true,
    "discount": "5%"
  }
]
```

Try: “Which customers have no discount set?”

## list_orders

Recent orders, newest first, up to **50**.

**Who:** staff see every customer’s orders; buyers see their own account’s.

| Input       | Type   | Required | Description                                                      |
| ----------- | ------ | -------- | ---------------------------------------------------------------- |
| `workspace` | string | yes      | Workspace address.                                               |
| `status`    | string | no       | `new`, `acknowledged`, `dispatched`, `completed` or `cancelled`. |

```json
[
  {
    "reference": "SO-000142",
    "customer": "Oakfield Joinery",
    "status": "new",
    "total": "£314.60",
    "poNumber": "PO-88231",
    "placed": "2026-09-27T07:48:12.000Z"
  }
]
```

Try: “Any new orders since yesterday? Total them up by customer.”

## recent_activity

The activity log of changes to customers, discounts and prices, newest first, up to **50** entries. Optionally one customer’s history.

**Who:** staff only.

| Input       | Type   | Required | Description           |
| ----------- | ------ | -------- | --------------------- |
| `workspace` | string | yes      | Workspace address.    |
| `customer`  | string | no       | Account code or name. |

```json
[
  {
    "when": "2026-09-26T14:21:03.000Z",
    "who": "Jo Hartley",
    "customer": "Oakfield Joinery",
    "what": "Set Northgate brand discount to 15% (was 12.5%)"
  }
]
```

Try: “Who changed Oakfield’s prices this month?”

## Notes

- Customer names and account codes are matched without regard to case. An exact account code is safest.
- A buyer with several accounts at one supplier sees the first account by name. To check another account, use the trade portal.
- For more than 25 products or 50 orders, or to change anything, use the [REST API](/docs/api).

Source: https://tradecatalog.app/docs/mcp/tools

---

# OAuth for MCP clients

> How MCP clients discover, register and get tokens for the TradeCatalog MCP server with OAuth 2.1.

This page is for people building an MCP client or debugging a connection. If you only want to connect Claude or ChatGPT, see [Connect an AI app](/docs/mcp).

TradeCatalog follows the [MCP authorization spec](https://modelcontextprotocol.io/specification/latest/basic/authorization): OAuth 2.1 with PKCE, protected resource metadata (RFC 9728) and authorization server metadata (RFC 8414).

## Endpoints

| What                            | URL                                                                 |
| ------------------------------- | ------------------------------------------------------------------- |
| MCP server (protected resource) | `https://tradecatalog.app/mcp`                                      |
| Protected resource metadata     | `https://tradecatalog.app/.well-known/oauth-protected-resource/mcp` |
| Authorization server metadata   | `https://tradecatalog.app/.well-known/oauth-authorization-server`   |
| Authorization endpoint          | `https://tradecatalog.app/oauth/authorize`                          |
| Token endpoint                  | `https://tradecatalog.app/oauth/token`                              |
| Dynamic client registration     | `https://tradecatalog.app/oauth/register`                           |
| Connected apps (for people)     | `https://tradecatalog.app/oauth/connections`                        |

The protected resource metadata is at the path-specific address for `/mcp`, as RFC 9728 describes for a resource with a path. There’s deliberately nothing at `/.well-known/oauth-protected-resource` on its own.

## Scope

There’s one scope, `tradecatalog:read`. It’s read-only, and every grant gets it.

## Discovery flow

1. The client calls `https://tradecatalog.app/mcp` without a token.
2. The server answers `401 Unauthorized` with a `WWW-Authenticate: Bearer` header pointing at the protected resource metadata.
3. The client reads the protected resource metadata to find the authorization server, `https://tradecatalog.app`.
4. The client reads the authorization server metadata for the endpoints above.

```bash
curl https://tradecatalog.app/.well-known/oauth-protected-resource/mcp
```

```json
{
  "resource": "https://tradecatalog.app/mcp",
  "authorization_servers": ["https://tradecatalog.app"],
  "scopes_supported": ["tradecatalog:read"],
  "bearer_methods_supported": ["header"],
  "resource_name": "TradeCatalog MCP server"
}
```

## Registering a client

Use either:

- **Client ID metadata documents** (preferred): use an `https` URL you control as your `client_id`, serving your client’s metadata. People approving the connection see that URL’s host as the publisher.
- **Dynamic client registration** (RFC 7591) at `/oauth/register`.

Anyone can register a client under any name, so the approval page shows people where the approval is sent (the redirect host) and, for URL client ids, the publisher. The name alone is marked as unverified.

## Authorization

Use the authorization code flow with **PKCE (`S256`)**:

1. Send the person’s browser to `/oauth/authorize` with `response_type=code`, `client_id`, `redirect_uri`, `code_challenge`, `code_challenge_method=S256`, `state` and `scope=tradecatalog:read`.
2. If they aren’t signed in, TradeCatalog asks them to sign in with an emailed code (or Google or Microsoft, where enabled), then brings them back.
3. They see which app is asking, the account they’re signed in as, and what it can do, then choose **Allow** or **Deny**.
4. **Allow** redirects to your `redirect_uri` with `code` and `state`. **Deny** redirects with `error=access_denied`.
5. Exchange the code at `/oauth/token` with your `code_verifier`. You get an access token and a refresh token.

Send the access token on every MCP request:

```http
POST /mcp HTTP/1.1
Host: tradecatalog.app
Authorization: Bearer <access token>
Content-Type: application/json
Accept: application/json, text/event-stream
```

Access tokens are short-lived. Use the refresh token at `/oauth/token` to get a new one.

## Who can approve

- Any signed-in TradeCatalog user: supplier staff or buyers.
- Not demo visitors (guest sessions). They’re sent to sign in properly.
- There’s no way to create an account through this flow, and no machine-only (client credentials) access. For server-to-server access, use the [REST API](/docs/api) with an API key.

The grant carries only the person’s identity. Each tool call re-checks what they can see, so if they lose access to a workspace, the AI app loses it too.

## Transport

The server is **stateless** Streamable HTTP. It doesn’t issue session ids; each request stands alone, and it answers with JSON rather than an event stream.

## Revoking

People see and disconnect approved apps at `/oauth/connections`. Disconnecting revokes the grant and its tokens straight away.

## Also published

- [`/auth.md`](/auth.md): a short summary of this page for agents.
- [`/.well-known/mcp/server-card.json`](/.well-known/mcp/server-card.json): the MCP server card with the tool list.

Source: https://tradecatalog.app/docs/mcp/oauth

---

# Sync with your ERP

> A complete nightly product and customer sync, and an order poller that pulls new orders into your own system.

This guide builds the two jobs most suppliers need:

1. **Nightly sync:** push products and customers from your ERP into TradeCatalog.
2. **Order poller:** every few minutes, pull new orders into your ERP and mark them acknowledged.

You need a **Read & write** API key. See [Authentication](/docs/api/authentication).

## How the nightly sync behaves

- Products are matched by SKU and customers by account code, so running the sync twice is harmless.
- New products go live at their list price straight away.
- Changed list prices **don’t** go live. They collect in draft price changes. Each morning someone opens **Price changes** in the app, checks the preview and warnings, picks a date and publishes. Customers can be emailed their own new prices.
- `brand`, `category`, `manufacturerRef` and `description` are written as sent, so always send the full product.
- Customers’ `email` and `discountBp` are written as sent too: `null` clears them.

**Only send what changed:** If your ERP can tell you which products changed since the last run, send just those. Fewer requests, and fewer products to scan in each draft.

## Nightly sync (Node.js)

Needs Node.js 20 or later, for the built-in `fetch`. Replace the two `fromErp` functions with reads from your system.

```js
// sync.mjs — run nightly: TRADECATALOG_API_KEY=tc_live_… node sync.mjs
const BASE = 'https://tradecatalog.app/api/v1'
const KEY = process.env.TRADECATALOG_API_KEY
const sleep = (seconds) => new Promise((r) => setTimeout(r, seconds * 1000))

async function api(method, path, body, attempt = 0) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      Authorization: `Bearer ${KEY}`,
      'Content-Type': 'application/json',
    },
    body: body && JSON.stringify(body),
  })
  if ((res.status === 429 || res.status >= 500) && attempt < 5) {
    await sleep(Number(res.headers.get('Retry-After')) || 2 ** attempt)
    return api(method, path, body, attempt + 1)
  }
  const json = await res.json()
  if (!res.ok) {
    const detail = json.error.issues
      ?.map((i) => `${i.path}: ${i.message}`)
      .join('; ')
    throw new Error(`${method} ${path}: ${json.error.message} ${detail ?? ''}`)
  }
  return json
}

// Replace these with reads from your ERP. Money in pence, discounts in basis points.
async function productsFromErp() {
  return [
    {
      sku: 'HX-8510',
      name: 'Hex bolt M10 x 50 zinc',
      listPrice: 1950,
      unitLabel: 'Box of 100',
      brand: 'Northgate',
      category: 'Bolts',
      availability: 'in_stock',
    },
  ]
}
async function customersFromErp() {
  return [
    {
      accountCode: 'OAK01',
      name: 'Oakfield Joinery',
      email: 'accounts@oakfieldjoinery.co.uk',
      discountBp: 500,
    },
  ]
}

const batches = (rows, size = 100) =>
  Array.from({ length: Math.ceil(rows.length / size) }, (_, i) =>
    rows.slice(i * size, i * size + size),
  )

const { data: workspace } = await api('GET', '/workspace')
console.log(`Syncing into ${workspace.name} (${workspace.currency})`)

for (const customers of batches(await customersFromErp())) {
  const { data } = await api('PUT', '/customers', { customers })
  console.log(`Customers: ${data.created} new, ${data.updated} updated`)
}

// Every batch adds its price changes to the same draft.
let priceChangeId
for (const products of batches(await productsFromErp())) {
  const { data } = await api('PUT', '/products', { products, priceChangeId })
  console.log(`Products: ${data.created} new, ${data.updated} updated`)
  priceChangeId = data.priceChangeId ?? priceChangeId
  if (data.unknownCustomers.length)
    console.warn('Unknown customers in onlyFor:', data.unknownCustomers)
}

if (priceChangeId) {
  const { data } = await api('GET', `/price-changes/${priceChangeId}`)
  console.log(
    `Draft price change: ${data.counts.up} up, ${data.counts.down} down,` +
      ` ${data.warnings.length} warnings. Publish it in TradeCatalog → Price changes.`,
  )
}
```

Customers go first, so products with `onlyFor` can find them.

Run it from cron, a scheduled task, or your ERP’s job runner:

```text
0 2 * * *  cd /opt/tradecatalog && TRADECATALOG_API_KEY=tc_live_… node sync.mjs >> sync.log 2>&1
```

## Order poller (Node.js)

Every few minutes, fetch new orders, hand each to your ERP, then mark it acknowledged. The buyer gets an email saying their order has been acknowledged.

```js
// orders.mjs — run every 5 minutes. Copy BASE, KEY, sleep and api() from sync.mjs.
async function sendToErp(order) {
  // Create the sales order in your system. Use order.reference as its external
  // reference, and check it first so a retry can't create it twice.
  console.log(
    `${order.reference} for ${order.customer.accountCode}: ${order.lines.length} lines`,
  )
}

const { data: newOrders } = await api('GET', '/orders?status=new')
for (const summary of newOrders.reverse()) {
  const { data: order } = await api('GET', `/orders/${summary.id}`)
  await sendToErp(order)
  await api('PATCH', `/orders/${order.id}`, { status: 'acknowledged' })
}
```

Oldest orders are handled first (`reverse()`), because the list is newest first.

If your ERP step succeeds but the `PATCH` fails, the order is still `new` next time. That’s why `sendToErp` should check for `order.reference` before creating anything.

Later, when an order ships, mark it dispatched with the tracking details in the note:

```js
await api('PATCH', `/orders/${orderId}`, {
  status: 'dispatched',
  note: 'DPD tracking 1554 2210 0931',
})
```

## Order poller (Python)

The same poller with [requests](https://requests.readthedocs.io):

```python
# orders.py: run every 5 minutes. TRADECATALOG_API_KEY=tc_live_… python orders.py
import os
import time

import requests

BASE = "https://tradecatalog.app/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['TRADECATALOG_API_KEY']}"


def api(method, path, body=None, attempt=0):
    res = session.request(method, BASE + path, json=body, timeout=30)
    if (res.status_code == 429 or res.status_code >= 500) and attempt < 5:
        time.sleep(int(res.headers.get("Retry-After", 2**attempt)))
        return api(method, path, body, attempt + 1)
    data = res.json()
    if not res.ok:
        raise RuntimeError(f"{method} {path}: {data['error']['message']}")
    return data


def send_to_erp(order):
    # Create the sales order in your system; skip it if order["reference"] exists.
    print(order["reference"], order["customer"]["accountCode"], len(order["lines"]), "lines")


for summary in reversed(api("GET", "/orders?status=new")["data"]):
    order = api("GET", f"/orders/{summary['id']}")["data"]
    send_to_erp(order)
    api("PATCH", f"/orders/{order['id']}", {"status": "acknowledged"})
```

## Money and codes

- `listPrice`, `unitPrice`, `lineTotal` and `total` are in minor units (pence for GBP). Divide by 100 for pounds.
- Order prices are before tax. Your ERP adds VAT when it invoices.
- Use your ERP’s customer account codes as TradeCatalog account codes, so both systems match without a lookup table.

## Limits to plan for

- 100 rows per `PUT`, and 120 requests a minute per key. A 10,000-product catalogue is 100 requests, about a minute.
- `GET /orders` covers the 200 most recent matching orders. Poll often enough that fewer than 200 new orders arrive between runs.
- Publishing price changes, discount rules and agreed prices are done in the app.

Source: https://tradecatalog.app/docs/guides/erp-sync

---

# AI and LLM resources

> Plain-markdown versions of these docs, llms.txt, OpenAPI, the agent skill and the other files AI tools can read.

These docs are written to be read by AI tools as well as people. Use them to give Claude, ChatGPT, Cursor or your own agent accurate, current context about TradeCatalog.

## Markdown versions of every page

Every docs page is also plain markdown:

- Add `.md` to the URL: `/docs/api/products` becomes `/docs/api/products.md`.
- Or ask for markdown: send `Accept: text/markdown` to any `/docs` URL.

```bash
curl https://tradecatalog.app/docs/api/products.md
curl -H "Accept: text/markdown" https://tradecatalog.app/docs/api/products
```

Other TradeCatalog pages also return markdown when asked with `Accept: text/markdown`.

## Buttons on every page

At the top of every docs page:

- **Copy page** copies the page as markdown, ready to paste into a chat.
- **View as Markdown** opens the `.md` version.
- **Open in Claude** and **Open in ChatGPT** start a new chat that reads the page, so you can ask questions about it.

Every code block also has a **Copy** button.

## llms.txt and llms-full.txt

| File                             | What’s in it                                                                                                          |
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| [/llms.txt](/llms.txt)           | A short summary of TradeCatalog and links to the key pages, following the [llms.txt](https://llmstxt.org) convention. |
| [/llms-full.txt](/llms-full.txt) | Every docs page in one markdown file. Paste it into a project or give it to an agent for full context.                |

For coding assistants, add `/llms-full.txt` to your project’s docs, or tell the assistant to read it before writing TradeCatalog code.

## OpenAPI

[`/api/v1/openapi.json`](/docs/api/openapi) describes every REST endpoint. Agents that understand OpenAPI can plan API calls from it directly.

## Discovery files

| File                                        | For                                                                                  |
| ------------------------------------------- | ------------------------------------------------------------------------------------ |
| `/.well-known/api-catalog`                  | RFC 9727 API catalog: links to the MCP server, the REST API, OpenAPI and these docs. |
| `/.well-known/mcp/server-card.json`         | The MCP server card: URL, transport, auth and tools.                                 |
| `/.well-known/agent-skills/index.json`      | An agent skill describing how to use TradeCatalog.                                   |
| `/.well-known/ai-catalog.json`              | Index of the MCP server and the skill.                                               |
| `/.well-known/oauth-protected-resource/mcp` | OAuth metadata for the MCP server. See [OAuth for MCP clients](/docs/mcp/oauth).     |
| `/auth.md`                                  | How agents authenticate, in one page.                                                |

## Let AI apps use your data

Reading docs is one thing. To let an AI app look up your real products, prices and orders, connect it to the [MCP server](/docs/mcp). It signs in as you and sees only what you can see.

Source: https://tradecatalog.app/docs/guides/llms

---

# Changelog

> What’s new in TradeCatalog for suppliers, their trade customers and developers, newest first.

Everything we change that you’d notice, newest first: for **suppliers**, for **buyers** (your trade customers) and for **developers**. Within `/api/v1` we only make additive changes. See [Versioning](/docs/api/conventions#versioning).

## 27 September 2026

### For suppliers

- **Settings in tabs:** Business, Branding, Orders & directory, Subscription, API and Your data. Each tab saves only its own fields.
- **Your logo** (Settings → Branding) now heads your emails to customers and shows on your trade portal, in the sidebar and in the supplier directory.
- **Import in steps:** upload, match your columns, check the changes, done. TradeCatalog remembers each file’s column layout, so the next file in the same layout goes straight to the check. Price changes of 20% or more are flagged, and products missing from the file can be hidden.
- **Grouped price lists import too:** headings can sit below a contents page, section rows (a heading above its sizes or finishes) go in front of product names or become the category, an unlabelled finish column can be added to names, and an item listed twice at the same price is skipped with a warning. “Non-stock” reads as made to order.
- **Re-importing a price list keeps your product details:** a file with only SKUs and prices no longer clears brands, categories, references or units.
- **Customer page in tabs** (Pricing, Orders, People, Details, History), with a plain-English summary of how each customer is priced. **See their prices** shows your catalogue exactly as that customer sees it.
- **Getting started:** a new workspace shows three steps (import your prices, add your customers, send them your link) until they’re done.
- **Price changes** warn you before publishing when a product is already in another scheduled change.
- **Free trial:** a new workspace has 14 days free. Without a subscription after that, it becomes read-only and customers can’t send orders. Your catalogue, prices and customers are all kept. Settings → Subscription shows the days left.
- **Your name** can be set at sign-up and on your account page, so orders, invitations and the activity log show it rather than your email address.
- Product photos open larger when you click them.

### For buyers

- **A catalogue laid out like the trade counter:** search from any page; places (Catalogue, Orders, Price changes, News & offers) down the left with **Filter by** category, brand and availability; products grouped under their category with your price beside **Add**; the next 50 products a click away. The Basket button shows its count, and the account menu switches account, lists all your suppliers and **signs you out**.
- **Adding to the basket adds to the amount already there** instead of replacing it.
- **Product photos open larger** when you click them. Product pages show the price and order box beside the details.
- **After signing in from your supplier’s link you land in their catalogue**, and you see “Order sent” as soon as an order goes.
- **Download my price list** is in the side menu and on the Price changes page. Upcoming changes read “Now” and “From 1 Oct”.

### Demo

- **Try the demo** shows each step while it sets up your private copy.

### For developers

- The [REST API](/docs/api) is live at `https://tradecatalog.app/api/v1`.
- Owners make [API keys](/docs/api/authentication) in **Settings → API**, read only or read & write.
- Read products with any customer’s prices, customers, orders, price changes and the activity log.
- Create and update products and customers in batches of 100. Changed list prices go into a draft price change.
- Move orders on with `PATCH /orders/{id}`.
- [OpenAPI 3.1](/docs/api/openapi) at `/api/v1/openapi.json`.
- 120 requests a minute per key.
- These docs, with markdown versions of every page, [/llms-full.txt](/llms-full.txt), and **Copy page** and **Open in Claude** buttons. See [AI and LLM resources](/docs/guides/llms).

## 26 September 2026

### For developers

- The read-only [MCP server](/docs/mcp) is live at `https://tradecatalog.app/mcp`, with OAuth 2.1 sign-in.
- Six [tools](/docs/mcp/tools): `list_workspaces`, `search_products`, `check_price`, `list_customers`, `list_orders` and `recent_activity`.

Source: https://tradecatalog.app/docs/changelog
