Skip to content

Pagination & incremental sync

Every list endpoint pages the same way, and product lists can be limited to what changed since your last sync — the cheapest way to keep an ERP, storefront or search index up to date.

Pages

Send page (from 1) and limit (1–250, default 50). Paginated responses carry a pagination block; stop when hasMore is false.

pagination
json
{
  "page": 2,
  "limit": 250,
  "total": 1284,
  "totalPages": 6,
  "hasMore": true
}

Small, bounded collections — categories, attributes, attribute groups, collections, channels — are returned whole as { data: [...] } without a pagination block.

Incremental sync with updated_since

  • Remember when a sync started and pass it as updated_since next time, with sort=updatedAt:asc. Using the start time (not the end time) means edits made while you were syncing are picked up again rather than missed.
  • Every change — in the app, through the API, an import, a bulk edit or AI enrichment — updates the product's updatedAt and bumps its version. Changing a product's collections updates it too.
  • Pages are computed per request: if products change while you page, some may move between pages. Syncing oldest-first and re-reading from your saved start time makes this harmless — upserts by SKU are idempotent.
  • Deletions do not appear in lists. Subscribe to the product.deleted webhook, or periodically compare the SKU list (GET /products?limit=250) with yours.
Node.js
javascript
const BASE = "https://library.retailcommerceai.com/api/v1";
const headers = { Authorization: `Bearer ${process.env.CL_API_KEY}` };

/** Every product changed since `since` (ISO string), oldest change first. */
async function* changedSince(since) {
  for (let page = 1; ; page++) {
    const url = `${BASE}/products?updated_since=${encodeURIComponent(since)}&sort=updatedAt:asc&limit=250&page=${page}`;
    const res = await fetch(url, { headers });
    if (res.status === 429) {
      await new Promise((r) => setTimeout(r, Number(res.headers.get("retry-after") ?? 1) * 1000));
      page--; // retry the same page
      continue;
    }
    if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
    const { data, pagination } = await res.json();
    yield* data;
    if (!pagination.hasMore) return;
  }
}

const startedAt = new Date().toISOString();
let cursor = await loadCursor(); // e.g. from your database; first run: "1970-01-01T00:00:00Z"
for await (const product of changedSince(cursor)) {
  await upsertIntoErp(product);
}
await saveCursor(startedAt);

Filters

Product lists (and exports, through the same filter object) accept:

ParameterMeaning
qText search in name, SKU, GTIN, MPN and brand
sku / idsComma-separated SKUs or product ids (up to 500)
statusdraft, in_review, approved, archived (comma-separated)
typesimple, parent, variant
categoryCategory ids or codes; subcategories are included unless include_subcategories=false
collectionCollection ids or codes
tag / brandTag(s) and exact brand
updated_sinceISO-8601 timestamp — products changed at or after it
completeness_min / completeness_max0–100
has_imagestrue or false
top_leveltrue: simple and parent products only
sortupdatedAt:desc (default), updatedAt:asc, createdAt:desc, name:asc|desc, sku:asc|desc, price:asc|desc, completeness:asc|desc — or -updated_at style
includevariants — nest variants under their parent (lists then show top-level products)
localeShow content in another workspace language, falling back per field

Polling one product cheaply

Product responses carry an ETag. Send it back as If-None-Match and the API answers 304 Not Modified with no body while the product is unchanged.

Prefer webhooks for real-time

Polling every few minutes is fine for nightly or hourly syncs. For near real-time updates, register a webhook for product.updated and fetch the product when it fires.