Subchapter 9.31
references/inline-recipes/setup-online-store.mdMarkdown17 KBView on GitHub
RECIPE: Business Recipe – Initial Setup for a Wix Online Store (Catalog V3)
Standard call shape (every curl below). The
<AUTH>placeholder is shorthand forAuthorization: Bearer <TOKEN>only. Body-bearing requests also needContent-Type: application/json.
A concise checklist for preparing any new Wix site that uses the Online Stores app with Catalog V3. Notice that this recipe is NOT meant for coding purposes and is ONLY meant for initial catalog setup.
This recipe is the how, not the what. What to seed — how many products, which are in/out of stock, which categories — is determined by the request you’re fulfilling. This recipe only specifies the calls and the request format; it does not decide quantities or which items to create.
API surfaces: products use Catalog V3 (). The Categories API is the exception — it lives on a endpoint (). Don’t mix in any pre-V3 product endpoints.
https://www.wixapis.com/stores/v3/...v1https://www.wixapis.com/categories/v1/categoriesYOU MUST complete the following steps in the given order (1-4) and without requiring additional user input. One conditional: the category steps (3-4) run only when the request names categories — if intent.stores.categoriesNamed is empty, create none (skill policy, per SEED.md § “What to seed per capability”) and skip the category steps. Products (steps 1-2) always run; the Attach images step runs last, only when imagery is on.
⚠️ CRITICAL ORDER REQUIREMENT: Do the product operations FIRST (clean + create, Steps 1-2), then categories (Steps 3-4). Categories API might take some time to be fully available after Stores installation, so always finish products before attempting category operations.
A freshly provisioned Wix Stores app comes pre-seeded with demo/sample products. Only remove products that are obviously the install’s own demo/sample data on a fresh install. Do not assume the existing products are samples: the site may already hold the owner’s real catalog (a connect/iterate run, or an owner-populated store). If what’s there isn’t obviously install demo data, or you’re unsure, do not delete it — ask the user first (SEED.md: seeding is additive; deleting real content needs the owner’s approval). When they clearly are the install’s samples, remove them before creating yours so the storefront shows only the intended catalog.
POST https://www.wixapis.com/stores/v3/products/query with body {"query": {"paging": {"limit": 50}}}. Collect every product.id from the response.POST https://www.wixapis.com/stores/v3/bulk/products/delete with body {"productIds": ["<id1>", "<id2>", …]} (the ids from step 1; up to 100 per call). On a fresh install the query returns only the install’s sample products — delete those. If it returns anything that could be the owner’s real catalog, stop and ask first (above).Create the products in a single bulk request to POST https://www.wixapis.com/stores/v3/bulk/products-with-inventory/create. How many products, and which are in or out of stock, are set by the request you’re fulfilling — this step only gives the call and the required format. Each product needs a real web image URL relevant to it, and price via actualPrice (+ optional compareAtPrice).
First decide each product’s type by what the buyer receives: shipped → PHYSICAL (the JSON below); a file the buyer downloads and keeps → DIGITAL (the digital block below — carries a file, no quantity). Access — a membership or an online course/program the buyer enrolls in — is Pricing Plans, not a Stores product.
⚠️ CRITICAL: PRODUCT & VARIANT VISIBILITY — set "visible": true explicitly.
Storefront product queries (searchProducts / queryProducts) return only visible products to site visitors. A product created without "visible": true is created successfully but will NOT appear on the live site — the catalog looks empty. So:
"visible": true on every product (product level)."visible": true on every variant. A variant with inventoryItem.quantity > 0 is buyable; a variant that should be out-of-stock stays "visible": true with "quantity": 0.visible.Request body shape (one representative product shown — repeat product objects inside the products array):
{
"products": [
{
"name": "Pro Basketball Sneaker",
"productType": "PHYSICAL",
"physicalProperties": {},
"visible": true,
"visibleInPos": true,
"description": {
"nodes": [
{
"type": "PARAGRAPH",
"id": "desc1",
"nodes": [{ "type": "TEXT", "textData": { "text": "High-performance basketball sneaker with superior ankle support." } }],
"paragraphData": { "textStyle": { "textAlignment": "AUTO" } }
}
],
"metadata": { "version": 1, "id": "basketball-desc-001" }
},
"options": [
{
"name": "Size",
"optionRenderType": "TEXT_CHOICES",
"choicesSettings": { "choices": [ { "choiceType": "CHOICE_TEXT", "name": "8" }, { "choiceType": "CHOICE_TEXT", "name": "9" } ] }
},
{
"name": "Color",
"optionRenderType": "SWATCH_CHOICES",
"choicesSettings": { "choices": [ { "choiceType": "ONE_COLOR", "name": "Red", "colorCode": "#FF0000" }, { "choiceType": "ONE_COLOR", "name": "Blue", "colorCode": "#0000FF" } ] }
}
],
"variantsInfo": {
"variants": [
{
"choices": [
{ "optionChoiceNames": { "optionName": "Size", "choiceName": "8", "renderType": "TEXT_CHOICES" } },
{ "optionChoiceNames": { "optionName": "Color", "choiceName": "Red", "renderType": "SWATCH_CHOICES" } }
],
"price": { "actualPrice": { "amount": "159.99" }, "compareAtPrice": { "amount": "200.00" } },
"visible": true,
"inventoryItem": { "quantity": 25, "preorderInfo": { "enabled": false } },
"physicalProperties": {}
}
]
}
}
],
"returnEntity": true
}⚠️ CRITICAL FORMAT REQUIREMENTS:
Description MUST be rich-text nodes, not a plain string — a plain string causes an "Expected an object" error. Use the { "nodes": [...], "metadata": {...} } shape shown.
Media — gated by the imagery policy (SEED.md § “Entity images”), no exception for stores. Always create products text-only here — omit media. When imagery is on, the Attach images step below writes generated brand images in a second pass; it does not happen at create time.
Choose the product type from what the buyer receives: a shipped item is PHYSICAL (default); a downloadable file they keep — ebook, PDF, template, preset pack, track, downloadable video — is DIGITAL. Selling access rather than a file — a membership, subscription, or an online course/program the buyer enrolls in — is Pricing Plans, not a Stores product.
Physical products MUST set "productType": "PHYSICAL" and an empty "physicalProperties": {} (on the product and on each variant).
Digital products ("productType": "DIGITAL") drop physicalProperties and are sellable only when each variant carries both a file and stock — miss either and the product is created fine, reads back visible: true, and is rejected at add-to-cart. Upload the file first (POST /site-media/v1/files/generate-upload-url → PUT the bytes → file.id):
{ "productType": "DIGITAL", "visible": true,
"variantsInfo": { "variants": [ {
"price": { "actualPrice": { "amount": "12.00" } }, "visible": true,
"inventoryItem": { "inStock": true },
"digitalProperties": { "digitalFile": { "id": "<file.id>" } }
} ] } }Options: text options use "optionRenderType": "TEXT_CHOICES" + "choiceType": "CHOICE_TEXT"; color options use "optionRenderType": "SWATCH_CHOICES" + "choiceType": "ONE_COLOR" + a colorCode.
Variants = the full Cartesian product of all option choices; each variant references all options via optionChoiceNames, sets price.actualPrice.amount (+ optional compareAtPrice.amount) as strings, and inventoryItem.quantity.
If part of the bulk request fails, retry the failed products once with the exact same format; do not loop.
⚠️ CRITICAL: options/variants are for things the buyer selects and buys — not for attributes you only filter or display by. Make something an option (and thus a variant) only if the buyer picks it to purchase a distinct SKU (Size, Color, Format). An attribute you merely filter, badge, or display by — roast level, material, genre, “new arrival” — is not a variant: encode it in the product name, its category, or description, and never as an option. Modeling a display-only attribute as an option multiplies the variant Cartesian product for nothing (slower seeding, larger payload) and produces a buyer-facing selector that shouldn’t exist. (A single-variant product is fine — give it one variant with no options.) Default cardinality — keep it small: unless the request names specific options, give each product at most one option with ≤3 choices (≤3 variants); many products legitimately have none (a single variant). This is a floor, not a cap — honor a larger/explicit option set when intent names one. The two-option example above shows the multi-option format; it is not the default shape to reach for.
⚠️ Reading the response — created products are under productResults.results[], NOT a top-level results. A successful bulk create returns 200 with this shape:
{ "productResults": { "results": [
{ "itemMetadata": { "id": "<productId>", "originalIndex": 0, "success": true },
"item": { "id": "<productId>", "slug": "<slug>", "name": "…", "visible": true, … } }
] } }Read each product’s id (→ the catalogItemId you’ll assign to categories in STEP 4 and the frontend will use) and slug from productResults.results[].item. There is no top-level results key — reading response.results finds nothing and makes a successful create look like it returned zero products. Check productResults.results first; do not re-create on an empty top-level results.
Create the categories for the store — the categories that fit the user’s request. Which categories (and how many) are set by the request you’re fulfilling; this step only gives the call and format.
Use the Create Category endpoint: POST https://www.wixapis.com/categories/v1/categories. There is no bulk-create endpoint for /categories/v1/ (the events/v1/bulk/categories/create URL is for the Events product, not Stores).
⚠️ CRITICAL: create the categories SEQUENTIALLY — one call at a time, never concurrently. Every category lives in the single shared @wix/stores category tree, which carries one revision. Firing the creates concurrently makes them race on that revision: all but one fail with 409 INVALID_REVISION (“Outdated revision for entity id @wix/stores”), and the failed-then-retried calls can leave duplicate categories behind. So issue each create, wait for its response, then issue the next. (There are only a handful of categories — the serial cost is negligible.)
The request body must include a top-level treeReference field — it must not be nested inside the category object:
{
"category": {
"name": "Drinkware",
"description": "The Drinkware category includes a wide range of containers designed for holding beverages",
"visible": true
},
"treeReference": {
"appNamespace": "@wix/stores",
"treeKey": null
}
}Add each product to the category it belongs to (per the request you’re fulfilling). Acquire the product ids (from STEP 2’s response) first. Use the Bulk Add Items To Category endpoint — products are the items: POST https://www.wixapis.com/categories/v1/bulk/categories/{categoryId}/add-items (one call per categoryId — the path parameter is single).
Issue these add-items calls SEQUENTIALLY too — one call at a time, waiting for each response before the next. They mutate the same shared @wix/stores tree as the creates above, so concurrent calls risk the same 409 INVALID_REVISION race. Serial is the safe, deterministic choice (the category count is small).
The request body is items (each with the product’s catalogItemId and the Stores appId) plus a treeReference at the same level as items (not nested):
{
"items": [
{ "catalogItemId": "<productId>", "appId": "215238eb-22a5-4c36-9e7b-e7c08025e04e" }
],
"treeReference": {
"appNamespace": "@wix/stores",
"treeKey": null
}
}Only when imagery is on (SEED.md § “Entity images”). Products were created text-only above; this pass-2 step writes a generated brand image onto each. Generate + import per references/IMAGE_GENERATION.md, keep each image’s url.
Attach every product in ONE call — POST https://www.wixapis.com/stores/v3/bulk/products/update (up to 100 products). You already hold each product’s id and revision from STEP 2’s bulk-create response (productResults.results[].item), so no per-product GET is needed. Each entry nests under a product wrapper carrying just id, revision, and media — options, variants, and every other field are left untouched (it’s a partial update):
{
"products": [
{ "product": {
"id": "<product id>",
"revision": "<from the create response>",
"media": { "itemsInfo": { "items": [ { "url": "<static.wixstatic.com/… or Wix Media id>", "altText": "…" } ] } }
} }
]
}revision is required on every entry — omitting it fails 400 "revision must not be empty". Use the revision from the create response (or a fresh query); a stale one fails the optimistic-concurrency check.
Everything nests under product — id / revision / media at the entry root fails validation. Send no field mask (the validator runs before masking → 428).
Write the image via media.itemsInfo.items; the server derives media.main from it. One primary image per product (a larger gallery is out of scope for the seed). The image may be referenced by a static.wixstatic.com url or by its Wix Media id (<hash>~mv2.png) — either works.
Success shape — bulkActionMetadata totals the run, and each result carries an itemMetadata:
{
"results": [ { "itemMetadata": { "id": "<product id>", "originalIndex": 0, "success": true } } ],
"bulkActionMetadata": { "totalSuccesses": 1, "totalFailures": 0, "undetailedFailures": 0 }
}Read itemMetadata.success per product (the result carries no product body). Retry only the failed ids once, then move on.
Confirm by requery — POST …/stores/v3/products/query with "fields": ["MEDIA_ITEMS_INFO"]; the attached image is at media.main.image.url. Wix re-hosts the image on attach, so that URL is a new wixstatic.com id — verify it’s a wixstatic URL, never string-equality against the one you sent. The read-back can lag a beat; one empty read right after success isn’t a failure.
Never block on image failure — a product that won’t attach stays text-only.
One-off fix (single product). To (re)attach a single image later, PATCH https://www.wixapis.com/stores/v3/products/{id} with the same { "product": { "id", "revision", "media": {…} } } body — same rules apply (revision required, no field mask, media-only is a partial update that leaves options/variants intact). Read the current revision from GET …/products/{id} → response.product.revision (the GET is not flat); the 200 response carries the image at media.main.url.
Following these steps in order sets up a new V3 Wix Online Store site:
visible: true so they appear on the live site.