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.
- Open
POST /pos/v1/sync/snapshotsreturns a snapshot id. - StreamSend the whole catalogue in batches of up to 200 with
POST /pos/v1/sync/snapshots/{id}/items. - Commit
POST /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.
{
"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 };# A tiny client (pip install requests). Set NUNUA_KEY to the shop's key.
import os
import time
import requests
BASE = "https://nunuapro.app/api/pos/v1"
def nunua(path, method="GET", body=None, idempotency_key=None):
headers = {"Authorization": f"Bearer {os.environ['NUNUA_KEY']}"}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
res = requests.request(method, BASE + path, json=body, headers=headers, timeout=30)
data = res.json() if res.content else {}
if not res.ok:
err = data.get("error", {})
raise RuntimeError(f"{res.status_code} {err.get('code', '')} {err.get('message', '')}")
return data
def sync_catalogue(products):
"""Send the whole catalogue in batches of 200; products no longer sent are archived."""
snapshot = nunua("/sync/snapshots", "POST")["snapshot_id"]
for i in range(0, len(products), 200):
results = nunua(f"/sync/snapshots/{snapshot}/items", "POST", {"products": products[i : i + 200]})["results"]
for r in results:
if r["status"] == "rejected":
print("rejected", r["external_id"], r["errors"])
return nunua(f"/sync/snapshots/{snapshot}/commit", "POST", {})
def update_stock(items):
"""On every sale, stock receipt or price change."""
return nunua("/inventory", "PATCH", {"items": items})
def poll_orders(handle, after=0):
"""Local POS (cannot receive webhooks): poll the change feed, remembering the last event id."""
while True:
for event in nunua(f"/events?after={after}")["events"]:
handle(event) # record the sale / void it, send stock BEFORE the ack
after = event["event_id"] # persist this so a restart continues from here
time.sleep(5)
def ack(order_id, body):
return nunua(f"/orders/{order_id}/ack", "POST", body)<?php
// A tiny client using only PHP's curl extension. Set NUNUA_KEY to the shop's key.
const NUNUA_BASE = 'https://nunuapro.app/api/pos/v1';
function nunua(string $path, string $method = 'GET', ?array $body = null, ?string $idempotencyKey = null): array
{
$headers = ['Authorization: Bearer ' . getenv('NUNUA_KEY'), 'Content-Type: application/json'];
if ($idempotencyKey) {
$headers[] = 'Idempotency-Key: ' . $idempotencyKey;
}
$ch = curl_init(NUNUA_BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30,
CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode((string) $raw, true) ?? [];
if ($status >= 400) {
throw new RuntimeException("$status " . ($data['error']['code'] ?? '') . ' ' . ($data['error']['message'] ?? ''));
}
return $data;
}
// Send the whole catalogue in batches of 200; products no longer sent are archived.
function syncCatalogue(array $products): array
{
$snapshot = nunua('/sync/snapshots', 'POST')['snapshot_id'];
foreach (array_chunk($products, 200) as $chunk) {
foreach (nunua("/sync/snapshots/$snapshot/items", 'POST', ['products' => $chunk])['results'] as $r) {
if ($r['status'] === 'rejected') {
error_log('rejected ' . $r['external_id'] . ': ' . implode(', ', $r['errors']));
}
}
}
return nunua("/sync/snapshots/$snapshot/commit", 'POST', []);
}
// On every sale, stock receipt or price change.
function updateStock(array $items): array
{
return nunua('/inventory', 'PATCH', ['items' => $items]);
}
// Local POS (cannot receive webhooks): poll the change feed, remembering the last event id.
function pollOrders(callable $handle, int $after = 0): void
{
while (true) {
foreach (nunua("/events?after=$after")['events'] as $event) {
$handle($event); // record the sale / void it, send stock BEFORE the ack
$after = $event['event_id']; // persist this so a restart continues from here
}
sleep(5);
}
}
function ack(int $orderId, array $body): array
{
return nunua("/orders/$orderId/ack", 'POST', $body);
}What happens to what you send
| You send | We do |
|---|---|
external_id | Your own stable id for the product. We update in place by it, so it must never change. |
name | Keep 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_qty | In 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, options | Updated 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. |
barcode | Matched 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[].url | Fetched in the background (https, public host, up to 8 MB, a real image). A photo the shop uploaded itself is never replaced. |
active: false | Archived: hidden from customers, history kept. Send it active again to bring it back. DELETE /pos/v1/products/{externalId} does the same. |
| Two products, one name | Both 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.