# 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,
  "catalogueScope": "all",
  "createdAt": "2026-07-02T10:12:44.000Z"
}
```

| Field            | Type            | Description                                                                                                                            |
| ---------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `id`             | string          | Customer id.                                                                                                                           |
| `accountCode`    | string          | Your account code. Unique in the workspace, ignoring capitals. It can be changed in the app, so keep the `id` for anything long-lived. |
| `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 percentage on the whole list, in basis points. `null` for none.                                                     |
| `adjustment`     | string          | `discount` (taken off the list) or `markup` (added on). Existing customers are `discount`.                                             |
| `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.                                                          |
| `catalogueScope` | string          | `all` (sees the whole catalog) or `approved` (sees and orders only its approved list). Set on the customer page; read-only in the API. |
| `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,
      "catalogueScope": "all",
      "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,
    "priceListId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
    "priceList": "Standard",
    "active": true,
    "newsOptOut": false,
    "createdAt": "2026-07-02T10:12:44.000Z",
    "discountRules": [
      {
        "id": "d0e1f2a3-4b5c-4d6e-9f7a-8b9c0d1e2f3a",
        "scope": "product",
        "discountBp": 2000,
        "productId": "5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e",
        "sku": "HX-8510",
        "target": "Hex bolt M10 x 50 zinc",
        "list": 2280,
        "unit": 1824
      },
      {
        "id": "e1f2a3b4-5c6d-4e7f-8a9b-0c1d2e3f4a5b",
        "scope": "brand",
        "discountBp": 1500,
        "productId": null,
        "sku": null,
        "target": "Northgate",
        "list": null,
        "unit": null
      },
      {
        "id": "f2a3b4c5-6d7e-4f80-9a1b-2c3d4e5f6a7b",
        "scope": "category",
        "discountBp": 1250,
        "productId": null,
        "sku": null,
        "target": "Hinges",
        "list": null,
        "unit": null
      }
    ],
    "agreedPrices": [
      {
        "id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
        "productId": "6c7d8e9f-0a1b-4c2d-9e3f-4a5b6c7d8e9f",
        "price": 1450,
        "note": "Contract price to March 2027",
        "noteVisible": true,
        "internalNote": "Agreed with their buyer",
        "sku": "HX-8512",
        "name": "Hex bolt M12 x 50 zinc",
        "list": 1980,
        "unit": 1450
      }
    ],
    "buyers": [{ "name": "Sam Patel", "email": "sam@oakfieldjoinery.co.uk" }],
    "pendingInvitations": [{ "email": "orders@oakfieldjoinery.co.uk" }]
  },
  "currency": "GBP"
}
```

| Field                | Description                                                                                                                                                                                                                                                     |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `priceList`          | Name of the price list this customer buys from. `priceListId` is that list’s id. A product with no price on the list is price on request.                                                                                                                       |
| `discountRules`      | Product, category and brand percentages. `scope` is `product`, `category` or `brand`; `adjustment` is `discount` or `markup`; `target` is its name. Product rules also give `sku`, `list` and `unit` (what they pay).                                           |
| `agreedPrices`       | Agreed (fixed) prices valid now, per SKU, in minor units, with the current `list` price and the `unit` price they pay. `note` is the remark; `noteVisible` says whether that customer sees it. `internalNote` is staff-only and is never shown to the customer. |
| `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      | Whole-list percentage, 0 to 10,000, for a discount or a markup. `null` clears it.                                                                              |
| `customers[].adjustment`  | `discount` or `markup` | no       | Which way `discountBp` moves the price. Omit it to leave the current direction. New customers default to `discount`.                                           |
| `customers[].priceList`   | string or null         | no       | Name of an existing price list. Omit it to leave the list unchanged. `null` puts the customer on Standard. An unknown name is rejected.                        |
| `customers[].region`      | string or null         | no       | Name of an existing region. Omit it to leave the region unchanged. `null` clears it. An unknown name is rejected.                                              |
| `customers[].rep`         | string or null         | no       | Email or username of a staff member assigned to this customer. Omit it to leave the rep unchanged. `null` clears it. An unknown email or username is rejected. |

`accountCode`, `name`, `email` and `discountBp` are required, and written as sent: `null` clears `email` and `discountBp`. That way a missing field in your code can’t silently leave an old percentage in place. `adjustment` and `priceList` are optional: leave `adjustment` out to keep a markup or a discount as it is.

The customer spreadsheet has the same choice. Beside Discount % it can have an Adjustment column (`discount` or `markup`), a Price list column and a Sales rep column. The rep cell is that person’s email, or their username when they sign in without email. A blank cell leaves that field as it is. An unknown list name or rep is an error on that row.

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.
