Contents

Sync your catalog

Load products into a Nucleo catalog, keep prices and stock current, download what changed and get webhooks.

How it works

The Catalog API keeps the software of another company — an ERP, a PLM, a supplier portal, a marketplace connector — in sync with one Nucleo catalog. A typical integration does four things:

  1. Loads the catalog once, in bulk.
  2. Pushes prices and stock as they change.
  3. Downloads what changed in Nucleo since its last run (updated_since).
  4. Listens to webhooks, so it does not have to poll.

Products are matched by SKU: sending the same product twice updates it, it never creates a duplicate. Every write goes through the same import Nucleo uses for files and other connected systems, so it follows the same rules and shows up in the product's Activity with the name of your key as the author. Writing through the API never publishes anything to Shopify or other sales channels: publishing stays a decision taken in Nucleo.

Before you start

An owner or admin of the catalog creates the key in Nucleo; you need its value.

  1. In Nucleo, go to Settings > Organization > Connections and open Catalog API (under Library, tab Catalog, until the catalog has a key; then under Connected).
  2. Click New key, give it a name (it appears as the author of every change in the Activity), and choose:
    • Access: Read only or Read and write.
    • Field rule: who wins when a field already has a value in Nucleo (see Field rules).
  3. Copy the key (nck_…). It is shown only once; if you lose it, create a new one and revoke the old one.

A key works for that catalog only: there is no header to choose a company or a catalog. Check it with GET /ping:

curl https://api-catalog.nucleoplatform.com/api/catalog/v1/ping \
  -H "Authorization: Bearer $NUCLEO_CATALOG_KEY"

Then read the codes your products will refer to: GET /price-lists, GET /locations, GET /locales, GET /attributes (with the allowed values) and GET /categories. They are managed in Nucleo. A code that does not exist is an error that names it, never a value silently dropped.

Load your products

Send up to 100 products per call to POST /products/batch. Each product is checked and written on its own: an invalid one comes back as invalid with its errors, the others are written.

curl -X POST https://api-catalog.nucleoplatform.com/api/catalog/v1/products/batch \
  -H "Authorization: Bearer $NUCLEO_CATALOG_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: load-2026-10-14-part-1" \
  -d '{
  "products": [
    {
      "sku": "TEE-001",
      "external_id": "erp-778",
      "title": "Basic tee",
      "description": "<p>Organic cotton.</p>",
      "status": "active",
      "brand": "Acme",
      "category": "Clothing > Tops",
      "attributes": { "color": "red", "fit": "regular" },
      "translations": { "it": { "title": "Maglietta basic" } },
      "variants": [
        {
          "sku": "TEE-001-M",
          "barcode": "8001234567890",
          "size": "M",
          "prices": [{ "price_list": "eu", "price": 19.9, "sale_price": 15 }],
          "stock": [{ "location": "main", "available": 7 }]
        }
      ]
    }
  ]
}'

The answer has one result per product, in the order you sent them:

{
  "data": [
    { "index": 0, "sku": "TEE-001", "product_id": 1201, "status": "created", "errors": [], "notes": [] }
  ],
  "summary": { "created": 1, "updated": 0, "partial": 0, "skipped": 0, "failed": 0, "invalid": 0 }
}
  • partial: some variants could not be written (see errors), the rest was.
  • skipped: your key's field rule left nothing to write.
  • notes explains fields that were not written — for example a field another connected system owns.

More than 100 products? Queue them with POST /imports (up to 5,000 per call). The answer is 202 with the import id; follow it with GET /imports/{id} until status is done, partial or failed. Products are written in groups of 100 and the results fill in as they go. Usually an import starts within a minute.

Good to know

  • Only the fields you send are written. An absent or empty field never clears a value in Nucleo.
  • Variants, prices and stock are added or updated, never removed by omission. To delete a variant use DELETE /products/{product}/variants/{variant}; to delete a product, DELETE /products/{product} (it goes to Nucleo's trash, restorable for 30 days).
  • external_id is your own id. Nucleo remembers it for your key: later you can send it instead of sku to find the product, and it comes back in every read.
  • image_urls are linked as they are. To have Nucleo download and store an image, use POST /products/{product}/images with its url (or upload the file).
  • One request can carry up to 20 MB.

Keep prices and stock current

Prices and stock change far more often than anything else. Send just those, by variant SKU, up to 1,000 lines per call — only the price lists and locations you name change:

curl -X PUT https://api-catalog.nucleoplatform.com/api/catalog/v1/stock \
  -H "Authorization: Bearer $NUCLEO_CATALOG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"stock": [
    {"sku": "TEE-001-M", "location": "main", "available": 12},
    {"sku": "TEE-001-L", "location": "main", "available": 0}
  ]}'

The answer has one result per line, in order: updated, or invalid / failed with the reason (for example an unknown SKU). available replaces the quantity at that location.

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);
});

Field rules: who wins on a field

Your key is a connection of the catalog, like any other connected system, and it has one field rule for all fields, chosen when the key is created (the catalog owner can change it):

Field ruleWhat the API does
sorgenteAlways updates the fields it sends. The default.
inserimentoOnly fills fields that are empty in Nucleo; values already there stay.
nucleoDoes not change existing fields.

With sorgente, the products your key wrote are managed by your key: people in Nucleo see their fields locked, including fields your key never sent, and change them in your system. Only the per-channel values set in Nucleo stay editable. If people should keep editing in Nucleo, choose inserimento or nucleo. The other way round, a field managed by another connected system (a PLM, an ERP) is never overwritten by your key: the call succeeds and notes says which fields were left out. Deleting products and variants, and changing categories and images directly, follow the same rule: allowed with sorgente; with inserimento only where Nucleo has nothing yet (categories, images); otherwise the answer is 409 field_rule.

GET /ping tells you the field rule of your key.

Errors and retries

Every error has the same shape, in English, with a stable code to branch on and the request_id to quote to support (it is also in the X-Request-Id header):

{
  "error": {
    "code": "validation_failed",
    "message": "The request is not valid: see details.",
    "request_id": "7c0f8a1e-3b2d-4e5f-9a6b-1c2d3e4f5a6b",
    "details": {
      "variants.0.prices.0.price_list": ["Unknown price list \"usa\". GET /price-lists lists them."]
    }
  }
}
StatusCodesWhat to do
400validation_failed, invalid_json, invalid_cursor, product_not_writtenFix the request.
401unauthorizedThe key is missing, wrong or revoked.
403read_only_keyUse a read and write key.
404product_not_found, variant_not_found, not_found…Check the id or SKU.
409field_rule, field_locked, idempotency_key_reused, idempotency_key_in_progressRead the message; retry only idempotency_key_in_progress.
413payload_too_largeSplit the request (20 MB at most).
429rate_limitedWait the seconds in Retry-After.
5xxinternal_error, catalog_not_readyRetry with backoff.

Retry writes safely with an Idempotency-Key header (any unique string, a UUID is fine). Sending the same request again with the same key within 48 hours returns the first answer without doing the work twice (Idempotent-Replayed: true). The same key with a different body is a 409. Answers 429 and 5xx are not stored, so you can retry them with the same key.

Limits

LimitValue
Requests per key600 per minute (the catalog owner can set 1–6,000)
Failed authentications per address30 in 5 minutes, then 429
Request body20 MB
Products per page1–200 (default 50)
POST /products/batch100 products
POST /imports5,000 products
PUT /prices, PUT /stock1,000 lines
Variants per product in one call500
image_urls per product50
Image uploadJPEG, PNG or WebP, 10 MB
Image from URL40 MB, never SVG
Categories per product200
Webhooks per key10
Idempotency-Keykept 48 hours