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.
The product objectLink to this section#
{
"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. |
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. 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 productsLink to this section#
GET/api/v1/products
Without q, products are listed by name. With q, the results come in this order:
- exact SKU or manufacturer reference
- SKU or manufacturer reference starting with
q - 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. |
curl "https://tradecatalog.app/api/v1/products?q=hex%20bolt&customer=OAK01&limit=20" \
-H "Authorization: Bearer $TRADECATALOG_API_KEY"
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()
{
"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}
| 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. |
curl "https://tradecatalog.app/api/v1/products/3f8a2c1e-9b4d-4e6f-a7c8-1d2e3f4a5b6c?customer=OAK01&qty=5" \
-H "Authorization: Bearer $TRADECATALOG_API_KEY"
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()
{
"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 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}.
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.
BodyLink to this section#
| 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.
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"]
}
]
}'
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')
{
"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 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.
curl https://tradecatalog.app/api/v1/categories \
-H "Authorization: Bearer $TRADECATALOG_API_KEY"
const res = await fetch('https://tradecatalog.app/api/v1/categories', {
headers: { Authorization: `Bearer ${process.env.TRADECATALOG_API_KEY}` },
})
const { data: categories } = await res.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.