Stitchwork Docs
stitchwork.io

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.

SignalDefault weightWhat it measures
bestseller1.0 Quantity sold across paid and fulfilled orders in the bestseller window (365 days), plus quantity on accepted and paid quotes.
popular1.0 view_product events in the popular window (30 days).
carted1.0 add_to_cart events in the popular window.
newness0.5 Exponential decay against the product's created_at, halving every 30 days.
featured2.0 The manual per-product flag. Heaviest by default because it exists to be decisive.
price_target0.0 off How close the product's average active-variant price sits to a target you set. A bell curve, not a cutoff.
on_sale0.0 off Whether base_compare_price sits above the effective price.

And three personal signals, computed per customer:

SignalDefault weightWhat it measures
view1.0This person's view_product events on this product.
cart2.0Their add_to_cart events. Intent counts double.
quote3.0Appearances 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:

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 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:

  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.
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_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.

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
keyRequired. Your pk_live_….
limit6Maximum 24.
categoryRestrict 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 hosted APIPoint 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:

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.

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
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,
    "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.

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.