Nunua Pro Developers
Building

Syncing the catalogue

Two calls keep a storefront true to the shop: a snapshot when you send everything, and an inventory update whenever something changes.

First load: snapshots

Use a snapshot for the first load and for a nightly reconcile.

  1. OpenPOST /pos/v1/sync/snapshots returns a snapshot id.
  2. StreamSend the whole catalogue in batches of up to 200 with POST /pos/v1/sync/snapshots/{id}/items.
  3. CommitPOST /pos/v1/sync/snapshots/{id}/commit. Products that were in an earlier sync but not in this one have left your POS and are archived: hidden, never deleted, so order history survives.

A snapshot that would archive more than 40 % of the catalogue is refused with 422 too_many_removed, which protects a shop from a broken export. Send {"force": true} on commit only when the removal is real.

To add or change a few products outside a snapshot, use POST /pos/v1/products/batch; it never archives anything.

Every day: inventory

Call PATCH /pos/v1/inventory on every sale, stock receipt or price change, with up to 500 items per call.

PATCH /pos/v1/inventory
{
  "items": [
    { "external_id": "SKU-1", "stock_qty": 39, "updated_at": "2026-11-03T10:15:00+03:00" },
    { "external_id": "PZ-1", "variant_external_id": "PZ-1-L", "price": 18000, "updated_at": "2026-11-03T10:15:00+03:00" }
  ]
}

Always send updated_at. If it is older than what we already hold, the item is answered as stale and ignored, so a local POS replaying a backlog after being offline can't overwrite newer data.

A small client

A complete client you can copy: it streams a snapshot and pushes inventory changes with an idempotency key.

// A tiny client. Node 18+ (global fetch). Set NUNUA_KEY to the shop's key.
const BASE = 'https://nunuapro.app/api/pos/v1';

async function nunua(path, { method = 'GET', body, idempotencyKey } = {}) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      Authorization: `Bearer ${process.env.NUNUA_KEY}`,
      'Content-Type': 'application/json',
      ...(idempotencyKey && { 'Idempotency-Key': idempotencyKey }),
    },
    body: body && JSON.stringify(body),
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(`${res.status} ${json.error?.code ?? ''} ${json.error?.message ?? ''}`);
  return json;
}

// Send the catalogue in batches of 200 (a full snapshot, so removed products are archived).
async function syncCatalogue(products) {
  const { snapshot_id } = await nunua('/sync/snapshots', { method: 'POST' });
  for (let i = 0; i < products.length; i += 200) {
    const { results } = await nunua(`/sync/snapshots/${snapshot_id}/items`, { method: 'POST', body: { products: products.slice(i, i + 200) } });
    for (const r of results.filter((r) => r.status === 'rejected')) console.warn('rejected', r.external_id, r.errors);
  }
  return nunua(`/sync/snapshots/${snapshot_id}/commit`, { method: 'POST', body: {} });
}

// On every sale / stock receipt / price change.
const updateStock = (items) => nunua('/inventory', { method: 'PATCH', body: { items } });

// Local POS (cannot receive webhooks): poll the change feed, remembering the last event id.
async function pollOrders(handle, after = 0) {
  for (;;) {
    const feed = await nunua(`/events?after=${after}`);
    for (const event of feed.events) {
      await handle(event);              // record the sale / void it, send stock BEFORE the ack
      after = event.event_id;           // persist this so a restart continues from here
    }
    await new Promise((r) => setTimeout(r, 5000));
  }
}

const ack = (orderId, body) => nunua(`/orders/${orderId}/ack`, { method: 'POST', body });

module.exports = { nunua, syncCatalogue, updateStock, pollOrders, ack };

What happens to what you send

You sendWe do
external_idYour own stable id for the product. We update in place by it, so it must never change.
nameKeep yours as pos_name and show a tidied name on the storefront ("COLGATE MAX 100ML" → "Colgate Max 100ml"). If the owner renamed the product, their wording stays until your name changes.
stock_qtyIn stock while stock_qty − the shop's buffer − units held for unacknowledged orders > 0. Send 0, not a negative number. track_stock: false means "always available while active".
variants, optionsUpdated in place by their own external_id. When you send variants it is the complete set: missing ones are archived. A product's price is its cheapest live variation. For packs (piece, dozen, carton) make each pack a variant with its own stock.
barcodeMatched to the shared catalogue only when it is a valid GTIN (8, 12, 13 or 14 digits with the right check digit). Anything else is kept as typed and ignored for matching.
images[].urlFetched in the background (https, public host, up to 8 MB, a real image). A photo the shop uploaded itself is never replaced.
active: falseArchived: hidden from customers, history kept. Send it active again to bring it back. DELETE /pos/v1/products/{externalId} does the same.
Two products, one nameBoth are kept; the second becomes "Name (SKU)" and its result carries renamed_to.

New products start hidden

Imported products are unlisted until the shop owner approves them in the app, unless the owner chose "list everything". The listed flag in each result tells you which. This protects a storefront from a 4 000-row import of till-style names going public at once.