Stitchwork Docs

Integration recipes

Working code for the things merchants actually ask for. Each recipe is self-contained — copy it, change the key, ship it.

The in-store handoff

One integration on this page isn't a copy-paste job, and it's the one worth having: the handoff that drops a customer's three weeks of research in front of your associate while they're still standing there. It needs a short route on your own domain, and we build it with you rather than publishing it — it lands in your implementation pack, in your runtime, and takes about five minutes of your developer's time.

See the in-store handoff for what it does on your floor, and the join endpoint for the one setting you point at it afterwards.

"Reserve in store" from your website

A shopper builds a basket online and chooses to complete it in a shop. Your backend creates the draft quote, and an agent has it open before they arrive.

// server-side only — the secret key must never reach the browser
async function reserveInStore({ cart, customer, visitorToken, channelId }) {
  const res = await fetch('https://app.stitchwork.io/api/v1/quotes/external', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + process.env.STITCHWORK_SECRET,
    },
    body: JSON.stringify({
      channel_id: channelId,
      customer: { email: customer.email, name: customer.name, phone: customer.phone },
      visitor_token: visitorToken,          // the shopper's __sw_v cookie
      currency: 'GBP',
      tax_rate: 20,
      tax_inclusive: true,
      notes_customer: 'Reserved online — please hold for 7 days.',
      line_items: cart.lines.map(l => ({
        sku: l.sku,
        description: l.title,
        quantity: l.quantity,
        unit_price: l.price.toFixed(2),
      })),
    }),
  });

  if (!res.ok) throw new Error(`Stitchwork ${res.status}: ${(await res.json()).error}`);
  const { data } = await res.json();
  return data.public_url;   // redirect the shopper here, or email it
}

Passing visitor_token is what makes the agent's "recent browsing" panel light up the moment they open the quote. Read it from the cookie on your own domain — it is not readable cross-origin.

Booking orders from your checkout

Fire this once the payment has settled, from your server:

async function recordOrder(order, visitorToken) {
  const res = await fetch('https://app.stitchwork.io/api/v1/orders/external', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + process.env.STITCHWORK_SECRET,
    },
    body: JSON.stringify({
      channel_id: 2,                                  // your web channel
      customer: { email: order.email, name: order.name },
      visitor_token: visitorToken,
      order_number: order.number,
      external_source: 'shopify',
      external_id: order.gid,                         // ← makes retries idempotent
      status: 'paid',
      currency: order.currency,
      placed_at: order.createdAt,
      discount_amount: order.discount.toFixed(2),
      tax_amount: order.tax.toFixed(2),
      shipping_amount: order.shipping.toFixed(2),      // omit it and it books as 0.00
      total: order.total.toFixed(2),
      line_items: order.lines.map(l => ({
        sku: l.sku,
        description: l.title,
        quantity: l.quantity,
        unit_price: l.price.toFixed(2),
        line_total: (l.price * l.quantity).toFixed(2),
      })),
    }),
  });
  if (!res.ok) throw new Error(await res.text());     // safe to retry — upserts on external_id
}
Set external_id yourself

Without it, a retry after a timeout generates a random id and creates a duplicate order — which then double-counts in revenue and the bestseller signal. It is also the id you'll name the order by for every update after this one.

Keeping it up to date

The sale is booked; the rest of its life is a series of small facts. Send each one as it happens — PATCH, same URL, same key, and only the fields that changed:

function updateOrder(externalId, patch) {
  return fetch('https://app.stitchwork.io/api/v1/orders/external', {
    method: 'PATCH',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + process.env.STITCHWORK_SECRET,
    },
    body: JSON.stringify({
      external_id: externalId,       // in the body: real ids have slashes in them
      external_source: 'shopify',    // only if that id exists under two sources
      ...patch,
    }),
  });
}

// the payment cleared
await updateOrder(order.gid, { status: 'paid' });

// it went out — 'shipped' is read as 'fulfilled'
await updateOrder(order.gid, { status: 'shipped' });

// two of the four came back
await updateOrder(order.gid, {
  status: 'partially_refunded',
  total: '1300.00',
});

Anything you don't name is left alone, so these are safe to fire from separate webhook handlers that each know one thing. A 404 means the order was never booked — POST it rather than retrying the patch. Everything else worth knowing is in Updating an order from your backend.

Re-ranking a category page

Your grid stays the source of truth for stock and facets; Stitchwork only reorders it. Three lookup keys depending on what your templates already emit.

// By your own product ids (external_ids)
const ids = [...document.querySelectorAll('[data-product-id]')]
  .map(el => el.dataset.productId);

// By SKU — parent SKUs rank products, child SKUs rank variants
const skus = [...document.querySelectorAll('[data-sku]')]
  .map(el => el.dataset.sku);

async function rerank({ external_ids, skus }) {
  const tok = (document.cookie.match(/(?:^|; )__sw_v=([^;]+)/) || [])[1] || '';
  const qs = new URLSearchParams({ key: 'pk_live_xxxxxxxxxxxx', visitor_token: tok, limit: '60' });
  if (external_ids) qs.set('external_ids', external_ids.join(','));
  if (skus)         qs.set('skus', skus.join(','));

  const res = await fetch('https://app.stitchwork.io/api/v1/public/recommendations?' + qs);
  if (!res.ok) return;                       // never break the page over a ranking call
  const { data } = await res.json();
  if (!data.products.length) return;

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

Both parameters cap at 200 entries. Paginate your grid, or rank the first page only.

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();
const top = data.categories[0];

if (top && top.score > 0.5) {
  const banner = document.querySelector(`[data-banner="${CSS.escape(top.category)}"]`);
  if (banner) {
    document.querySelector('[data-banner-default]')?.remove();
    banner.hidden = false;
  }
}

Render your default banner server-side and swap it, rather than waiting on the call to decide what to paint — otherwise you have bought yourself a layout shift.

Driving your own messaging from the profile

The recipe above branches on one signal. When the whole page's tone should change — headline, hero, proof, offer — read the visitor profile once and put its answers on the document, then let CSS and your own components react. Stitchwork describes the person; every creative decision below stays in your code.

const tok = (document.cookie.match(/(?:^|; )__sw_v=([^;]+)/) || [])[1] || '';
const qs  = new URLSearchParams({ key: 'pk_live_xxxxxxxxxxxx', visitor_token: tok });

let data;
try {
  const res = await fetch('https://app.stitchwork.io/api/v1/public/profile?' + qs);
  if (!res.ok) return;                       // never break the page over a profile call
  ({ data } = await res.json());
} catch { return; }

// Put the facts on the document. CSS can now do most of the work:
//   html[data-sw-stage="high_intent"] .urgency { display: block }
//   html[data-sw-band="premium"] .finance-message { display: none }
const el = document.documentElement;
el.dataset.swStage    = data.intent.stage;                    // browsing … purchased
el.dataset.swBand     = data.price_affinity?.band ?? '';      // budget … luxury
el.dataset.swCategory = data.categories[0]?.category ?? '';
el.dataset.swBrand    = data.brands[0]?.brand ?? '';
el.dataset.swReturning = String(data.recency?.returning ?? false);

// The decisions themselves — yours, not ours.
if (data.intent.stage === 'high_intent' && !data.transaction_value) {
  showFirstOrderIncentive();               // hesitating, never bought
}
if (data.price_affinity && data.price_affinity.percentile > 0.75) {
  leadWith('craftsmanship');               // shopping the top of your range
} else if (data.price_affinity) {
  leadWith('value');
}
if (data.acquisition?.last_touch.paid) {
  suppressDiscountCode();                  // already paid to get them here
}
if (data.searches.length) {
  prefillSearchBox(data.searches[0].term);
}

Read percentile rather than band when you want your own thresholds — the band is only a label on the quartile, and your catalogue's own p25/median/p75 ship in the same payload so you can cut it wherever you like.

Everything is always present, so none of the above needs a guard beyond the optional chaining shown. A brand-new visitor returns { stage: "unknown", categories: [], price_affinity: null, … } — the same shape, with nothing in it — which is also what you get if our end has a bad day.

A "find my quote" form

<form id="find-quote">
  <input name="number" placeholder="Quote number, e.g. Q-2026-000007" required>
  <input name="email" type="email" placeholder="The email it was sent to" required>
  <button>Find my quote</button>
  <p data-error hidden></p>
</form>

<script>
document.getElementById('find-quote').addEventListener('submit', async (e) => {
  e.preventDefault();
  const f = new FormData(e.target);
  const res = await fetch('https://app.stitchwork.io/api/v1/quotes/public/lookup', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Public pk_live_xxxxxxxxxxxx',
    },
    body: JSON.stringify({ number: f.get('number'), email: f.get('email') }),
  });
  const err = e.target.querySelector('[data-error]');
  if (!res.ok) {
    err.textContent = "We couldn't find a quote with those details.";
    err.hidden = false;
    return;
  }
  const { data } = await res.json();
  location.assign('https://app.stitchwork.io/q/' + data.quote.public_token);
});
</script>

Every miss returns the same generic 404 — wrong number, wrong email, or no such quote — so show one generic message rather than trying to be helpful about which field was wrong.

A production webhook receiver

import express from 'express';
import crypto from 'node:crypto';

const app = express();
const SECRET = process.env.STITCHWORK_WEBHOOK_SECRET;
const seen = new Set();   // use Redis or a table in real life

// raw body — verification must run on the exact bytes we received
app.post('/hooks/stitchwork',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.get('Stitchwork-Signature') || '';
    if (!verify(req.body.toString('utf8'), sig, SECRET)) {
      return res.status(400).send('bad signature');
    }

    const event = JSON.parse(req.body.toString('utf8'));

    // At-least-once delivery: dedupe before doing any work.
    if (seen.has(event.id)) return res.status(200).send('duplicate');
    seen.add(event.id);

    // Acknowledge first, work afterwards — the sender holds a lock for the
    // length of this call and times out at 20 seconds.
    res.status(200).send('ok');

    queue.push(event);
  });

function verify(body, header, secret, tolerance = 300) {
  let ts = null; const sigs = [];
  for (const part of header.split(',')) {
    const [k, v] = part.trim().split('=', 2);
    if (k === 't')  ts = parseInt(v, 10);
    if (k === 'v1') sigs.push(v);
  }
  if (!ts || !sigs.length) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > tolerance) return false;
  const expected = Buffer.from(
    crypto.createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex'));
  return sigs.some(s => {
    const b = Buffer.from(s);
    return expected.length === b.length && crypto.timingSafeEqual(expected, b);
  });
}

More detail, and verifiers for PHP and Python, in Verifying signatures.

Wiring the tag into a single-page app

Page views are handled for you — the tag wraps pushState, replaceState and popstate. What you need to add is intent.

// React — fire once per product, not once per render
useEffect(() => {
  window.sw?.('track', 'view_product', { sku: product.sku });
}, [product.sku]);

// Cart actions
function addToCart(variant, qty) {
  cart.add(variant, qty);
  window.sw?.('track', 'add_to_cart', {
    sku: variant.sku,
    price: variant.price,
    currency: variant.currency,
    quantity: qty,
  });
}

// Search — debounce, and send the submitted query, not every keystroke
function onSearchSubmit(query) {
  window.sw?.('track', 'search', { query });
}

The ?. guards are belt and braces: the queue stub in the install snippet already makes sw callable before the tag loads. Keep the stub — without it, calls that fire during hydration are lost.

A nightly sync job

Keep Stitchwork current without a webhook on your side of the wire. Your backend holds a sk_live_ key and posts each order as it settles — no sign-in, no cookie jar, and nothing to rotate when somebody leaves.

#!/usr/bin/env bash
set -euo pipefail

APP=https://app.stitchwork.io

# Yesterday's orders, one POST each. Idempotent on (external_source,
# external_id): re-running over an overlapping range updates rather than
# duplicates, so a retry after a half-finished night is safe.
jq -c '.[]' /exports/orders.json | while read -r order; do
  curl -sS -X POST "$APP/api/v1/orders/external" \
    -H "Authorization: Bearer $SW_SECRET_KEY" \
    -H "Content-Type: application/json" \
    -d "$order" >> /var/log/sw-orders.json
done

Order ingest triggers a ranking recompute itself, so there is usually nothing to add afterwards. Payment and shipment news that arrives later goes to the same endpoint with PATCH — see Updating an order from your backend.

The catalogue has no server-to-server equivalent: import it from Products → Import, which takes the same CSV and matches on SKU. A catalogue changes on the day someone adds a product, not nightly, so it rarely wants a cron job in the first place.

The morning loop — an agent sweep that converges

The pattern the MCP tools are shaped for: each morning, before anyone is in, an agent reads what stands, finds who is worth a call, and puts each one on the right colleague's list with the reason and a draft to open with — so the answer to "who should we call today" is waiting when the team arrives. Run it from any MCP-speaking agent under an mcp_live_ token; the prompt below is the whole of the logic, because the safety lives on Stitchwork's side of the door rather than in the script.

Each morning:

1. get_follow_up_review              — what became of last week's suggestions.
   Report the settled ones plainly: those customers bought before anybody
   rang, and no credit is due for them.
2. list_follow_ups                   — what is already owed. A customer on
   somebody's list is not a lead.
3. list_appointments                 — today's diary; get_customer_brief for
   each visit, so every appointment is briefed before opening.
4. find_abandoned_orders, find_open_quotes,
   find_lapsed_customers (active_since_days: 14),
   find_high_intent_customers        — who is worth a call.
5. For each worth calling and not already owed: create_follow_up with the
   reason in plain prose, worth + worth_currency when the figure came from a
   finder, and a proposed_message two sentences long that a colleague could
   send as-is or rewrite.
6. Report what you did: raised N, skipped M already owed, and anything the
   review says was overtaken by events.

Why this is safe to run unattended, mechanically rather than by promise: only one follow-up can be open per customer (a second attempt returns the standing one, so the sweep converges instead of accumulating); work routes from the customer's own records and the agent is never asked who should call; drafts render on the card for a person to send — nothing reaches a customer unless somebody presses Send; settlement has already retired anything the customer answered themselves; and the weekly review counts how much of the pipeline the agent proposed, which is the number that tells you whether the sweep is earning its seat.

Testing without touching production data

All four cleanup paths are owner-only and are covered in Clearing out test data.