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
}
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.
Swapping a hero banner by interest
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
-
Event ingest: post with a made-up
visitor_tokensuch astest-0001. It creates a visitor you can find and ignore. - Webhooks: point a subscription at a request-capture service to read the raw headers and body, then swap the URL once your verifier works.
-
Quotes: build against a test customer, then delete the customer with
?cascade=1to take the quotes with it. -
Orders: use a distinctive
external_sourcelike"sandbox"so test rows are easy to find and delete.
All four cleanup paths are owner-only and are covered in Clearing out test data.