Stitchwork Docs

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:

TypeUse whenVariants
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_view is 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, not example.com/guitars/concerto) because it is compared against the URL the browser reports. The scheme and host are stored lowercased and any #fragment is 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 category filter 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_price sits 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.

ColumnAliasesNotes
product_skusku, parent_skuRequired. Matched against product SKUs first, then variant SKUs.
product_namename, title1–200 characters.
product_typetypesimple / 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.
categorycategoriesMagento 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_pricecompare_price, compare_at_price, rrp, was_price, list_price, msrpThe "was" price. Feeds the on-sale signal.
currency—3-letter ISO code.
status—draft / active / discontinued.
web_urlurl, product_url, url_keyThe product page on your site.
created_atcreated, created_date, date_createdThe source system's creation date. Read day-first for ambiguous formats.
variant_skuchild_skuMakes the row a variant of product_sku.
variant_pricepricePer-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_sourcesource, platformWhich platform the ids below came from, e.g. shopify. Required before either is used for anything.
external_idproduct_external_id, shopify_product_id, platform_product_idThat platform's own product id.
variant_external_idshopify_variant_id, platform_variant_id, child_external_idThat platform's own variant id.

What the importer guarantees

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 no configurable_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 a web_url that 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_key is an alias for web_url but 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 to web_url if you want the links.

Managing products over the API

Delete is discontinue

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.

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