Stitchwork Docs
stitchwork.io

Connect your stack

Web tracking tag

One script tag on your site. It tracks page views by itself, takes intent events from your own code, respects the consent banner you already run, and turns campaign clicks and in-store QR scans into attributable sessions.

Get your keys

Settings → Web tracking → Generate keys. Two credentials, very different jobs:

KeyLivesAccepted on
pk_live_… Browser Event ingest, public recommendations, public category interest, public quote lookup. Write-only and workspace-scoped — nothing sensitive is readable with it.
sk_live_… Your server The join endpoint, external quote creation, external order creation. Stored hashed, shown once.
The secret is shown once

There is no endpoint that reads it back. Rotating issues a new pair and invalidates the old secret everywhere it is in use, so plan the swap.

curl -X POST https://app.stitchwork.io/api/v1/settings/tracking/keys -b cookies.txt
# {"data":{"public_key":"pk_live_…","secret_key":"sk_live_…"}}

Install the tag

<script>window.sw=window.sw||function(){(window.sw.q=window.sw.q||[]).push(arguments)};</script>
<script defer src="https://app.stitchwork.io/tag/your-workspace-slug.js"></script>

The first line is a queue stub, so sw(…) is callable from anywhere on the page — including markup above this snippet. Calls made before the deferred tag loads are queued and replayed in order.

The tag script is served per workspace at /tag/{slug}.js, cached for five minutes, and carries your public key and consent configuration baked in. It is about 9 KB and has no dependencies.

What it does with no further work

Firing your own events

Everything beyond page views is declared by you, from your own code:

sw('track', 'view_product', { sku: 'CONCERTO-WAL-OO' });
sw('track', 'add_to_cart',  { sku: 'CONCERTO-WAL-OO', price: 2850, currency: 'GBP' });
sw('track', 'search',       { query: 'walnut concerto' });
sw('track', 'purchase',     { order_id: '1001', value: 2850, currency: 'GBP' });

// anything not in the known list lands as a custom event with its name kept
sw('track', 'newsletter_signup', { placement: 'footer' });
NameFire it onWhat it powers
view_productProduct page render, quick-view openPopular ranking signal, personal view affinity, category interest
add_to_cartAdd-to-cart clickCarted ranking signal, personal cart affinity — weighted twice as heavily as a view
searchSearch submitThe customer's own words on their timeline
purchaseOrder confirmation pageTimeline. Revenue and bestseller come from orders, not this.
page_viewAutomaticTimeline; the only type that falls back to URL matching
clickWhatever you likeTimeline
Always send props.sku

view_product, add_to_cart and purchase attribute to a product only through the SKU. They fire from carts, quick-adds and carousels whose URL isn't the product's page, so the URL is deliberately not trusted for them. A variant SKU is rolled up to its parent automatically, and both grains are kept — so you can later rank parents or variants depending on what your grid renders.

Three modes, set per workspace:

ModeBehaviourUse when
auto
default
Detect a CMP and follow it. If none is present, track immediately. You run one of the CMPs below, or you have no consent obligation.
require Never track until something grants — a detected CMP, or an explicit swConsent('grant'). You operate under GDPR/ePrivacy but run a CMP we don't auto-detect.
off No gating at all. You have no consent obligations, or consent is handled entirely upstream.

Independently, Global Privacy Control is honoured by default and acts as a hard deny floor regardless of mode — including over a CMP that says yes. Required for US-state CCPA/CPRA compliance.

curl -X PATCH https://app.stitchwork.io/api/v1/settings/tracking/consent \
  -H "Content-Type: application/json" -b cookies.txt \
  -d '{"mode":"require","honour_gpc":true}'

Auto-detected platforms

The first matching detector wins, and the tag then follows its change events for the rest of the session:

  1. Cookiebot — grants on the statistics category.
  2. Shopify Customer Privacy — grants when analyticsProcessingAllowed() is true.
  3. IAB TCF v2.2 — OneTrust, Usercentrics, Didomi, Iubenda, TrustArc, Quantcast and anything else implementing __tcfapi. Grants when purpose 1 (store/access information on device) and purpose 7 (measurement) are both consented, or when GDPR doesn't apply.
swConsent('grant');    // flush the queue, allow new events, promote the id to a cookie
swConsent('revoke');   // clear the cookie and the queue, block new events
swConsent('status');   // 'granted' | 'pending' | 'denied'

swConsent has the same pre-load queue stub pattern as sw, so you can call it before the tag has finished loading.

Nothing is lost while consent is pending

Before consent, the tag holds a visitor id in memory and queues events. If consent arrives mid-visit, the in-memory id is promoted to the cookie and the queued events are flushed with that id — so the whole page load is attributed to one visitor, not split across two.

Cookies the tag sets

CookieLifetimePurpose
__sw_v 2 years, sliding The visitor identity. First-party on your domain. Written only after consent is granted.
__sw_channel 30 min, sliding Which physical channel this visit belongs to after a QR scan. Every subsequent event inherits it.
__sw_attr 30 min, sliding Deduplicates campaign touches so a refresh doesn't re-record the same UTM set.

All three are SameSite=Lax, and Secure on HTTPS. None are readable cross-origin — the tag sends values in the request body.

In-store QR attribution

A QR code that points at your own site with two extra markers on the URL. No cross-domain redirect, no server-side event, no extra consent step.

  1. Print   QR encodes:  https://you.example/guitars/concerto?_sw_ch=3&_sw_scan=1&_sw_qr=12
                                                                │        │        └ which QR
                                                                │        └ "this was a scan"
                                                                └ which channel

  2. Scan    phone opens your page, your tag boots

  3. Tag     ├ fires  qr_scan  { channel_id: 3, qr_id: 12 }
             ├ sets   __sw_channel=3  (30 min, sliding)
             └ strips _sw_* from the URL via history.replaceState

  4. Result  every event for the next 30 minutes carries channel_id 3,
             and the URL bar, back button and share sheet all show the clean URL
Step 3's URL cleanup happens before the browser registers the page in history, so the markers never end up in a bookmark or a shared link.

Making them

Channels → (a channel) → QR codes. Three kinds:

KindTargetTypical use
productThe product's web_urlShelf-edge label, tag on the item itself
channel_entryThe channel's urlSticker on the front door
customAny URL you typeCare instructions, a lookbook, a contact form
curl -X POST https://app.stitchwork.io/api/v1/channels/3/qr-codes \
  -H "Content-Type: application/json" -b cookies.txt \
  -d '{"name":"Window — Concerto","kind":"product","product_id":12}'

The response includes scan_url — the fully decorated URL to encode into your QR image. Targets resolve at creation time, so changing a product's web_url later does not invalidate prints you have already made; regenerate the code if you want it to follow.

Store visits land on the timeline

A qr_scan shows up on the customer's activity timeline as a distinct "store visit" row alongside their web browsing, and feeds the Store visits dashboard KPI.

Campaign attribution

The tag reads utm_source, utm_medium, utm_campaign, utm_term, utm_content, and the click ids gclid, fbclid and msclkid, on load and on every SPA transition.

A bare click id with no UTM parameters gets a derived source and medium — gclid → google/cpc, fbclid → facebook/paid-social, msclkid → bing/cpc.

Each distinct touch is recorded once per 30-minute window and lands in two places: a campaign_touch event on the timeline, and a row in the multi-touch table that conversions snapshot from. Unlike the QR markers, UTM parameters are not stripped from the URL — your own analytics expect them.

See Campaign attribution for the conversion side.

The ingest endpoint

If you would rather not use the tag — a native app, a server-side page renderer — post events yourself:

curl -X POST https://app.stitchwork.io/api/v1/track/event \
  -H "Authorization: Public pk_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "visitor_token": "9c1f…",
    "type": "view_product",
    "name": null,
    "url": "https://example.com/guitars/concerto",
    "referrer": "https://google.com/",
    "title": "Concerto Acoustic",
    "props": {"sku":"CONCERTO-WAL-OO","channel_id":3}
  }'

visitor_token and url are required; everything else is optional. Success is 204 No Content. The endpoint is CORS-enabled for any origin — the public key is workspace-scoped and write-only, so that is safe.

ResponseMeaning
204Recorded.
401Bad or missing public key.
422Missing visitor_token or url.
429Over the per-workspace per-minute cap (1200 by default). Back off and retry.

An unknown type is coerced to custom rather than rejected, so a typo costs you a categorisation, not an event.

What is stored about the request

The user agent, and a salted SHA-256 hash of the client IP — never the IP itself. The salt is per deployment. Proxy headers from Cloudflare and standard forwarding are respected so the hash is stable behind a CDN.

The join endpoint

One small route on your domain, because that is the only place the first-party __sw_v cookie is readable. It is what makes the in-store QR handoff work.

Tell Stitchwork where it lives:

curl -X PATCH https://app.stitchwork.io/api/v1/settings/tracking \
  -H "Content-Type: application/json" -b cookies.txt \
  -d '{"tracking_join_url":"https://example.com/__stitchwork/join"}'

A bare host is also accepted for backwards compatibility, in which case the path /__stitchwork/join is assumed. Until this is set, the "share your browsing" option simply doesn't appear on public quote pages — the endpoint returns {"available": false, "reason": "tracking_not_installed"}.

Your route receives ?code=…&return=…, reads the cookie, calls Stitchwork, and redirects back:

// Node / Express — full versions for PHP, Next.js and Cloudflare Workers
// are in the recipes.
app.get('/__stitchwork/join', async (req, res) => {
  const t = req.cookies['__sw_v'];
  let ok = '0', reason = '&reason=no_visitor';
  if (t) {
    const r = await fetch('https://app.stitchwork.io/api/v1/track/join', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json',
                 'Authorization': 'Bearer ' + process.env.STITCHWORK_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');
  }
  const ret = String(req.query.return || '/');
  res.redirect(ret + (ret.includes('?') ? '&' : '?') + 'joined=' + ok + reason);
});

See Join endpoint recipes for the other runtimes, and Identity stitching for the failure reasons.

Verifying it works

  1. Open your site in a fresh browser profile. In devtools, confirm a __sw_v cookie appears — if it doesn't, consent is pending or GPC is on.
  2. Watch the network tab for a POST to /api/v1/track/event returning 204.
  3. Open Rankings in the app: the Popular input count should climb.
  4. Click a product and check the event carries a sku in props. No SKU means no product attribution.
  5. Open a customer with a stitched visitor and check their timeline shows the browsing.

Common failures and their causes are in Troubleshooting.