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:
| Key | Lives | Accepted 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. |
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
- Fires
page_viewon load, and on every SPA navigation (pushState,replaceState,popstate). - Sets and slides a first-party
__sw_vvisitor cookie (2-year expiry, refreshed on every event). - Detects QR markers on the URL, records a store visit, and cleans the URL bar.
- Detects UTM parameters and ad click ids, and records a campaign touch.
- Talks to your consent management platform and holds everything back until consent is granted.
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' });
| Name | Fire it on | What it powers |
|---|---|---|
view_product | Product page render, quick-view open | Popular ranking signal, personal view affinity, category interest |
add_to_cart | Add-to-cart click | Carted ranking signal, personal cart affinity — weighted twice as heavily as a view |
search | Search submit | The customer's own words on their timeline |
purchase | Order confirmation page | Timeline. Revenue and bestseller come from orders, not this. |
page_view | Automatic | Timeline; the only type that falls back to URL matching |
click | Whatever you like | Timeline |
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.
Consent and CMPs
Three modes, set per workspace:
| Mode | Behaviour | Use when |
|---|---|---|
autodefault |
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:
- Cookiebot — grants on the statistics category.
- Shopify Customer Privacy — grants when
analyticsProcessingAllowed()is true. - 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.
Driving consent yourself
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.
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
| Cookie | Lifetime | Purpose |
|---|---|---|
__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
Making them
Channels → (a channel) → QR codes. Three kinds:
| Kind | Target | Typical use |
|---|---|---|
product | The product's web_url | Shelf-edge label, tag on the item itself |
channel_entry | The channel's url | Sticker on the front door |
custom | Any URL you type | Care 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.
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.
| Response | Meaning |
|---|---|
204 | Recorded. |
401 | Bad or missing public key. |
422 | Missing visitor_token or url. |
429 | Over 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
- Open your site in a fresh browser profile. In devtools, confirm a
__sw_vcookie appears — if it doesn't, consent is pending or GPC is on. - Watch the network tab for a
POSTto/api/v1/track/eventreturning204. - Open Rankings in the app: the Popular input count should climb.
- Click a product and check the event carries a
skuinprops. No SKU means no product attribution. - Open a customer with a stitched visitor and check their timeline shows the browsing.
Common failures and their causes are in Troubleshooting.