Connect your stack
Integration recipes
Working code for the things merchants actually ask for. Each recipe is self-contained — copy it, change the key, ship it.
The join endpoint
One route on your domain that reads the first-party __sw_v cookie and hands
it to Stitchwork. Required for the in-store QR handoff, and it takes five minutes.
Expose it at /__stitchwork/join (or anywhere — just tell Stitchwork the URL
via Settings → Web tracking). Put your sk_live_… in an
environment variable; it must never reach the browser.
Node / Express
// requires cookie-parser
const STITCHWORK = 'https://app.stitchwork.io/api/v1';
const SECRET = process.env.STITCHWORK_SECRET; // sk_live_...
const COOKIE = '__sw_v';
app.get('/__stitchwork/join', async (req, res) => {
const t = req.cookies[COOKIE];
let ok = '0', reason = '&reason=no_visitor';
if (t) {
try {
const r = await fetch(STITCHWORK + '/track/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + SECRET },
body: JSON.stringify({ visitor_token: t, join_code: req.query.code }),
});
ok = r.ok ? '1' : '0';
reason = r.ok ? '' : '&reason=' + (((await r.json()) || {}).reason || 'unknown');
} catch (e) {
reason = '&reason=network';
}
}
const ret = String(req.query.return || '/');
const sep = ret.includes('?') ? '&' : '?';
res.redirect(ret + sep + 'joined=' + ok + reason);
});
PHP
<?php
$STITCHWORK = 'https://app.stitchwork.io/api/v1';
$SECRET = getenv('STITCHWORK_SECRET'); // sk_live_...
$token = $_COOKIE['__sw_v'] ?? null;
$code = $_GET['code'] ?? '';
$return = (string) ($_GET['return'] ?? '/');
$ok = '0'; $reason = '&reason=no_visitor';
if ($token) {
$ch = curl_init($STITCHWORK . '/track/join');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $SECRET,
],
CURLOPT_POSTFIELDS => json_encode([
'visitor_token' => $token,
'join_code' => $code,
]),
CURLOPT_TIMEOUT => 10,
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 200 && $status < 300) {
$ok = '1'; $reason = '';
} else {
$j = json_decode((string) $body, true) ?: [];
$reason = '&reason=' . ($j['reason'] ?? 'unknown');
}
}
$sep = (strpos($return, '?') !== false) ? '&' : '?';
header('Location: ' . $return . $sep . 'joined=' . $ok . $reason, true, 302);
exit;
Next.js (app router)
// app/__stitchwork/join/route.ts
import { NextRequest, NextResponse } from 'next/server';
const STITCHWORK = 'https://app.stitchwork.io/api/v1';
const COOKIE = '__sw_v';
export async function GET(req: NextRequest) {
const token = req.cookies.get(COOKIE)?.value;
const code = req.nextUrl.searchParams.get('code') || '';
const ret = req.nextUrl.searchParams.get('return') || '/';
let ok = '0', reason = '&reason=no_visitor';
if (token) {
try {
const r = await fetch(STITCHWORK + '/track/join', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + process.env.STITCHWORK_SECRET,
},
body: JSON.stringify({ visitor_token: token, join_code: code }),
});
ok = r.ok ? '1' : '0';
reason = r.ok ? '' : '&reason=' + (((await r.json()) as any)?.reason || 'unknown');
} catch {
reason = '&reason=network';
}
}
const sep = ret.includes('?') ? '&' : '?';
return NextResponse.redirect(ret + sep + 'joined=' + ok + reason);
}
Cloudflare Worker
export default {
async fetch(req, env) {
const url = new URL(req.url);
if (url.pathname !== '/__stitchwork/join') return new Response('Not found', { status: 404 });
const code = url.searchParams.get('code') || '';
const ret = url.searchParams.get('return') || '/';
const cookie = (req.headers.get('Cookie') || '')
.split(';').map(s => s.trim()).find(s => s.startsWith('__sw_v='));
const token = cookie ? decodeURIComponent(cookie.slice(7)) : null;
let ok = '0', reason = '&reason=no_visitor';
if (token) {
try {
const r = await fetch('https://app.stitchwork.io/api/v1/track/join', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + env.STITCHWORK_SECRET,
},
body: JSON.stringify({ visitor_token: token, join_code: code }),
});
ok = r.ok ? '1' : '0';
reason = r.ok ? '' : '&reason=' + (((await r.json()) || {}).reason || 'unknown');
} catch {
reason = '&reason=network';
}
}
const sep = ret.includes('?') ? '&' : '?';
return Response.redirect(ret + sep + 'joined=' + ok + reason, 302);
}
};
The customer lands back on their quote page with ?joined=1 or
?joined=0&reason=…. The public quote page reads that and confirms the
handoff. The reason codes are worth logging on
your side too — no_visitor usually means consent blocked the tag.
"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),
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.
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.
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:
#!/usr/bin/env bash
set -euo pipefail
APP=https://app.stitchwork.io
COOKIES=/var/lib/stitchwork/cookies.txt
# 1. sign in
curl -sS -X POST "$APP/api/v1/auth/login" \
-H "Content-Type: application/json" -c "$COOKIES" \
-d "{\"email\":\"$SW_EMAIL\",\"password\":\"$SW_PASSWORD\"}" > /dev/null
# 2. push the catalogue (idempotent on SKU, merges rather than replaces)
curl -sS -X POST "$APP/api/v1/products/import" \
-b "$COOKIES" -F "file=@/exports/catalogue.csv" | tee /var/log/sw-products.json
# 3. push yesterday's orders (idempotent on external_id; triggers a recompute)
curl -sS -X POST "$APP/api/v1/orders/import" \
-b "$COOKIES" -F "file=@/exports/orders.csv" | tee /var/log/sw-orders.json
Both imports are safe to re-run over overlapping date ranges. The order import triggers a
ranking recompute itself, so there is usually nothing to add after step 3 — add
POST /api/v1/rankings/recompute only if you changed windows or bulk-edited
product dates.
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.