商品マスタを自社システムと同期する

クイックスタート 商品管理 PIM Products API 同期 Node.js

自社の基幹システムや表計算で管理している商品マスタを、レシートローラーの商品マスタへ反映する Node.js スクリプトを作ります。スクリプトは手元の JSON または CSV を読み、SKU で既存の商品と突き合わせ、ない商品は作成し、値が変わった項目だけを更新します。何度実行しても同じ商品が二重に作られないので、夜間バッチなどで定期的に動かせます。

始める前に用意するもの

書き込める人と見える価格

商品の作成・更新・削除は、次のいずれかのトークンでだけ行えます。

  • ビジネスアカウントに対してアプリに発行されたトークン(利用者を伴わない連携用トークン)
  • オーナー(SuperUser)、OperationsManager、商品マネージャー(ProductManager)のいずれかのロールを持つ人のトークン

それ以外のメンバーのトークンでは書き込みは 403 になります。読み取りでは、全店舗の価格と原価が見えるのは連携用トークンと SuperUser・OperationsManager・ProductManager・FinanceManager だけです。ほかのメンバーには自分が所属する店舗の価格だけが返り、原価(baseCostPrice・店舗別の costPrice)は 0 になります。書き込みできるトークンでは常に原価まで見えるので、同期スクリプトの比較が原価の値で狂うことはありません。

手順1 商品一覧を取得する

GET /api/v1/products は、ビジネスアカウントの商品(削除済みを除く)をすべて一度に返します。ページングはなく、totalCount は返した件数です。

curl -s "https://receiptroller.io/api/v1/products?organizationId={organizationId}" \
  -H "Authorization: Bearer {access_token}"
{
  "organizationId": "{organizationId}",
  "products": [
    {
      "id": "{productId}",
      "productName": "ブレンドコーヒー M",
      "sku": "CFE-BLD-M",
      "janCode": "4912345000017",
      "category": "ドリンク",
      "brand": "",
      "supplier": "",
      "description": "",
      "pricing": {
        "basePrice": 480,
        "baseCostPrice": 120,
        "taxRate": 0.1,
        "storePrices": []
      },
      "status": "Active",
      "integrations": {
        "squareCatalogObjectId": "",
        "squareVariationId": "",
        "smaregiProductId": ""
      },
      "createdAt": "...",
      "updatedAt": "..."
    }
  ],
  "totalCount": 1
}

絞り込みに使えるクエリパラメータは次の3つです。

パラメータ内容
search商品名・SKU・JANコードを対象にした検索
categoryカテゴリの完全一致
brandブランドの完全一致
curl -s "https://receiptroller.io/api/v1/products?organizationId={organizationId}&search=CFE-BLD-M" \
  -H "Authorization: Bearer {access_token}"

search は検索エンジンを通した全文検索で、SKU の完全一致ではありません。同期の突き合わせには使わず、一覧全体を一度取得して手元で SKU を照合してください。

手順2 1件の商品を取得する

curl -s "https://receiptroller.io/api/v1/products/{productId}?organizationId={organizationId}" \
  -H "Authorization: Bearer {access_token}"

一覧と同じ形の商品オブジェクトが1件返ります。バリエーションのある商品は variants.items に各バリエーションが入ります。そのビジネスアカウントにない ID を指定すると 404 と {"error": "product not found in this business account"} が返ります。

手順3 商品を作成する

必須項目は productName だけです。taxRate を省略すると 0.1、status を省略すると 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": "アイスティー",
    "sku": "TEA-ICE-001",
    "janCode": "4912345000024",
    "category": "ドリンク",
    "basePrice": 380,
    "baseCostPrice": 95,
    "taxRate": 0.08,
    "status": "Active"
  }'
{
  "id": "{productId}",
  "organizationId": "{organizationId}",
  "productName": "アイスティー",
  "sku": "TEA-ICE-001",
  "janCode": "4912345000024",
  "category": "ドリンク",
  "pricing": { "basePrice": 380, "baseCostPrice": 95, "taxRate": 0.08, "storePrices": [] },
  "status": "Active",
  ...
}
  • 成功時は 200 で、作成された商品が返ります。以降の更新には id を使います。
  • 本文に organizationId を入れても無視され、トークンとクエリで決まったビジネスアカウントに作成されます。
  • status は Active(販売中)、Draft(下書き)、Discontinued(廃止)のいずれかを使ってください。
  • 同じ SKU の商品がすでにあっても作成は拒否されず、2件目ができます。重複を防ぐのは呼び出し側の役目です(手順5)。
  • ビジネスアカウントがスクエアへの商品送信を有効にしている場合、価格のある商品はスクエアのレジにも作成されることがあります。重複して作ると、レジ側にも重複が残ります。

手順4 変わった項目だけを更新する(PATCH)

同期には PATCH /api/v1/products/{productId} を使います。送った項目だけを書き換え、それ以外はそのまま残します。

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 }'

更新後の商品オブジェクトが 200 で返ります。PATCH で変更できるのは次の項目です。

productName、description、category、brand、supplier、sku、janCode、basePrice、baseCostPrice、taxRate、status

PUT /api/v1/products/{productId} もありますが、こちらは商品全体の置き換えです。送らなかった文字列項目は空になり、taxRate は 0.1、status は Active に戻り、表紙画像(imageUrl)や店舗別価格(storePricesJson)も消えます。PUT を使うのは、店舗別価格のように PATCH で扱えない項目を書き換えるときだけにし、その場合は直前に取得した値をすべて送り直してください。リクエスト本文の項目は ProductDto リファレンスを参照してください。

手順5 同期を冪等にする

同期キーには SKU を使います。レシートローラー側で SKU は一意に強制されないため、次の順で処理します。

  • 最初に GET /api/v1/products で全件を取得し、SKU(大文字小文字を区別しない)をキーにした対応表を作ります。
  • 同じ SKU の商品が2件以上ある場合、その SKU は処理せずに報告し、管理画面で重複を解消します。
  • 対応表にない SKU は POST で作成し、返った商品を対応表に追加します。同じファイル内で同じ SKU が2回出てきても2件目は更新扱いになります。
  • 対応表にある SKU は、手元の値と現在の値を比べ、違う項目だけを PATCH します。変わっていなければリクエストを送りません。
  • リクエストは1件ずつ順番に送ります。

JAN コードで照合したい場合は、対応表のキーを janCode に変えるだけで同じ考え方が使えます。スマレジやスクエアから取り込んだ商品は、integrations.smaregiProductId・integrations.squareCatalogObjectId に連携元の ID が入っています。

手順6 同期スクリプトを動かす

入力ファイルは JSON(オブジェクトの配列)または CSV(1行目が見出し)です。列名には sku と、上の PATCH で変更できる項目名を使います。

sku,productName,category,basePrice,taxRate,janCode
CFE-BLD-M,ブレンドコーヒー M,ドリンク,500,0.1,4912345000017
TEA-ICE-001,アイスティー,ドリンク,380,0.08,4912345000024

次のスクリプトを sync-products.mjs として保存します。この CSV 読み込みは簡易版で、値の中にカンマや改行を含むファイルには対応しません。そうしたデータは 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);
});

まず --dry-run で、何が作成・更新されるかだけを確認します。

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 }

内容が正しければ --dry-run を外して実行します。もう一度実行すると、すべて unchanged になります。

よくあるエラーと対処

ステータス原因対処
401トークンがない、無効、または期限切れトークンを更新して再実行します(はじめてのAPI呼び出し:アプリ登録から取引一覧の取得まで)
403スコープ不足、organizationId がトークンのビジネスアカウントと違う、メンバーでない、または書き込みに必要なロールがないスコープ、ID、ロールを確認します(403/401が返る)
400 {"error": "productName is required"}作成時に productName が空商品名を入れて送り直します
400(errors を含む本文)JSON の形が不正、または basePrice などの数値項目に数値以外を送ったerrors に出た項目の型を直します
404 {"error": "product not found in this business account"}その ID の商品がビジネスアカウントにない一覧を取り直して ID を確認します

商品APIには専用のレート制限は設定されていませんが、一括処理はこのスクリプトのように1件ずつ順番に送ってください。

次のステップ

公開日: 2026-10-26 更新日: 2026-10-26