> ## Documentation Index
> Fetch the complete documentation index at: https://docs.qwoty.io/llms.txt
> Use this file to discover all available pages before exploring further.

#  API Breaking changes the 26/10 at 08h00

> Migration guide for the latest API. All breaking changes to public endpoints are documented here.

> [!WARNING]
>
> This release introduces **breaking changes** to the public API. Existing integrations must be updated before the deprecation date. Read each section carefully and check all items in the migration checklist.

> [!WARNING]
>
> **No API versioning.** This is not a new API version running alongside the previous one — the new contract replaces the old one directly. There is no `/v2/` prefix or compatibility shim. Any integration still using the old field names will break as soon as the release is deployed.

## Migration checklist

Before deploying your updated integration, confirm each item below.

**1. Update external identifiers in read responses**

Read `identifiers.crm` / `.erp` / `.accounting` instead of `id_crm`, `id_erp`, `id_accounting` on customers, contacts, addresses, catalogs and payment terms. Read `identifiers` instead of `external_ids` on quotes.

**2. Update external identifiers in write requests**

Send grouped fields as nested JSON (`identifiers`, `shipping`, `inventory`, `settings`, `accounting`). Bracket-flat keys (`identifiers[crm]`, `shipping[weight]`…) are rejected with a `400`.

**3. Update product-parent catalog field**

Read catalogs from `catalogs[].id` instead of `catalog_ids` on product-parents.

**4. Update price linked resources**

Read linked resources on prices from the embedded objects `product`, `pricebook`, `currency`, `default_tax`, `billing_frequency`. Read `prices[].pricebook` on products.

**5. Check integration token permissions**

Ensure your integration token has the `product_pricing:write` right if it writes to catalogs, prices or products. Writes that previously passed will now return a `403`.

**6. Update custom fields format**

Read and write custom fields as `custom_fields: { "<api_name>": value }`. Use `custom_fields` instead of `custom_properties` on quotes.

***

## Summary

| # | Change | Affected endpoints |
| - | - | - |
| 1 | `id_crm / id_erp / id_accounting` and `external_ids` → `identifiers`; grouped fields as nested JSON on write | Read: `/customers`, `/contacts`, `/addresses`, `/catalogs`, `/payment-terms`, `/quote/{id}` · Write: all `POST` / `PATCH` |
| 2 | `catalog_ids` → `catalogs` (objects) on product-parents; `catalogs[].name` and `prices[]` aligned on products | `/product-parents`, `/products` |
| 3 | Embedded linked resources on prices, new `key` field | `/prices` |
| 4 | Token role rights enforced on writes (`403`) | `/catalogs`, `/categories`, `/prices`, `/products`, `/product-parents` |
| 5 | Non-breaking additions (`deleted_at` / `deleted_by` on payment-terms, `key` on prices) | `/payment-terms`, `/prices` |
| 6 | Custom fields: `custom_fields` object indexed by `api_name`, typed values, merge patch | `/customers`, `/quote/{id}` |

***

## 1. External identifiers: `identifiers` replaces flat fields

### 1.1 Read responses (GET)

The fields `id_crm`, `id_erp`, `id_accounting` are removed from all responses.\
They are replaced by an `identifiers` object with three keys.

```json Before theme={null}
{
  "id": "...",
  "name": "Acme Corp",
  "id_crm": "CRM-001",
  "id_erp": "ERP-042",
  "id_accounting": null
}
```

```json After theme={null}
{
  "id": "...",
  "name": "Acme Corp",
  "identifiers": {
    "crm": "CRM-001",
    "erp": "ERP-042",
    "accounting": null
  }
}
```

**Affected endpoints**

| Endpoint | Methods |
| - | - |
| `/customers` | GET (list), POST (response) |
| `/customers/{id}` | GET, PATCH (response) |
| `/contacts` | GET (list), POST (response) |
| `/contacts/{id}` | GET, PATCH (response) |
| `/addresses` | GET (list), POST (response) |
| `/addresses/{id}` | GET, PATCH (response) |
| `/catalogs` | GET (list), POST (response) |
| `/catalogs/{id}` | GET, PATCH (response) |
| `/payment-terms` | GET (list), POST (response) |
| `/payment-terms/{id}` | GET, PATCH (response) |
| `/quote/{id}` | GET — see §1.5 |

> [!NOTE]
>
> Products (`/products`, `/product-parents/{id}/products`) and prices already returned `identifiers` as an object — nothing changes for them **on read**. They are however affected on write (§1.2, §1.3). Product-parents have no `identifiers`: they are carried by the product.

### 1.2 Write requests (POST / PATCH body)

The fields `id_crm`, `id_erp`, `id_accounting` are no longer accepted in request bodies.\
Use the nested JSON object `identifiers` — same shape as in read responses.

```json Before theme={null}
{
  "name": "Acme Corp",
  "id_crm": "CRM-001"
}
```

```json After theme={null}
{
  "name": "Acme Corp",
  "identifiers": {
    "crm": "CRM-001"
  }
}
```

> [!WARNING]
>
> The following are rejected with a `400`:
>
> * Bracket-flat keys (`"identifiers[crm]": "..."`)
> * The fields `id_crm`, `id_erp`, `id_accounting` and their aliases (`crm_id`, `erp_id`, `accounting_id`)
>
> **CSV import columns are not affected** — they remain flat (`identifiers[crm]`).

### 1.3 Other grouped fields (products)

The same rule applies to all grouped fields on products (`POST /products`, `PATCH /products/{id}`, `POST /product-parents`, `POST /product-parents/{id}/products`): `settings`, `shipping`, `inventory`, `accounting`, `identifiers`.

```json Before theme={null}
{
  "inventory[sku]": "WID-001",
  "shipping[weight]": 2.5,
  "shipping[weight_unit]": "kg"
}
```

```json After theme={null}
{
  "inventory": { "sku": "WID-001" },
  "shipping": { "weight": 2.5, "weight_unit": "kg" }
}
```

### 1.4 Migration snippet

```js theme={null}
// Reading — Before
const crmId = response.data.id_crm

// Reading — After
const crmId = response.data.identifiers?.crm ?? null
```

```js theme={null}
// Writing — Before
body = { name: "Acme", id_crm: "CRM-001" }

// Writing — After
body = { name: "Acme", identifiers: { crm: "CRM-001" } }
```

### 1.5 Quotes: `external_ids` → `identifiers`

`GET /quote/{id}` previously used `external_ids`. The field is now `identifiers`, with the same keys `crm`, `erp`, `accounting`, everywhere in the quote:

* `business_unit`, `customer`, `owner` and each line in `pricing_table`
* Global discounts (`discounts.global_discounts[]`)
* Nested JSON: line discounts, taxes, `payment_terms`, `payment_methods`

Empty values are now `null` instead of `""`, consistent with other endpoints.

```json Before theme={null}
{
  "customer": {
    "external_ids": { "crm": "CRM-001", "erp": null, "accounting": null }
  }
}
```

```json After theme={null}
{
  "customer": {
    "identifiers": { "crm": "CRM-001", "erp": null, "accounting": null }
  }
}
```

***

## 2. Product-parents: `catalog_ids` → `catalogs`

### Read

The `catalog_ids` field (UUID array) is replaced by `catalogs` (array of objects).

```json Before theme={null}
{
  "id": "...",
  "name": "Premium Widget",
  "catalog_ids": [
    "660e8400-e29b-41d4-a716-446655440001",
    "770e8400-e29b-41d4-a716-446655440002"
  ]
}
```

```json After theme={null}
{
  "id": "...",
  "name": "Premium Widget",
  "catalogs": [
    { "id": "660e8400-e29b-41d4-a716-446655440001", "name": "B2B Catalog", "api_name": "b2b" },
    { "id": "770e8400-e29b-41d4-a716-446655440002", "name": "Export Catalog", "api_name": "export" }
  ]
}
```

### Write

**Unchanged** — continue sending `catalog_ids` in POST / PATCH request bodies.

### Migration snippet

```js theme={null}
// Before
const ids = response.data.catalog_ids

// After — IDs only
const ids = response.data.catalogs.map(c => c.id)

// After — names directly accessible
const names = response.data.catalogs.map(c => c.name)
```

**Affected endpoints:** `GET /product-parents`, `GET /product-parents/{id}`, responses of `POST /product-parents` and `PATCH /product-parents/{id}`.

### Products and variants

On `/products` and `/products/{id}`, the `catalogs` field already existed but only contained `id` and `api_name`. It now also includes `name`.

```json Before theme={null}
{ "catalogs": [{ "id": "...", "api_name": "b2b" }] }
```

```json After theme={null}
{ "catalogs": [{ "id": "...", "name": "B2B Catalog", "api_name": "b2b" }] }
```

### `prices[]` on products

On `/products`, `/products/{id}` and `/product-parents/{id}/products`, each `prices[]` entry now takes the same shape as a price from `/prices` (see §3): `pricebook` becomes an object, `billing_frequency` and `key` are added. `pricebook_id` and `pricebook_api_name` are removed.

```json Before theme={null}
{
  "prices": [
    {
      "id": "price-001",
      "pricebook_id": "pb-001",
      "pricebook_api_name": "standard",
      "pricing_model": "flat",
      "amount": 1990
    }
  ]
}
```

```json After theme={null}
{
  "prices": [
    {
      "id": "price-001",
      "key": "premium_widget.standard",
      "pricebook": { "id": "pb-001", "name": "Standard", "api_name": "standard", "type": "fixed" },
      "billing_frequency": null,
      "pricing_model": "flat",
      "amount": 1990
    }
  ]
}
```

> [!NOTE]
>
> A recurring product has one entry per frequency. `billing_frequency` (`{ id, period, unit }`) and `key` (e.g. `…standard.month.1`) allow you to distinguish them.

***

## 3. Prices: embedded linked resources

The five raw UUID FK fields are replaced by embedded objects containing `id` and the main descriptive fields.

> [!NOTE]
>
> **Write is unchanged** — continue sending `pricebook_id`, `currency_id`, etc. in POST / PATCH request bodies.

### 3a. `product_variant_id` → `product`

```json Before theme={null}
{ "product_variant_id": "aaa-111" }
```

```json After theme={null}
{
  "product": {
    "id": "aaa-111",
    "name": "Widget Standard",
    "product_parent_id": "bbb-222"
  }
}
```

### 3b. `pricebook_id` → `pricebook`

```json Before theme={null}
{ "pricebook_id": "ccc-333" }
```

```json After theme={null}
{
  "pricebook": {
    "id": "ccc-333",
    "name": "Standard Rate 2025",
    "api_name": "standard_rate_2025",
    "type": "fixed"
  }
}
```

### 3c. `currency_id` → `currency`

```json Before theme={null}
{ "currency_id": "ddd-444" }
```

```json After theme={null}
{
  "currency": {
    "id": "ddd-444",
    "name": "Euro",
    "api_name": "EUR"
  }
}
```

### 3d. `default_tax_id` → `default_tax`

```json Before theme={null}
{ "default_tax_id": "eee-555" }
```

```json After theme={null}
{
  "default_tax": {
    "id": "eee-555",
    "name": "VAT 20%",
    "api_name": "vat_20",
    "rate": 20,
    "amount": null,
    "is_percentage": true
  }
}
```

If no tax is associated: `"default_tax": null`.

### 3e. `billing_frequency_id` → `billing_frequency`

```json Before theme={null}
{ "billing_frequency_id": "fff-666" }
```

```json After theme={null}
{
  "billing_frequency": {
    "id": "fff-666",
    "period": 1,
    "unit": "month"
  }
}
```

If no frequency is associated: `"billing_frequency": null`.

> [!NOTE]
>
> Billing frequency has no `api_name`. Identify it by `unit` + `period`.

### 3f. New `key` field (semantic price key)

Each price exposes a **calculated** (read-only, not stored) semantic key, returned immediately after `id`.

| Price type | Format | Example |
| - | - | - |
| One-off | `<product_api_name>.<pricebook_api_name>` | `onboarding.standard_eur` |
| Recurring | `<product_api_name>.<pricebook_api_name>.<unit>.<period>` | `pro_license.standard_eur.month.1` |

```json theme={null}
{
  "id": "550e8400-...",
  "key": "pro_license.standard_eur.month.1"
}
```

> [!WARNING]
>
> `key` is **not** an `api_name`. It changes if the product or pricebook is renamed, and is not accepted on write.

**Affected endpoints:** `GET /prices`, `GET /prices/{id}`, response of `PATCH /prices/{id}`.

***

## 4. Token rights enforced on writes

Write endpoints in the catalog now check the token's role rights, as `/customers`, `/contacts`, `/addresses` and `/payment-terms` already did. A token without the required right receives a `403` — writes that previously passed will now fail.

| Endpoints | Required right |
| - | - |
| `POST` / `PATCH` on `/catalogs`, `/catalogs/{id}/products`, `/categories`, `/prices/{id}`, `/products`, `/product-parents`, `/product-parents/{id}/products` | `product_pricing:write` |
| `DELETE /categories/{id}` | `product_pricing:delete` |

```json theme={null}
{ "error": "Insufficient permissions. Requires product_pricing:write" }
```

**Action required:** Ensure your integration token is attached to a role with `product_pricing:write` (system roles `admin` and `manager`). Roles `sale` and `view_only` remain read-only. `GET` requests are not affected.

***

## 5. Non-breaking additions

No existing fields are modified. No action required.

| Endpoint | New field |
| - | - |
| `/payment-terms`, `/payment-terms/{id}` (GET, POST / PATCH responses) | `deleted_at`, `deleted_by` — always `null`: deleted payment terms are not returned |
| `/prices`, `/prices/{id}`, `prices[]` on products | `key` — see §3f |

***

## 6. Custom fields: `custom_fields` object indexed by `api_name`

Custom fields are no longer an array of objects but an **object** whose keys are the fields' `api_name` and values are the field values themselves. Same shape on read and write. Values are **typed** according to the field type: `text` → string, `number` → number, `timestamptz` → ISO 8601 string.

```json Before theme={null}
{
  "custom_fields": [
    {
      "id": "d0e1f2a3-...",
      "api_name": "loyalty_tier",
      "value": "Gold",
      "is_active": true,
      "is_mandatory": false,
      "is_editable_by_sale": false,
      "is_display_for_customer": false
    }
  ]
}
```

```json After theme={null}
{
  "custom_fields": {
    "loyalty_tier": "Gold",
    "annual_budget": null
  }
}
```

### Read

* `GET /customers/{id}` and responses of `POST /customers` / `PATCH /customers/{id}`: all workspace customer custom fields are present, `null` if not set.
* `GET /quote/{id}`: `custom_properties` is renamed `custom_fields` on the quote, on `customer`, and on each line item (`pricing_table.sections[].line_items[]` and `…phases[].line_items[]`). Values are no longer coerced to strings (`"12"` → `12`).
* Per-value flags (`is_active`, `is_mandatory`, `is_editable_by_sale`, `is_display_for_customer`) and the definition `id` are no longer exposed. They remain manageable in the application.

### Write (`POST /customers`, `PATCH /customers/{id}`)

* `custom_fields` is an object `{ "<api_name>": value }`. The legacy array format is rejected (`400`).
* `PATCH` follows **JSON Merge Patch** (RFC 7396): only the keys sent are written, `null` clears a value, absent keys are unchanged.
* Unknown key, wrong type, or value outside constraints (`min`, `max`, `maxLength`) → `400`, nothing is written. Errors reference the field as `custom_fields[<api_name>]`.

```jsonc theme={null}
// PATCH /customers/{id}
{ "custom_fields": { "loyalty_tier": "Platinum", "annual_budget": null } }
```

```jsonc theme={null}
// 400
{
  "error": "Validation error",
  "details": [
    "Unknown custom field 'does_not_exist'",
    "custom_fields[annual_budget] must be a number"
  ]
}
```

***

## General rules

| Rule | Detail |
| - | - |
| Missing linked resource | `default_tax` and `billing_frequency` keys are always present: if no resource is linked, the value is `null` (never an absent key). |
| Depth | An embedded object does not embed its own linked resources (e.g. `price.pricebook` does not contain its currency). Exception: `prices[]` on a product take the full shape of a price from `/prices`, including `pricebook` and `billing_frequency`. |
| Write | POST / PATCH are unchanged for FK fields pointing to linked resources — continue sending `pricebook_id`, `catalog_ids`, etc. in request bodies. |
| Rejected keys | `id_crm`, `id_erp`, `id_accounting`, `crm_id`, `erp_id`, `accounting_id` and any bracket-flat key (`identifiers[crm]`, `shipping[weight]`…) in request bodies → `400`. |

***

## Full reference table

| Endpoint | Removed field | Replaced by |
| - | - | - |
| `/customers`, `/contacts`, `/addresses`, `/catalogs`, `/payment-terms` GET / POST-PATCH responses | `id_crm`, `id_erp`, `id_accounting` | `identifiers.crm`, `identifiers.erp`, `identifiers.accounting` |
| All POST / PATCH request bodies | `id_crm`, `id_erp`, `id_accounting`, `identifiers[crm]` | `identifiers: { crm, erp, accounting }` (nested JSON) |
| `/products`, `/product-parents` POST + PATCH bodies | `inventory[sku]`, `settings[...]`, `shipping[...]`, `accounting[...]` | `inventory: { sku }`, `settings: {...}`, `shipping: {...}`, `accounting: {...}` (nested JSON) |
| `/product-parents` GET | `catalog_ids` | `catalogs[{ id, name, api_name }]` |
| `/products` GET | `catalogs[{ id, api_name }]` | `catalogs[{ id, name, api_name }]` (added `name`) |
| `/products` GET | `prices[].pricebook_id`, `prices[].pricebook_api_name` | `prices[].pricebook: { id, name, api_name, type }`, `prices[].billing_frequency`, `prices[].key` |
| `/prices` GET | `product_variant_id` | `product: { id, name, product_parent_id }` |
| `/prices` GET | `pricebook_id` | `pricebook: { id, name, api_name, type }` |
| `/prices` GET | `currency_id` | `currency: { id, name, api_name }` |
| `/prices` GET | `default_tax_id` | `default_tax: { id, name, api_name, rate, amount, is_percentage }` |
| `/prices` GET | `billing_frequency_id` | `billing_frequency: { id, period, unit }` |
| `/prices` GET | — (new) | `key`: calculated semantic key, e.g. `pro_license.standard_eur.month.1` |
| Catalog / price / product writes | — | `403` if token role lacks `product_pricing:write` (`delete` for `DELETE /categories/{id}`) |
| `/customers` GET / POST / PATCH | `custom_fields: [{ id, api_name, value, is_active, … }]` | `custom_fields: { <api_name>: value }` (typed values, flags removed) |
| `/quote/{id}` GET | `custom_properties` (string-coerced values) on quote, customer and line items | `custom_fields` (typed values) |
| `/quote/{id}` GET | `external_ids` on quote, customer, owner, lines, discounts, payment terms, payment methods, taxes | `identifiers: { crm, erp, accounting }` (`""` → `null`) |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.