GuidesImport a catalog

Import an existing catalog

⚠️

There is no bulk or CSV import endpoint today. Product creation is one-at-a-time through the merchant catalog API. A native importer is planned, not shipped. This guide documents the real, shipped pattern: a scripted loop over product-create, made safe to re-run by keying on the product slug.

The building blocks are three fdk_ merchant routes, all under scope manage:own_catalog (the tenant is carried by the credential):

MethodPathPurpose
POST/v1/merchant/catalog/productsCreate one product.
POST/v1/merchant/catalog/products/:productId/variantsAdd a variant (SKU + price).
PUT/v1/merchant/catalog/products/:id/imageAttach an image ref.

Idempotency without an Idempotency-Key

Catalog writes do not take an Idempotency-Key header — that header is for money-mutating checkout calls. The natural idempotency key here is the slug, which is unique per tenant: creating a product whose slug already exists is refused with 409 slug_exists. A re-run that treats slug_exists as “already imported” converges instead of duplicating — the same convergence the built-in demo-seed route relies on.

A note on status and activation

The product status (draft or active) is set only at create time and defaults to active. The merchant catalog PATCH route deliberately does not change status, so there is no self-serve draft-to-active transition yet. The honest options are:

  • Import directly as active once your source rows are validated (below).
  • Import as draft to stage a catalog that never serves publicly, and coordinate going live through an operator.

Steps

Shape your source rows

Reduce your export to the fields the API accepts. A product needs slug and name; each variant needs a sku and a non-negative integer price_cents.

type ImportRow = {
  slug: string;            // unique per tenant — the idempotency key
  name: string;
  description?: string;
  status?: "draft" | "active"; // default active
  variants: { sku: string; price_cents: number; label?: string }[];
};

Loop over create, skipping what already exists

Run this server-side with an fdk_ key. Each product is created, then its variants; a slug_exists refusal is treated as “already imported” so the whole script is safe to re-run after a partial failure.

const API = "https://api.ruo.pro";
const KEY = process.env.FLIGHTDECK_FDK_KEY!; // manage:own_catalog
 
async function call(method: string, path: string, body: unknown) {
  const res = await fetch(`${API}${path}`, {
    method,
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  const json = await res.json();
  return { status: res.status, json };
}
 
for (const row of rows) {
  const created = await call("POST", "/v1/merchant/catalog/products", {
    slug: row.slug,
    name: row.name,
    description: row.description ?? null,
    status: row.status ?? "active",
  });
 
  let productId: string;
  if (created.status === 201) {
    productId = created.json.product.id;
  } else if (created.json.error?.code === "slug_exists") {
    // Already imported on a previous run — look it up and continue.
    const found = await call("GET", "/v1/merchant/catalog/products", {});
    productId = found.json.products.find((p: any) => p.slug === row.slug).id;
  } else {
    throw new Error(`create failed for ${row.slug}: ${created.json.error?.message}`);
  }
 
  for (const v of row.variants) {
    const vres = await call("POST", `/v1/merchant/catalog/products/${productId}/variants`, {
      sku: v.sku,
      price_cents: v.price_cents,
      label: v.label ?? null,
      currency: "usd",
    });
    // A duplicate SKU is likewise refused, not duplicated — safe to re-run.
    if (vres.status !== 201 && vres.json.error?.code !== "sku_exists") {
      throw new Error(`variant ${v.sku} failed: ${vres.json.error?.message}`);
    }
  }
}

Every failure is the nested envelope { "error": { "code", "message" } }, and the loop stops on any code it does not recognize as an idempotent replay — a partial import never reports success.

Attach images

Product imagery is a separate content-addressed upload. For each product that has an image, follow the presign -> PUT -> finalize -> attach flow, then set the returned asset_ref on the product with PUT /v1/merchant/catalog/products/:id/image. See Add an image to a product.

Verify the import

Read the catalog back through the merchant surface and reconcile counts against your source before flipping the store live.

curl https://api.ruo.pro/v1/merchant/catalog/products \
  -H "Authorization: Bearer fdk_your_key_here"

Try it small first

For a quick, disposable dataset, the POST /v1/merchant/catalog/demo-seed route stocks a draft store with clearly-labeled sample products in one call. It is idempotent (existing sample-* slugs are skipped) and refuses on live stores — a good way to exercise the shape of an import before wiring your own.