Skip to content
Docs menu

Reference

REST API

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.

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

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

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

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

The product objectLink to this section#

json
{
  "id": "3f8a2c1e-9b4d-4e6f-a7c8-1d2e3f4a5b6c",
  "sku": "HX-8510",
  "name": "Hex bolt M10 x 50 zinc",
  "description": "Grade 8.8, zinc plated, fully threaded.",
  "brandId": "a1b2c3d4-0000-4000-8000-000000000001",
  "categoryId": "a1b2c3d4-0000-4000-8000-000000000002",
  "manufacturerRef": "DIN933-M10-50",
  "unitLabel": "Box of 100",
  "minOrderQty": 1,
  "availability": "in_stock",
  "stockQty": 240,
  "lowStockAt": null,
  "leadTimeNote": null,
  "dealText": null,
  "active": true,
  "visibility": "all",
  "details": { "Finish": "Zinc", "Thread": "M10" },
  "createdAt": "2026-08-14T09:30:00.000Z",
  "updatedAt": "2026-09-20T16:02:11.000Z",
  "price": {
    "unit": 1850,
    "line": 1850,
    "list": 1850,
    "discountBp": 0,
    "source": "list",
    "label": ""
  }
}
FieldTypeDescription
idstringProduct id.
skustringYour stock code. Unique in the workspace, ignoring case.
namestringProduct name.
descriptionstring or nullLonger description.
brandId, categoryIdstring or nullSee brands and categories.
manufacturerRefstring or nullManufacturer’s part number. Searchable like the SKU.
unitLabelstringWhat one unit is, e.g. “Each” or “Box of 100”.
minOrderQtyintegerSmallest quantity buyers can order.
availabilitystringnot_tracked, in_stock, low_stock, out_of_stock or made_to_order. A label for buyers only: every product can be ordered. Sending it without a stockQty clears any stored count (you are setting the status by hand). With a stockQty it is worked out for you: 0 or less is out_of_stock, up to the low level is low_stock, above it in_stock. made_to_order is never changed by the number.
stockQtyinteger or nullUnits on hand, or null when you don’t count this product. When set, availability follows it. Left out when you ask as a customer (customer).
lowStockAtinteger or nullThis product’s own low-stock level, or null to use the workspace level. Left out when you ask as a customer.
leadTimeNotestring or nulle.g. “3–5 working days”.
dealTextstring or nullA note shown beside the price, such as “min 12 btls 45”. Its heading is the workspace’s deal label (Settings, Catalog display).
activebooleanInactive products are hidden from buyers.
visibilitystringall, or selected when only chosen customers can see it.
detailsobjectExtra columns from imports, like { "Finish": "Zinc" }.
priceobject or nullThe price at quantity 1 (or qty). See Prices. unit is null when the product has no price yet.

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

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

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

List or search productsLink to this section#

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.

NameTypeRequiredDescription
qstringnoSKU, manufacturer ref, name, brand or category. Up to 200 characters.
categorystringnoCategory id.
brandstringnoBrand id.
availabilitystringnoOne of the availability values.
activetrue or falsenotrue returns only active products. Defaults to true when customer is set; otherwise all products are returned.
customerstringnoCustomer id or account code.
limitintegerno1 to 50. Default 50.
offsetintegernoRows 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 productLink to this section#

GET/api/v1/products/{id}

NameInTypeRequiredDescription
idpathstringyesProduct id.
customerquerystringnoCustomer id or account code. Returns 404 if that customer can’t see the product.
qtyqueryintegernoQuantity 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, which takes a SKU.

Create or update productsLink to this section#

PUT/api/v1/productswrite key

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 handledLink to this section#

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

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

One sync, one draft per list

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

BodyLink to this section#

FieldTypeRequiredDescription
productsarrayyes1 to 100 products. SKUs must be unique within the call.
priceChangeIdstringnoA draft for the listPrice list from an earlier call. Changed prices for that list are added to it. 404 if it doesn’t exist, 400 if it isn’t a draft any more.
draftsarraynoUp to 30 { list, priceChangeId } entries so other lists continue their own drafts.
products[].skustringyesUp to 64 characters.
products[].namestringyesUp to 200 characters.
products[].listPriceinteger or nullyesMinor units, 0 to 1,000,000,000. null is price on request: a new product has no price, an existing one keeps its own.
products[].priceListstring or nullnoName of an existing price list for listPrice. Omit or null is Standard. An unknown name is rejected.
products[].listPricesarraynoOther lists, as { list, price }. price is minor units, or null to leave that list unchanged. An unknown name is rejected.
products[].unitLabelstringyesUp to 60 characters, e.g. “Each”.
products[].brandstringnoBrand name, up to 120 characters. Cleared if left out.
products[].categorystringnoCategory name, up to 120 characters. Cleared if left out.
products[].manufacturerRefstringnoUp to 120 characters. Cleared if left out.
products[].descriptionstringnoUp to 4,000 characters. Cleared if left out.
products[].availabilitystringnoOne of the availability values. Kept if left out; new products start as not_tracked.
products[].stockQtyinteger or nullnoUnits on hand, from -1,000,000 to 1,000,000,000. Sets availability (see the product object); a status you send alongside it is ignored, except made_to_order. Kept if left out, unless you send an availability without it: then the count is cleared and that status is used. null stops counting; the availability sent in the same row applies, or the current status if there is none.
products[].lowStockAtinteger or nullnoLow-stock level for this product, from 0. null uses the workspace level (Settings, 5 to start with). Kept if left out.
products[].leadTimeNotestringnoUp to 200 characters. Kept if left out.
products[].dealTextstringnoUp to 200 characters, shown beside the price. Kept if left out.
products[].detailsobjectnoUp to 30 extra columns: names up to 60 characters, values up to 500. Replaces all details when sent; kept if left out.
products[].imageUrlstringnoAn https image URL. Copied into TradeCatalog in the background when it changes. Kept if left out.
products[].onlyForarraynoUp to 200 account codes or contact emails. Makes the product visible only to those customers, replacing any earlier list. Kept if left out.

Send every field you care about

brand, category, manufacturerRef and description are written as sent, so leaving one out clears it. Send the full product each time, like a spreadsheet row.

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

bash
curl -X PUT https://tradecatalog.app/api/v1/products \
  -H "Authorization: Bearer $TRADECATALOG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "products": [
      {
        "sku": "HX-8510",
        "name": "Hex bolt M10 x 50 zinc",
        "listPrice": 1950,
        "unitLabel": "Box of 100",
        "brand": "Northgate",
        "category": "Bolts",
        "manufacturerRef": "DIN933-M10-50",
        "availability": "in_stock",
        "details": { "Finish": "Zinc", "Thread": "M10" }
      },
      {
        "sku": "OAK-HNG-75",
        "name": "Oak-finish butt hinge 75mm",
        "listPrice": 695,
        "unitLabel": "Pair",
        "category": "Hinges",
        "onlyFor": ["OAK01"]
      }
    ]
  }'
js
const res = await fetch('https://tradecatalog.app/api/v1/products', {
  method: 'PUT',
  headers: {
    Authorization: `Bearer ${process.env.TRADECATALOG_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ products }),
})
const { data: result } = await res.json()
if (result.priceChangeId)
  console.log('Review the draft price change in TradeCatalog')
json
{
  "data": {
    "created": 1,
    "updated": 1,
    "priceChanges": 1,
    "priceChangeId": "c4d5e6f7-1234-4abc-9def-0123456789ab",
    "priceChangesByList": [
      {
        "list": "Standard",
        "priceChangeId": "c4d5e6f7-1234-4abc-9def-0123456789ab"
      }
    ],
    "unknownCustomers": []
  }
}
FieldDescription
createdNew products.
updatedExisting products matched by SKU.
priceChangesHow many prices went into a draft, across every list in the call.
priceChangeIdThe draft for the listPrice list, or null if that list had no changes.
priceChangesByListOne { list, priceChangeId } for each list that had a price change in this call.
unknownCustomersValues 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 categoriesLink to this section#

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.