Stitchwork Docs
stitchwork.io

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);
  }
};
Handling the return

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

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.

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

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