This guide builds the two jobs most suppliers need:
- Nightly sync: push products and customers from your ERP into TradeCatalog.
- Order poller: every few minutes, pull new orders into your ERP and mark them acknowledged.
You need a Read & write API key. See Authentication.
How the nightly sync behavesLink to this section#
- 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,manufacturerRefanddescriptionare written as sent, so always send the full product.- Customers’
emailanddiscountBpare written as sent too:nullclears 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)Link to this section#
Needs Node.js 20 or later, for the built-in fetch. Replace the two fromErp functions with reads from your system.
// 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:
0 2 * * * cd /opt/tradecatalog && TRADECATALOG_API_KEY=tc_live_… node sync.mjs >> sync.log 2>&1
Order poller (Node.js)Link to this section#
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.
// 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:
await api('PATCH', `/orders/${orderId}`, {
status: 'dispatched',
note: 'DPD tracking 1554 2210 0931',
})
Order poller (Python)Link to this section#
The same poller with requests:
# 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 codesLink to this section#
listPrice,unitPrice,lineTotalandtotalare 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 forLink to this section#
- 100 rows per
PUT, and 120 requests a minute per key. A 10,000-product catalogue is 100 requests, about a minute. GET /orderscovers 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.