Connect your stack
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 global signals. Each is normalised to 0–1 within your catalogue, then multiplied by its weight.
| Signal | Default weight | What it measures |
|---|---|---|
bestseller | 1.0 | Quantity sold across paid and fulfilled orders in the bestseller window (365 days), plus quantity on accepted and paid quotes. |
popular | 1.0 | view_product events in the popular window (30 days). |
carted | 1.0 | add_to_cart events in the popular window. |
newness | 0.5 | Exponential decay against the product's created_at, halving every 30 days. |
featured | 2.0 | The manual per-product flag. Heaviest by default because it exists to be decisive. |
price_target | 0.0 off | How close the product's average active-variant price sits to a target you set. A bell curve, not a cutoff. |
on_sale | 0.0 off | Whether base_compare_price sits above the effective price. |
And three personal signals, computed per customer:
| Signal | Default weight | What it measures |
|---|---|---|
view | 1.0 | This person's view_product events on this product. |
cart | 2.0 | Their add_to_cart events. Intent counts double. |
quote | 3.0 | Appearances on their prior quotes. The strongest personal signal there is. |
How they combine
order = (1 − strength) × Σ(global weights × global signals, minus featured and on_sale)
+ strength × Σ(personal weights × personal signals)
+ featured_weight × is_featured
+ on_sale_weight × is_on_sale
personalisation_strength is a 0–1 dial, default 0.5.
Featured and on-sale sit outside the blend deliberately: they are
merchandising decisions, and a flagged product should be lifted even for a fully
personalised visitor.
Tuning
The sliders live on Settings → Personalisation. The API takes a partial patch and deep-merges it, so you can move one weight without restating the rest:
curl -X PATCH https://app.stitchwork.io/api/v1/settings/ranking \
-H "Content-Type: application/json" -b cookies.txt \
-d '{
"global": {"bestseller": 1.6, "newness": 0.2, "on_sale": 0.8},
"personalisation_strength": 0.7,
"category_balance": 0.4,
"target_avg_price": 1800,
"price_target_tolerance": 500,
"windows": {"bestseller_days": 540}
}'
The full config, with defaults:
{
"global": {
"bestseller": 1.0, "popular": 1.0, "carted": 1.0,
"newness": 0.5, "featured": 2.0,
"price_target": 0.0, "on_sale": 0.0
},
"personal": { "view": 1.0, "cart": 2.0, "quote": 3.0 },
"personalisation_strength": 0.5,
"category_balance": 0.0,
"target_avg_price": 0.0,
"price_target_tolerance": 0.0,
"windows": {
"bestseller_days": 365,
"popular_days": 30,
"newness_half_life_days": 30
}
}
Values are validated on the way in: weights are clamped to be non-negative, the two 0–1 dials are clamped to that range, and window days are at least 1. Unknown keys are dropped.
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.
Price target
A commercial lever for average transaction value. Set target_avg_price to
the number you want to sell towards and turn up the price_target weight.
Products are scored by a bell curve on their distance from the target, so a £1,900 item
with a £1,800 target scores near 1 and a £200 item scores near 0.
price_target_tolerance is the width of that curve. Left at 0 it defaults to
40% of the target, which is usually sensible. The signal contributes nothing while
target_avg_price is 0, regardless of its weight.
Recomputing
The four heavy global signals — bestseller, popular, carted, newness — are materialised, not computed on every read. Featured, on-sale and price-target are computed live, so toggling those is instant.
curl -X POST https://app.stitchwork.io/api/v1/rankings/recompute -b cookies.txt
# {"data":{"product_count":412,"computed_at":"2026-04-02T09:14:22+00:00"}}
Recompute after:
- Importing products or orders — an order import triggers this for you automatically.
- Changing a window (
bestseller_days,popular_days,newness_half_life_days). - Any bulk change to
created_at.
You do not need to recompute after moving a weight, featuring a product, or setting a compare price — those apply on the next read.
Nothing recomputes on a timer. Events and orders keep accumulating, but their effect on the four materialised 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:
- 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.
- The top 50 products with every signal's contribution broken out, so you can see why something ranks where it does.
- Unmatched-SKU order lines — sales whose SKU doesn't link to any variant in your catalogue, and which therefore contribute nothing.
curl "https://app.stitchwork.io/api/v1/rankings/diagnostics?limit=50" -b cookies.txt
{"version":"v1","data":{
"inputs": {
"bestseller_orders_qty": 8412,
"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,
"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": { … },
"category_balance": 40,
"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_linesmeans 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.
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>
| Attribute | Default | Notes |
|---|---|---|
key | — | Required. Your pk_live_…. |
limit | 6 | Maximum 24. |
category | — | Restrict to one category. |
heading | "Recommended for you" | Pass empty to hide the heading entirely. |
on-empty | show | Set to hide to remove the whole component when there is nothing to recommend. |
api-base | the hosted API | Point at a self-hosted instance. |
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:
| Parameter | Matches | Use when |
|---|---|---|
ids | Stitchwork product ids | You stored our id on your product. |
external_ids | products.external_id | Your grid renders by Shopify / Lightspeed / your own id. Up to 200. |
skus | Product or variant SKUs | Your grid renders by SKU. Up to 200. |
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:
| Case | Cache-Control |
|---|---|
| Anonymous — no token, or a token that resolves to nothing | public, max-age=300, s-maxage=300 |
| Personalised — the token resolved to a visitor or customer | private, 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.
The staff endpoint
Used by the quote builder and the customer detail page:
curl "https://app.stitchwork.io/api/v1/recommendations?customer_id=14&limit=12&exclude_quote_id=7" \
-b cookies.txt
| Parameter | Effect |
|---|---|
customer_id | Personalises to that customer — and restricts the set to products they have engaged with. |
category | Restrict to one category. |
ids | Restrict to specific Stitchwork product ids. |
exclude_quote_id | Leave out products already on the draft being built. Without this, lines come straight back as recommendations. |
limit | Default 20. |
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,
"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.
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.
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:
- Products are
draft. Onlyactiveproducts rank. Checkdiagnostics.draft_products. - The customer has no linked visitor.
linked_visitors: 0— nothing has been stitched. - Events carry no SKU.
view_eventsis climbing butviews_on_active_productsis 0. - No recompute since import.
inputs.computed_atis older than your last catalogue change. - The category filter matches nothing. Category is exact-match, case-sensitive.
Longer form in Troubleshooting.