Contents

Track catalog changes

Download what changed since your last sync, or get a webhook when products and stock change.

Download what changed

Read the whole catalog once with GET /products, 1 to 200 products per page (limit, default 50), following next_cursor until it is null. Then, at every run, ask only for what changed since the previous one:

// Run on a schedule. Store the server_time of each run and use it next time.
const headers = { Authorization: `Bearer ${process.env.NUCLEO_CATALOG_KEY}` };
const { data: ping } = await (await fetch("https://api-catalog.nucleoplatform.com/api/catalog/v1/ping", { headers })).json();
const since = await loadLastRun(); // e.g. "2026-10-14T06:00:00Z"

let cursor = null;
do {
  const url = new URL("https://api-catalog.nucleoplatform.com/api/catalog/v1/products");
  url.searchParams.set("limit", "200");
  url.searchParams.set("updated_since", since);
  if (cursor) url.searchParams.set("cursor", cursor);
  const page = await (await fetch(url, { headers })).json();
  for (const product of page.data) {
    if (product.deleted) await removeFromMyCopy(product.sku);
    else await upsertInMyCopy(product);
  }
  cursor = page.next_cursor;
} while (cursor);

await saveLastRun(ping.server_time);
  • Take server_time from GET /ping before you start reading, so nothing that changes while you read is missed. Changes are returned at or after updated_since, so the same product can come twice across runs: treat writes as upserts.
  • What counts as a change: anything recorded in the product's Activity (made in the app, by an import, by another connected system, by Atomo or through this API) and every change of a stock quantity made on the product page, by an import or through this API.
  • Deleted products come back as {"id", "sku", "deleted": true, "updated_at"} — remove them from your copy. A product stays 30 days in Nucleo's trash; after that it is gone for good and no longer listed, so run at least once every 30 days.
  • A cursor only works with the filters it was created with; to change filters, start again without it.

Receive webhooks

Instead of polling, let Nucleo tell you when something changes. Register an HTTPS endpoint for the events you want (up to 10 webhooks per key, also with a read-only key):

curl -X POST https://api-catalog.nucleoplatform.com/api/catalog/v1/webhooks \
  -H "Authorization: Bearer $NUCLEO_CATALOG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://erp.acme.example/nucleo/webhooks",
       "events": ["product.created", "product.updated", "product.deleted", "stock.updated"]}'

The answer contains the signing secret (whsec_…): store it now, it is not shown again (PATCH /webhooks/{id} with rotate_secret: true makes a new one). Each event is a small notice — what changed — and you read the product if you need it:

POST /nucleo/webhooks HTTP/1.1
Host: erp.acme.example
Content-Type: application/json
User-Agent: Nucleo-Webhooks/1.0
X-Nucleo-Signature: t=1791115200,v1=5f2b6c0e9a8d4f7b1c3e2a6d9b0f8e7c6a5d4b3c2e1f0a9b8c7d6e5f4a3b2c1d
X-Nucleo-Event: product.updated
X-Nucleo-Webhook-Id: 5d1c9a0e-2b7f-4c3a-9e8d-6f1a2b3c4d5e
X-Nucleo-Attempt: 1

{"id":"5d1c9a0e-2b7f-4c3a-9e8d-6f1a2b3c4d5e","type":"product.updated","created_at":"2026-10-14T08:00:00Z","triggered_by_this_key":false,"data":{"product_id":1201,"sku":"TEE-001","changed_fields":["title","price:eu"]}}
  • product.created (also a product restored from the trash), product.updated (changed_fields names what changed), product.deleted (moved to the trash), stock.updated (variant_sku, location, available).
  • Changes made with your own key are sent too, with triggered_by_this_key: true: skip them to avoid echoes.
  • 10-second timeout, redirects not followed; answer with any 2xx. Events leave within about a minute of the change.
  • Retries. Any other answer is retried about 30 seconds, 1, 2, 4, 8, 16 and 32 minutes later — 8 attempts in all — then the event is dropped. Every attempt carries the same id (also in X-Nucleo-Webhook-Id): skip an id you have already handled. Events can arrive out of order.
  • The last success and the last error of each webhook are in GET /webhooks/{id}.

Verify the signature before trusting the body. X-Nucleo-Signature is t=<unix time>,v1=<signature>, where the signature is the lowercase hex HMAC-SHA256 of <t>.<raw body> keyed with the webhook's secret. Compute it on the exact bytes received, compare in constant time and reject a t older than 5 minutes.

// Express
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const app = express();

function verify(header, rawBody, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!parts.v1 || !Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"));
  const given = Buffer.from(parts.v1);
  return expected.length === given.length && timingSafeEqual(expected, given);
}

app.post("/nucleo/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verify(req.get("X-Nucleo-Signature") ?? "", raw, process.env.NUCLEO_WEBHOOK_SECRET)) {
    return res.sendStatus(403);
  }
  const event = JSON.parse(raw);
  if (!event.triggered_by_this_key) queueForSync(event); // answer fast, work later
  res.sendStatus(204);
});