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):
| Method | Path | Purpose |
|---|---|---|
POST | /v1/merchant/catalog/products | Create one product. |
POST | /v1/merchant/catalog/products/:productId/variants | Add a variant (SKU + price). |
PUT | /v1/merchant/catalog/products/:id/image | Attach 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
activeonce your source rows are validated (below). - Import as
draftto 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.
Related
- Add products to a catalog — the single-product create flow in detail.
- Add an image to a product — the imagery step referenced above.
- Add search and faceted browse — make the imported catalog discoverable.