Stitchwork Docs

Recommendations & ranking

One score per product, built from signals you weight yourself, with a per-customer overlay on top. It drives the widget on your site, the order of your category pages, and what the agent sees in the quote builder.

The signals

Seven signals about your catalogue, each one a lever you set the strength of. None of them is a black box: Rankings in the app shows what every signal contributed to every product.

SignalWhat it measures
bestseller What is actually selling, over a window you set. Counts revenue-counting orders and accepted quotes — so a showroom sale through your team counts the same as one through the website. Rank on units sold or on money taken; see Units or revenue.
popular What people are looking at, over a recent window you set.
carted What people are putting in baskets — interest that went one step further than a look.
newness How recently the product arrived, fading smoothly rather than dropping off a cliff.
featured Your own hand on the scale. Weighted heavily out of the box, because when you flag something you mean it.
price_target How close a product sits to the average order value you are selling towards. Off until you turn it on — see Price target.
on_sale Whether the product is currently reduced. Off until you turn it on.

And three personal signals, so the same catalogue orders differently per person:

SignalWhat it measures
viewWhat this person has looked at.
cartWhat they put in a basket. Intent counts for more than a look.
quoteWhat has been on their prior quotes — the strongest personal signal there is, because somebody in your business already put it in front of them.

What they just bought stops following them around

The browsing that leads up to a sale is exactly the browsing that scores highest — which naively means the thing a customer has just bought sits at the top of their own recommendations, because they bought it. Every personalisation engine has this problem. Stitchwork handles it: a purchase settles the interest that led to it, and recommendations move on to what they might buy next.

It is not a blocklist, so this stays safe for consumables and anything else people re-buy — the product keeps its place on merit, it just stops being pinned to one person's list by browsing they did before they owned one. Renewed interest counts from scratch, and a refunded or cancelled order puts the history back.

How they combine

One dial, personalisation strength, sets how far the personal signals pull against the global ones — from "the same order for everybody" to "almost entirely about this person". Featured and on-sale sit outside that blend deliberately: they are merchandising decisions, and something you have flagged should be lifted even for a fully personalised visitor.

Tuning

The sliders live on Settings → Personalisation, and that is where most people stay — you move a weight, and Rankings shows you the new order against your real catalogue before you commit to it.

The sliders live on Settings → Personalisation. Moving one weight leaves the rest where they were.

Weights are non-negative, the dials run 0–1, and anything unrecognised is ignored rather than allowed to break your ranking. GET the same path to read back what is currently set.

Units or revenue

bestseller_metric decides what the bestseller signal counts. units (the default) counts how many sold; revenue counts how much they took. It changes which products win, not how strongly the signal applies, so you can switch between them without re-tuning anything.

Units is the right default for most catalogues. Reach for revenue when a cheap, high-turnover line is burying stock you actually want to sell: a £15 cushion selling 500 units outranks a £4,000 sofa selling 20 on units, and loses to it decisively on revenue.

Two things to know before you switch. It only takes effect on the next recompute — saving the setting alone changes nothing. And if you sell in more than one currency, line totals are added up without conversion: Stitchwork holds no exchange rates. That produces a defensible ordering rather than a meaningful total, which is why the figure is never shown to your customers and appears on the diagnostics page labelled as what it is. Single-currency workspaces are unaffected.

Category balance

A 0–1 dial that interleaves the output across categories. At 0 the order is pure relevance and one category can dominate the whole panel; at 1 it is strict round-robin — bed, mattress, bed, mattress. Useful when your top sellers all come from one shelf.

By default every category takes part. category_balance_include narrows it to a named set — "show beds and mattresses evenly, and I'm not fussed where the pillows land". Naming a category that nothing in your catalogue carries is harmless: the list comes back untouched rather than empty.

In the app the set is a row of tick-boxes under the balance slider on Settings → Personalisation, listing the categories your catalogue actually carries. Rankings (the diagnostics page) has the same tick-boxes wired to a live preview, so you can see the re-ordered list against your real products before saving anything.

Price target

A commercial lever, not a widget. Set target_avg_price to the number you want your average order to reach and turn up the price_target weight — and the catalogue leans towards it on every surface at once: the widget on your site, your category pages, and what your associate is shown in the quote builder. For a business whose economics are the average order, that is a dial worth having.

The price target is one of the optimisation tools — part of Collection, and an add-on to Independent. On a plan without them the catalogue still ranks by every other weight you set; the target is kept, and leans again the day the tools are switched on.

It leans, it doesn't censor. Products either side of the target still surface on their own merit, so you don't lose the entry-level range that starts relationships or the pieces at the top that anchor them.

price_target_tolerance controls how tightly it leans — leave it alone and Stitchwork picks a sensible spread around your target. The signal does nothing at all until you set target_avg_price, whatever its weight, so it can't surprise you.

Recomputing

The four heavy global signals — bestseller, popular, carted, newness — are stored, not computed on every read. Featured, on-sale and price-target are computed live, so toggling those is instant.

Recompute after:

You do not need to recompute after moving a weight, featuring a product, or setting a compare price — those apply on the next read.

There is no scheduler

Nothing recomputes on a timer. Events and orders keep accumulating, but their effect on the four stored signals only lands at the next recompute. A weekly cron hitting the endpoint is a reasonable production setup; for a trial, the automatic recompute after order import is usually enough.

Diagnostics

The Rankings page exists so the engine is never a black box. It shows three things:

  1. Input volume per signal, scoped to your configured windows — how many order lines, quote lines, product views and cart adds are actually feeding the score.
  2. The top 50 products with every signal's contribution broken out, so you can see why something ranks where it does.
  3. Unmatched-SKU order lines — sales whose SKU doesn't link to any variant in your catalogue, and which therefore contribute nothing.
{"version":"v1","data":{
  "inputs": {
    "bestseller_orders_qty": 8412,
    "bestseller_orders_revenue": 421870.00,
    "bestseller_order_lines": 6180,
    "bestseller_order_lines_unlinked": 214,
    "bestseller_order_lines_all_time": 9903,
    "oldest_order_days_ago": 703.4,
    "bestseller_quotes_qty": 87,
    "bestseller_quotes_revenue": 9240.00,
    "popular_events": 14203,
    "carted_events": 1980,
    "featured_products": 6,
    "on_sale_products": 41,
    "active_products": 412,
    "computed_at": "2026-04-02T09:14:22+00:00"
  },
  "weights": { … },
  "windows": { … },
  "bestseller_metric": "units",
  "category_balance": 40,
  "category_balance_include": ["Beds", "Mattresses"],
  "categories": ["Beds", "Mattresses", "Pillows"],
  "top_products": [ … ],
  "unmatched_skus": [{"sku":"GTR-OLD-01","line_count":22,"qty_total":31}]
}}

Two numbers worth watching:

bestseller_order_lines_unlinked
Order lines with no variant link. Large relative to bestseller_order_lines means your SKUs don't match — fix the catalogue, not the weights.
oldest_order_days_ago vs bestseller_days
If your oldest order is 700 days old and your window is 365, half your history is invisible to the ranker. Widen the window or accept it deliberately.

Pass ?category_balance=0..100 to preview a balance setting without saving it — that is what the slider on the page does. ?category_balance_include=Beds,Mattresses previews the named set alongside it; pass it empty to preview balancing across every category. Both fall back to the saved setting when absent, so the table matches what customers see. categories lists the catalogue's own category names, which is what the page draws its tick-boxes from.

Putting it on your site

Three surfaces, one engine.

1. The drop-in widget

<script src="https://app.stitchwork.io/assets/embed/sw-recs.js" defer></script>

<sw-recs key="pk_live_xxxxxxxxxxxx" limit="6"></sw-recs>
AttributeDefaultNotes
key—Required. Your pk_live_….
limit6Maximum 24.
category—Restrict to one category.
heading"Recommended for you"Pass empty to hide the heading entirely.
on-emptyshowSet to hide to remove the whole component when there is nothing to recommend.
api-basethe Stitchwork APIOverride the origin the widget calls. Only needed if you proxy Stitchwork through your own domain.

It reads the __sw_v cookie the tag already set and personalises off it, or falls back to global top-rank for a first-time visitor. It renders in shadow DOM so it cannot collide with your CSS; theme it with custom properties on the element:

sw-recs {
  --sw-recs-gap: 20px;
  --sw-recs-grid-cols: 4;
  --sw-recs-card-bg: #fffdf9;
  --sw-recs-card-radius: 12px;
  --sw-recs-name-size: 16px;
  --sw-recs-price-color: #7a4a2b;
}

2. Re-ranking your own category pages

Use Stitchwork to reorder your grid, never to replace it. Your list stays the source of truth for stock, facets and merchandising rules; we contribute ranking signal, personalised when the visitor has consented.

// after your category page has rendered its default order
(async () => {
  const tiles = document.querySelectorAll('[data-product-id]');
  const ids   = Array.from(tiles).map(el => el.dataset.productId);
  const tok   = (document.cookie.match(/(?:^|; )__sw_v=([^;]+)/) || [])[1] || '';

  const res = await fetch(
    'https://app.stitchwork.io/api/v1/public/recommendations'
    + '?key=pk_live_xxxxxxxxxxxx'
    + '&visitor_token=' + encodeURIComponent(tok)
    + '&external_ids=' + encodeURIComponent(ids.join(','))
  );
  const { data } = await res.json();

  const rank = new Map(data.products.map((p, i) => [String(p.external_id), i]));
  const grid = document.querySelector('[data-product-grid]');
  Array.from(grid.children)
    .sort((a, b) => (rank.get(a.dataset.productId) ?? 1e9)
                  - (rank.get(b.dataset.productId) ?? 1e9))
    .forEach(el => grid.appendChild(el));
})();

Three ways to name the products you want ranked — pick whichever matches what your templates already have:

ParameterMatchesUse when
idsStitchwork product idsYou stored our id on your product.
external_idsproducts.external_idYour grid renders by Shopify / Lightspeed / your own id. Up to 200.
skusProduct or variant SKUsYour grid renders by SKU. Up to 200.
SKUs choose the grain

Pass parent SKUs and variant activity rolls up — you get products back. Pass variant (child) SKUs — for grids that list each variant as its own tile — and those variants are ranked directly. Every returned row carries its sku so you can re-order by SKU either way.

Caching

The response tells your CDN what it may do:

CaseCache-Control
Anonymous — no token, or a token that resolves to nothingpublic, max-age=300, s-maxage=300
Personalised — the token resolved to a visitor or customerprivate, max-age=60

The payload also carries personalised: true|false so you can tell which you got.

3. Category interest

A read-only answer to "what is this visitor into right now?" — useful for choosing which hero banner to render, which email lane to put someone in, or which CTA to lead with. It does not affect product ranking; it is an orthogonal signal.

const tok = (document.cookie.match(/(?:^|; )__sw_v=([^;]+)/) || [])[1] || '';
const res = await fetch(
  'https://app.stitchwork.io/api/v1/public/categories/interest'
  + '?key=pk_live_xxxxxxxxxxxx'
  + '&visitor_token=' + encodeURIComponent(tok)
  + '&limit=3'
);
const { data } = await res.json();
// data.categories: [{ category, score, views, carts, order_qty, quote_qty }, …]

if (data.categories[0]?.category === 'Acoustic guitars' && data.categories[0].score > 0.5) {
  document.querySelector('[data-banner="guitars"]').hidden = false;
}

Same caching rules as recommendations.

4. The visitor profile

The two above already make a creative decision for you: one picks products, the other picks a collection. The profile is the layer underneath — a description of the person with nothing chosen. Read it when you want to swap a hero image, change a headline, or decide between a finance message and a free-delivery one, and keep that decision in your own code.

It works on a browser that has never identified itself. The __sw_v cookie is all it needs.

If what you want from the profile is "show this visitor a message", you can have that without writing any of it: a nudge targets the same facts, renders through the tag you already have installed, and measures itself against a control group. Read the profile when the decision belongs in your own code — a hero image, a headline, a different template.

const tok = (document.cookie.match(/(?:^|; )__sw_v=([^;]+)/) || [])[1] || '';
const res = await fetch(
  'https://app.stitchwork.io/api/v1/public/profile'
  + '?key=pk_live_xxxxxxxxxxxx'
  + '&visitor_token=' + encodeURIComponent(tok)
  + '&window_days=90'
);
const { data } = await res.json();

// Where are they in the journey?
if (data.intent.stage === 'high_intent') showUrgencyBanner();

// What price do they shop at — relative to YOUR catalogue, not to money?
if (data.price_affinity?.band === 'premium') swapHeroToPremium();

// Did we pay to bring them back? Don't spend a discount on someone who typed the URL.
if (data.acquisition?.last_touch.paid) suppressDiscountCode();

// What are they actually looking at?
document.body.dataset.swCategory = data.categories[0]?.category ?? '';
FieldWhat it carries
intentstage is one of unknown, browsing, considering, high_intent, purchased. score is the continuous 0–1 version underneath it, and signals the counts both came from.
recencyFirst and last seen, days since each, session count, returning, total events.
categories brands productsWhat they engage with, scored 0–1 against their own strongest signal. Three views of one number, so they never disagree.
price_affinityThe price band they browse. Available from a first session. null if they have looked at nothing priced.
transaction_valueWhat they have actually paid, per currency. null until they buy.
acquisitionFirst and last campaign touch — source, medium, campaign, term, content, click kind, and paid on each.
searchesRecent terms with counts — the most literal statement of what someone wants.

Every section is always present. Empty is [] or null, never absent, so data.intent.stage is safe to read without guarding it. This runs inside your checkout; a missing key there is a TypeError in front of a shopper.

There are two average transaction values because they are two questions. price_affinity is what they browse and transaction_value is what they paid. A browsed price is what someone was shown and a paid price is what they were charged — averaging them together gives a figure that is neither, so the two never touch.

Bands are relative to your catalogue, not to money. "Premium is over £1,000" means nothing across a £40 accessories range and a £6,000 mattress range, and nothing again the moment the currency changes. We place the visitor's average browsed price as a percentile inside your own active catalogue, in that currency, and label the quartile. The raw percentile and your catalogue's own min/p25/median/p75/max ship alongside it, so if you want a three-way or five-way split you can ignore our labels and never ask us again.

"price_affinity": {
  "currency": "GBP",
  "avg": 2314.00, "median": 2400.00, "min": 1699.00, "max": 3200.00,
  "sample": 7,
  "percentile": 0.6667,
  "band": "premium",
  "catalogue": { "min": 49.00, "p25": 286.50, "median": 1199.00,
                 "p75": 2224.75, "max": 3200.00, "products": 214 },
  "by_currency": [ … ]
}

window_days (1–365, default 90) bounds the behavioural half only — categories, brands, products, price_affinity, searches and intent. A two-year sliding cookie collects browsing that stopped being true a long time ago. recency, transaction_value and acquisition are lifetime facts and ignore it: a purchase four months ago still says what someone converts on.

Money is per currency, as everywhere else in Stitchwork — there are no exchange rates and no base currency to convert into. Both money sections carry by_currency, and the scalars beside them describe the dominant currency rather than a total spanning several.

An unknown or expired visitor_token returns the anonymous payload rather than a 404 — otherwise the endpoint would confirm whether a token is live. And identified is a boolean: no name, no email, no customer id ever comes back through a pk_live key, because that key is in your page source. Same caching rules as recommendations, and the same fail-closed contract.

The staff endpoint

Used by the quote builder and the customer detail page:

ParameterEffect
customer_idPersonalises to that customer — and restricts the set to products they have engaged with.
categoryRestrict to one category.
idsRestrict to specific Stitchwork product ids.
exclude_quote_idLeave out products already on the draft being built. Without this, lines come straight back as recommendations.
limitDefault 20.
Naming a customer means "their list", not "the catalogue, tilted"

A named customer restricts results to products they have actually engaged with, and an empty personal set returns nothing rather than the global bestsellers. An agent reading a list aloud under a "recommended for them" heading has no way to tell the two apart, so the engine refuses to blur them. The public storefront endpoint is deliberately different — a widget on a category page must still render for a first-time visitor.

Every staff call returns a diagnostics block, so an empty list is never silent:

{"version":"v1","data":{
  "products": [],
  "diagnostics": {
    "active_products": 412,
    "draft_products": 0,
    "customer_id": 14,
    "linked_visitors": 0,
    "view_events": 0,
    "cart_events": 0,
    "views_on_active_products": 0,
    "reset_by_purchase": 0,
    "personal_products": 0,
    "quote_lines": 0,
    "quote_lines_spent": 0,
    "personalised_only": true
  }
}}

linked_visitors: 0 there is the whole story: this customer has never been stitched to a browser, so there is no personal history to rank.

The last four describe the purchase boundary. personal_products is the size of the set a staff call ranks over, counted after the reset; reset_by_purchase is how many products dropped out of it because everything the customer did with them predates buying them, and quote_lines_spent is the same figure for their quote lines. A personal_products of 0 next to a non-zero view_events means they engaged and then bought — a different answer to "they never engaged", and the customer panel says so in as many words.

Response shape

Product rows carry:

{
  "id": 12,
  "sku": "CONCERTO",
  "name": "Concerto Acoustic",
  "slug": "concerto-acoustic",
  "description": "Hand-built parlour guitar",
  "brand": "Maison Aurelia",
  "category": "Acoustic guitars",
  "base_price": "2500.00",
  "base_compare_price": "2999.00",
  "currency": "GBP",
  "is_featured": false,
  "created_at": "2024-01-15T00:00:00+00:00",
  "external_source": "shopify",
  "external_id": "gid://shopify/Product/123",
  "web_url": "https://example.com/guitars/concerto"
}

Staff responses additionally include score_global and score_personal. Public responses strip both — your website doesn't need to see how the sausage was made.

The public endpoint never takes your page down

Any failure inside the public recommendations or category-interest endpoints — a missing migration, a malformed SKU set, a database blip — degrades to an empty list, logged server-side, rather than a 500 with an opaque body. Write your integration to handle an empty products array gracefully and it will never break your grid.

When the list is empty

In descending order of likelihood:

  1. Products are draft. Only active products rank. Check diagnostics.draft_products.
  2. The customer has no linked visitor. linked_visitors: 0 — nothing has been stitched.
  3. Events carry no SKU. view_events is climbing but views_on_active_products is 0.
  4. No recompute since import. inputs.computed_at is older than your last catalogue change.
  5. The category filter matches nothing. Category is exact-match, case-sensitive.

Longer form in Troubleshooting.