Products & catalogue
Your catalogue is the spine of everything personalised. Get SKUs, categories and product URLs right and the rest of Stitchwork works without further help.
Product shapes
Pick the type that matches how the thing is sold:
| Type | Use when | Variants |
|---|---|---|
simple |
One thing, one price. A notebook, a candle, a single guitar. | One, created automatically, inheriting the parent SKU, price, currency and status. |
configurable |
The customer chooses: size, colour, wood, finish. | Generated from the option axes you declare. |
variant |
Your export lists every variant as its own top-level row with no parent. | Managed by hand or by the importer. |
Status is draft, active or discontinued. Only
active products are ranked or recommended — the single most common cause
of an empty recommendations list is a catalogue that imported as drafts.
Fields that pull their weight
- sku
- The join key for everything: tracked events, imported order lines, external quote lines, category re-ranking. Up to 64 characters of letters, digits, dot, hyphen, underscore or slash. Unique per workspace when set.
- web_url
-
The product's page on your site. It is how a bare
page_viewis matched to a product, it is what product QR codes point at, and it is the link on every recommendation card. Without it you can still rank — you just have to send SKUs on every intent event, you cannot make a product QR, and recommendation cards render without a link rather than guessing at one.
Set it on the CSV, through the API, or on the product page under Settings. It must be a full address including the protocol (https://example.com/guitars/concerto, notexample.com/guitars/concerto) because it is compared against the URL the browser reports. The scheme and host are stored lowercased and any#fragmentis dropped; the path and query are kept exactly as given. Clearing the field on the product page removes it — a blank CSV cell does not, because on import blank always means "not supplied". - category
-
Free text, one per product. Drives the
categoryfilter on recommendations, the category-interest endpoint, and category balancing. Worth populating even if it is coarse. - base_price / base_compare_price
-
The price and the "was" price. When
base_compare_pricesits above the effective price, the product counts as on sale and the on_sale ranking signal can boost it. - created_at
- Settable on create and update. The newness ranking signal decays against it, so importing the real creation date from your source system means a five-year-old product doesn't rank as brand new the day you migrate.
- external_source / external_id
-
Your system's identifier for this product. Store it and you can ask for
recommendations by your own ids (
?external_ids=) instead of ours. - tags, attributes
- A string array and an arbitrary JSON blob. Carried through, not interpreted.
Options and variants
A configurable product declares its axes as options, then you generate the combinations from the product's Options tab. The generator reports how many variants it created and how many it discontinued.
Option names must be 1–40 lowercase letters, digits or underscores
(display_name is the pretty version shown to staff). Re-running the
generator after changing an option is safe: new combinations are created, and
combinations that no longer exist are marked discontinued rather than
deleted — a variant that has appeared on a quote or an order must not vanish.
Editing a variant
Prices and statuses are per variant. Set them individually rather than through the
parent — the parent's base_price is a starting point for new combinations,
not the price anything actually sells at.
Importing a catalogue
Products → Import. Owner only. Up to 20 MB and 50,000 rows per file; the import is allowed to run for up to ten minutes, because being cut off mid-file is the worst possible outcome.
Columns
Headers are matched case-insensitively and aliases are accepted, so most Shopify and
Magento exports work unmodified. Only product_sku is required.
| Column | Aliases | Notes |
|---|---|---|
product_sku | sku, parent_sku | Required. Matched against product SKUs first, then variant SKUs. |
product_name | name, title | 1–200 characters. |
product_type | type | simple / variant / configurable. virtual, bundle, downloadable, grouped, service and physical are coerced to simple. Blank becomes configurable if the row declares options, else simple. |
description | — | Free text. |
brand | — | Free text. |
category | categories | Magento paths are simplified: Default/Beds/All Beds → Beds, and a comma-separated list takes the first path. |
tags | — | Comma-separated. |
base_price | — | Number. |
base_compare_price | compare_price, compare_at_price, rrp, was_price, list_price, msrp | The "was" price. Feeds the on-sale signal. |
currency | — | 3-letter ISO code. |
status | — | draft / active / discontinued. |
web_url | url, product_url, url_key | The product page on your site. |
created_at | created, created_date, date_created | The source system's creation date. Read day-first for ambiguous formats. |
variant_sku | child_sku | Makes the row a variant of product_sku. |
variant_price | price | Per-variant price. |
variant_name, variant_status | — | Per-variant label and status. |
option:<name> | — | Repeatable. option:wood declares the wood axis and gives this row's value. |
configurable_variations | — | Magento's inline child list: sku=A,size=L,color=Red|sku=B,size=M,color=Blue. Child rows named here are absorbed as variants rather than imported as standalone products. |
external_source | source, platform | Which platform the ids below came from, e.g. shopify. Required before either is used for anything. |
external_id | product_external_id, shopify_product_id, platform_product_id | That platform's own product id. |
variant_external_id | shopify_variant_id, platform_variant_id, child_external_id | That platform's own variant id. |
What the importer guarantees
-
Idempotent on SKU. Re-running the same file updates rather than
duplicates. Products match on
products.skuthenproduct_variants.sku; variants match on(organization_id, sku), falling back to the option-value combination. - An update is a merge, not a replace. Only columns the CSV actually supplies are written. A three-column price refresh will not blank your descriptions or knock every product back to draft — nor the external ids, which survive any file that does not mention them.
-
A row that names no variant updates only the product. Without a
variant_skuand without anyoption:columns, a row has not said which variant it means, so on a product that already has variants it changes the product's own fields and leaves them alone. A simple product's single variant is still updated by such a row — that is the ordinary way to reprice one. -
External ids are stored, never matched on. SKU alone decides whether a
row creates or updates. Adding
external_idto a file you already import cannot repartition an existing catalogue; it fills a column in. See Shopify for what they are for. - Blank cells mean "leave alone". To clear a field, edit the product rather than importing an empty cell.
-
Rows fail individually. One bad row does not abort the file; it lands
in
errorswith its line number.
Reading the summary
{"version":"v1","data":{
"rows_total": 1204,
"rows_ok": 1198,
"rows_failed": 6,
"products_created": 310,
"products_updated": 88,
"variants_created": 806,
"variants_updated": 92,
"parents_grouped": 310,
"variants_grouped": 806,
"type_remapped": {"bundle": 4},
"variants_unmatched": [ … ],
"child_conflicts": [ … ],
"skus_sanitised": 2,
"errors": [{"line": 47, "message": "product_sku is required."}],
"warnings": [ … ]
}}
- variants_unmatched
- Rows that named a child SKU whose parent could not be resolved. Usually a typo in
product_sku, or a child listed before its parent in a file with noconfigurable_variations. - child_conflicts
- A child SKU claimed by two different parents. The first parent wins; fix the export.
- type_remapped
- Counts per source type that was coerced onto our enum — useful for spotting that half your catalogue arrived as
bundle. - skus_sanitised
- SKUs that contained characters we don't allow and were cleaned. Check them; a sanitised SKU no longer matches your source system.
- warnings
-
Things we could not use on a row that imported anyway — an unreadable
created_at, or aweb_urlthat isn't a full web address. The rest of the row lands and whatever was already stored for that one field is kept, so a warning is worth reading but never costs you the row. Magento exports are the common case:url_keyis an alias forweb_urlbut holds a slug rather than an address, so it misses on every row — the first ten name their line and the remainder collapse into a single total, rather than repeating the same sentence fifty thousand times. Map a real URL column toweb_urlif you want the links.
Managing products over the API
DELETE /products/{id} sets the product's status to
discontinued. It stops being ranked and recommended, but every quote,
order and event that references it keeps working. This is what you want for a
product you have stopped selling.
Removing a product outright
Discontinue is the wrong tool for a product that should never have existed — a typo,
a duplicate, a bad import run. Those want removing, not archiving. Add
?purge=1 and the product, its options and its variants are deleted for
good, along with its rankings and per-customer affinity.
DELETE /api/v1/products/42?purge=1
A purge is refused with 409 when an order line or a quote line names one
of the product's variants, and the response body says what is attached:
{
"error": "This product has been sold or quoted — it appears on 3 order lines...",
"data": {
"dependents": { "order_items": 3, "quote_line_items": 1, "events": 87, "qr_codes": 0 },
"discontinue_instead": true
}
}
This refusal is ours, not the database's. Every foreign key pointing at a product is
ON DELETE SET NULL, so Postgres would accept the delete and quietly blank
product_variant_id on the order line. The money would survive —
order_items keeps its own description, SKU and totals — but the link every
roll-up joins on would be gone, so the sale would disappear from your bestsellers, from
the customer's timeline, from frequently-bought-together and from that product's own
sales figures, with nothing to say why. Discontinue those instead.
Events and QR codes do not block a purge. They are reported in
dependents so you can see what you are giving up: the events survive as
history with their product link cleared, and a QR code that pointed at the product
stops resolving to one. Rankings and affinity are derived and are simply rebuilt.
Featuring a product
A manual thumb on the scale. is_featured contributes the
featured signal, which is weighted at 2.0 by default — the heaviest of the
global weights.
Owner or admin. See ranking signals.
Who to tell
A new arrival, something back in stock, the last one on the floor: the Who to tell tab on a product lists the clients it is worth a call to. First those who engaged with this product — a quote outranks a basket, which outranks a look — then those who keep browsing its category. Never anyone who has already bought it. Each row says why in words ("Put it in a basket twice; looked at it 3 times", "Keeps looking at Sideboards (6 views)") and who looks after the client, with a button that puts them on that person's follow-up list.
It is read from the same per-customer interest the recommendations use, so it only knows
people whose browsing is linked to their record — see
identity stitching. AI connections ask the same
question with find_customers_for_product.
Catalogue health checklist
- Products are
active, notdraft. - Every product has a
category. - Every product has a
web_urlthat matches the real page, including protocol. - Variant SKUs match exactly what your order export and your tag send — same case, same separators.
- The unmatched-SKU list on Rankings is short. Anything on it is a sale that isn't feeding the bestseller signal.
created_atreflects the real launch date, not the import date.