Sync your product catalogue with ReceiptRoller
You will build a Node.js script that copies the product catalogue from your own system or spreadsheet into ReceiptRoller. It reads a local JSON or CSV file, matches each row to an existing product by SKU, creates the products that are missing, and updates only the fields that changed. Running it again creates no duplicates, so you can schedule it as a nightly job.
Before you start
- An app registered with ReceiptRoller (Creating a new app)
- An access token carrying the scopes
store.products.read(read) andstore.products.write(create and update) (Your first API call: from app registration to listing transactions, OAuth Scopes Reference and Availability) - The ID of the business account you are syncing into (Listing business accounts, stores, and POS terminals)
- That business account on the Starter plan or higher. The developer API is available from Starter. There is no sandbox yet, so test against a business account that holds real products or test products you created for the purpose.
- Node.js 20 or later (the script uses the built-in
fetch; no packages to install)
Who can write, and which prices you see
Creating, updating and deleting products works only with one of these tokens:
- A token issued to your app for the business account, with no person behind it (an integration token)
- A token for a person who holds the
SuperUser(owner),OperationsManagerorProductManagerrole
Any other member's token gets 403 on writes. On reads, every store's prices and the cost prices are visible to integration tokens and to SuperUser, OperationsManager, ProductManager and FinanceManager. Other members see only the prices of the stores they are placed at, and cost prices (baseCostPrice and each store's costPrice) read as 0. A token that can write always sees cost prices, so cost comparisons in a sync script are reliable.
Step 1: List the products
GET /api/v1/products returns every product in the business account (deleted ones excluded) in a single response. There is no paging; totalCount is the number of products returned.
curl -s "https://receiptroller.io/api/v1/products?organizationId={organizationId}" \
-H "Authorization: Bearer {access_token}"
{
"organizationId": "{organizationId}",
"products": [
{
"id": "{productId}",
"productName": "House Blend M",
"sku": "CFE-BLD-M",
"janCode": "4912345000017",
"category": "Drinks",
"brand": "",
"supplier": "",
"description": "",
"pricing": {
"basePrice": 480,
"baseCostPrice": 120,
"taxRate": 0.1,
"storePrices": []
},
"status": "Active",
"integrations": {
"squareCatalogObjectId": "",
"squareVariationId": "",
"smaregiProductId": ""
},
"createdAt": "...",
"updatedAt": "..."
}
],
"totalCount": 1
}
Three query parameters narrow the list:
| Parameter | Effect |
|---|---|
search | Search across product name, SKU and JAN code |
category | Exact category match |
brand | Exact brand match |
curl -s "https://receiptroller.io/api/v1/products?organizationId={organizationId}&search=CFE-BLD-M" \
-H "Authorization: Bearer {access_token}"
search goes through a full-text search engine; it is not an exact SKU lookup. Don't use it to match products during a sync. Fetch the whole list once and match SKUs on your side.
Step 2: Read one product
curl -s "https://receiptroller.io/api/v1/products/{productId}?organizationId={organizationId}" \
-H "Authorization: Bearer {access_token}"
Returns one product in the same shape as the list. For a product with variants, variants.items holds each variant. An ID that is not in the business account returns 404 with {"error": "product not found in this business account"}.
Step 3: Create a product
Only productName is required. If you omit taxRate it is set to 0.1; if you omit status it is set to Active.
curl -s -X POST "https://receiptroller.io/api/v1/products?organizationId={organizationId}" \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{
"productName": "Iced Tea",
"sku": "TEA-ICE-001",
"janCode": "4912345000024",
"category": "Drinks",
"basePrice": 380,
"baseCostPrice": 95,
"taxRate": 0.08,
"status": "Active"
}'
{
"id": "{productId}",
"organizationId": "{organizationId}",
"productName": "Iced Tea",
"sku": "TEA-ICE-001",
"janCode": "4912345000024",
"category": "Drinks",
"pricing": { "basePrice": 380, "baseCostPrice": 95, "taxRate": 0.08, "storePrices": [] },
"status": "Active",
...
}
- Success is
200with the created product. Use itsidfor later updates. - An
organizationIdin the body is ignored; the product is created in the business account resolved from the token and the query string. - Use one of
Active,DraftorDiscontinuedforstatus. - A second product with the same SKU is not refused; you get two. Preventing duplicates is the caller's job (Step 5).
- If the business account sends its products to Square, a product with a price may also be created on the Square register. A duplicate here can become a duplicate there too.
Step 4: Update only what changed (PATCH)
Use PATCH /api/v1/products/{productId} for syncing. It changes only the fields you send and leaves everything else as it is.
curl -s -X PATCH "https://receiptroller.io/api/v1/products/{productId}?organizationId={organizationId}" \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{ "basePrice": 500 }'
The updated product comes back with 200. PATCH accepts these fields:
productName, description, category, brand, supplier, sku, janCode, basePrice, baseCostPrice, taxRate, status
PUT /api/v1/products/{productId} also exists, but it replaces the whole product. String fields you leave out become empty, taxRate goes back to 0.1, status to Active, and the cover image (imageUrl) and per-store prices (storePricesJson) are cleared. Use PUT only for fields PATCH cannot change, such as per-store prices, and send back every value you read just before. The request fields are listed in the ProductDto reference.
Step 5: Make the sync idempotent
Use the SKU as the sync key. ReceiptRoller does not enforce unique SKUs, so work in this order:
- Fetch every product once with
GET /api/v1/productsand build a map keyed by SKU (case-insensitive). - If two or more products share a SKU, skip that SKU, report it, and resolve the duplicate in the ReceiptRoller dashboard.
- Create a SKU that is not in the map with
POST, then add the returned product to the map, so the same SKU appearing twice in one file is treated as an update the second time. - For a SKU that is in the map, compare your values with the current ones and
PATCHonly the fields that differ. Send nothing when nothing changed. - Send requests one at a time.
To match on JAN code instead, key the map on janCode; the rest is the same. Products imported from Smaregi or Square carry the source system's ID in integrations.smaregiProductId or integrations.squareCatalogObjectId.
Step 6: Run the sync script
The input file is JSON (an array of objects) or CSV (header row first). Name the columns sku plus any of the fields PATCH accepts.
sku,productName,category,basePrice,taxRate,janCode
CFE-BLD-M,House Blend M,Drinks,500,0.1,4912345000017
TEA-ICE-001,Iced Tea,Drinks,380,0.08,4912345000024
Save the following as sync-products.mjs. The CSV reader is deliberately simple and does not handle commas or line breaks inside a value; pass that kind of data as JSON.
// sync-products.mjs — create or update ReceiptRoller products from a local file.
// Usage: node sync-products.mjs products.json [--dry-run]
import { readFile } from "node:fs/promises";
const BASE_URL = "https://receiptroller.io";
const TOKEN = process.env.RR_ACCESS_TOKEN;
const ORGANIZATION_ID = process.env.RR_ORGANIZATION_ID;
const INPUT_PATH = process.argv[2];
const DRY_RUN = process.argv.includes("--dry-run");
// The fields PATCH accepts besides sku. Only these are compared and sent.
const SYNCED_FIELDS = [
"productName", "description", "category", "brand", "supplier",
"janCode", "basePrice", "baseCostPrice", "taxRate", "status",
];
const NUMBER_FIELDS = new Set(["basePrice", "baseCostPrice", "taxRate"]);
async function callApi(method, path, body) {
const url = new URL(path, BASE_URL);
url.searchParams.set("organizationId", ORGANIZATION_ID);
const response = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await response.text();
if (!response.ok) {
const error = new Error(`${method} ${url.pathname} returned ${response.status} ${text}`);
error.status = response.status;
throw error;
}
return text ? JSON.parse(text) : null;
}
// JSON: an array of objects. CSV: a header row, then one product per line
// (simple CSV only: no commas or line breaks inside a value).
async function loadRows(path) {
const text = await readFile(path, "utf8");
if (path.endsWith(".json")) return JSON.parse(text);
const [headerLine, ...lines] = text.split(/\r?\n/).filter((line) => line.trim() !== "");
const headers = headerLine.split(",").map((h) => h.trim());
return lines.map((line) => {
const values = line.split(",");
return Object.fromEntries(headers.map((h, i) => [h, (values[i] ?? "").trim()]));
});
}
// Your row as a request body: sku plus whichever synced fields have a value.
function toRequestBody(row) {
const body = { sku: String(row.sku ?? "").trim() };
for (const field of SYNCED_FIELDS) {
const value = row[field];
if (value === undefined || value === "") continue;
body[field] = NUMBER_FIELDS.has(field) ? Number(value) : String(value);
}
return body;
}
// The same fields as ReceiptRoller currently holds them.
function currentValues(product) {
return {
productName: product.productName,
description: product.description,
category: product.category,
brand: product.brand,
supplier: product.supplier,
janCode: product.janCode,
basePrice: product.pricing.basePrice,
baseCostPrice: product.pricing.baseCostPrice,
taxRate: product.pricing.taxRate,
status: product.status,
};
}
function changedFields(wanted, current) {
const changes = {};
for (const field of SYNCED_FIELDS) {
if (wanted[field] !== undefined && wanted[field] !== current[field]) {
changes[field] = wanted[field];
}
}
return changes;
}
const skuKey = (sku) => sku.trim().toLowerCase();
async function main() {
if (!TOKEN || !ORGANIZATION_ID || !INPUT_PATH) {
console.error("Set RR_ACCESS_TOKEN and RR_ORGANIZATION_ID, and pass the input file.");
process.exit(1);
}
const rows = await loadRows(INPUT_PATH);
// One read of the whole catalogue, indexed by SKU.
const { products } = await callApi("GET", "/api/v1/products");
const bySku = new Map();
const duplicateSkus = new Set();
for (const product of products) {
if (!product.sku) continue;
const key = skuKey(product.sku);
if (bySku.has(key)) duplicateSkus.add(key);
bySku.set(key, product);
}
const counts = { created: 0, updated: 0, unchanged: 0, skipped: 0, failed: 0 };
for (const row of rows) {
const wanted = toRequestBody(row);
if (!wanted.sku) { counts.skipped++; console.warn("skip: row without sku"); continue; }
const key = skuKey(wanted.sku);
if (duplicateSkus.has(key)) {
counts.skipped++;
console.warn(`skip ${wanted.sku}: more than one product has this SKU, fix it in ReceiptRoller first`);
continue;
}
try {
const existing = bySku.get(key);
if (!existing) {
if (!wanted.productName) { counts.skipped++; console.warn(`skip ${wanted.sku}: productName is required to create`); continue; }
if (!DRY_RUN) {
const created = await callApi("POST", "/api/v1/products", wanted);
bySku.set(key, created);
}
counts.created++;
console.log(`create ${wanted.sku}`);
continue;
}
const changes = changedFields(wanted, currentValues(existing));
if (Object.keys(changes).length === 0) { counts.unchanged++; continue; }
if (!DRY_RUN) await callApi("PATCH", `/api/v1/products/${existing.id}`, changes);
counts.updated++;
console.log(`update ${wanted.sku}: ${Object.keys(changes).join(", ")}`);
} catch (error) {
// A token or permission problem will fail every row the same way.
if (error.status === 401 || error.status === 403) throw error;
counts.failed++;
console.error(`fail ${wanted.sku}: ${error.message}`);
}
}
console.log(DRY_RUN ? "dry run:" : "done:", counts);
}
main().catch((error) => {
console.error(error.message);
process.exit(1);
});
Run it with --dry-run first to see what would be created and updated.
export RR_ACCESS_TOKEN="{access_token}"
export RR_ORGANIZATION_ID="{organizationId}"
node sync-products.mjs products.csv --dry-run
update CFE-BLD-M: basePrice
create TEA-ICE-001
dry run: { created: 1, updated: 1, unchanged: 0, skipped: 0, failed: 0 }
If that looks right, run it again without --dry-run. A second real run reports everything as unchanged.
Common errors
| Status | Cause | What to do |
|---|---|---|
401 | No token, or the token is invalid or expired | Refresh the token and run again (Your first API call: from app registration to listing transactions) |
403 | Missing scope, an organizationId that differs from the token's business account, not a member, or no role that allows writes | Check the scopes, the ID and the role (403/401 is returned) |
400 {"error": "productName is required"} | productName empty on create | Add a product name and send again |
400 (body with errors) | Malformed JSON, or a non-number in a numeric field such as basePrice | Fix the type of the fields listed in errors |
404 {"error": "product not found in this business account"} | No product with that ID in the business account | Re-read the list and check the ID |
The products endpoints have no rate limit of their own, but send bulk work one request at a time, as this script does.
Next steps
-
Connect an AI agent to ReceiptRoller over MCPConnect Claude as an MCP connector, then build your own MCP client with dynamic client registration, PKCE and JSON-RPC calls over HTTP.
-
Receive new sales by webhook and post them to SlackRegister a receipt.created webhook, verify its signature and drop duplicates in Node.js, and post each new sale to a Slack channel.
-
Show a store's reservations in your own calendarFetch the next 7 days of a store's reservations with the Reservations API and write them to an iCalendar (.ics) file with Node.js.