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 and NFC taps 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.
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 / NFC 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.
- Renders any nudge this visitor matches — there is no second script for that.
The nudge check is deliberately last and lowest priority: it runs after the tag has done its own work, on a passive schedule, and it can never delay a page view or displace an event. If you have no live nudges it costs nothing, because the tag stops asking.
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.
Identifying a visitor
When someone tells you who they are — a live-chat handover, a newsletter box, a gated download — hand us the address and we attach everything that browser has already done to a customer record:
sw('identify', { email: '[email protected]', source: 'live_chat' });
Only email is required. name, phone and
source are optional; source is a short free-text label
recorded against the identification so you can tell later which surface is actually
producing identified customers.
An email typed into a nudge arrives here too, with its own source label. It is the same door, so a shopper who gives you an address on a banner gets the same retroactive stitching as one who gives it to a live-chat agent.
The customer is upserted by email, so calling this on every page of a logged-in session is safe. The visitor's whole prior trail — the three weeks of anonymous browsing before they ever typed anything — becomes attributable at that moment, and their recommendations and category interest are populated immediately rather than starting from empty.
| Field | Notes |
|---|---|
email | Required. Validated, lower-cased, and the key the customer is upserted on. |
name | Optional. When absent on a new customer we store the email address as the name rather than inventing one from the local part. A real name supplied later replaces it, and a later call without a name never overwrites one you already captured. |
phone | Optional. |
source | Optional label, [a-z0-9_.-], 40 chars. Defaults to identify. |
It obeys the consent gate like everything else. Called before consent is granted, the identity is held — in its own slot, not the event buffer, so it can't be evicted by a flood of page views — and sent after the queued events flush, so the trail exists before we ask for it to be attached. A revoke discards it. Global Privacy Control blocks it outright.
Server-to-server
The browser call posts to POST /api/v1/identify with your
pk_live_… key and answers 204 with an empty body — deliberately.
That key ships in your page source, so a response carrying a name, a phone number or
even "this address is already a customer" would let anyone enumerate your customer list
from the browser console.
When you need the customer back — writing the id into your own CRM, or identifying from a chat platform's webhook rather than its widget — use the secret door instead:
curl -X POST https://app.stitchwork.io/api/v1/customers/identify \
-H "Authorization: Bearer sk_live_••••" \
-H "Content-Type: application/json" \
-d '{"visitor_token": "…", "email": "[email protected]", "source": "live_chat"}'
{ "version": "v1", "data": {
"customer": { "id": 412, "name": "[email protected]", "email": "[email protected]", … },
"created": true,
"linked": true,
"link_reason": null } }
visitor_token is the value of the first-party __sw_v cookie on
your domain. It is optional: without it you still get the customer, with
linked: false and a link_reason of
no_visitor_token. A lead with no cookie is still a lead.
If the token already belongs to a different customer — a shared machine, a second
person on the same browser — the link is refused and you get
link_reason: "visitor_belongs_to_another_customer". The customer is still
created or updated; only the trail stays where it was. A token we have never seen
answers unknown_visitor, which usually means consent is still pending or
an extension is blocking the tag.
The refusal is not thrown away. The commonest reason a known browser identifies as a second address is that it is the same person — the newsletter address and the checkout address — so Stitchwork records the pair as a possibly the same person question on both records. It shows on the customer page and on the lead desk, and a person answers it: merge the two, or say it is a shared device. See Identity.
Both doors are ungated: a workspace whose billing has lapsed keeps capturing leads.
Every identification also fires the customer.created or
customer.updated webhook, which is usually the
easiest way to get a new lead in front of whoever needs to act on it.
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.
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 or NFC tap. 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 and NFC 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. The same URL written to an NFC tag makes a tap do the same job — see NFC tags below.
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 & NFC. 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 |
The response includes scan_url — the fully decorated URL to encode into
your QR image — and tap_url, the same destination for writing to an NFC tag.
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.
NFC tags
A code is a URL first and a picture second. Every code you make also carries an NFC link, shown next to the QR link on the channel's QR & NFC tab with a copy button. Write it to a tag as a URL record (an NDEF URI record — every NFC writer app calls it "URL" or "Link") and a tap lands on the same page, with the same attribution, through the same tag on your site. Nothing else to install.
QR link https://you.example/guitars/concerto?_sw_ch=3&_sw_scan=1&_sw_qr=12
NFC link https://you.example/guitars/concerto?_sw_ch=3&_sw_scan=1&_sw_qr=12&_sw_src=nfc
└ "this was a tap"
Tap → qr_scan { channel_id: 3, qr_id: 12, source: 'nfc_tap', medium: 'nfc' }
Scan → qr_scan { channel_id: 3, qr_id: 12, source: 'qr_scan', medium: 'qr' }
_sw_src means QR, so codes printed before NFC existed
keep reporting as scans without a reprint.
Both are the same kind of event — a store visit — so every report that counts store visits counts taps too. What differs is the count on the code itself: the channel's list shows scans and taps separately, which is what answers "we put a sticker and a tag on the same shelf label; which one do people use?".
The NFC link is a plain URL, so anything that writes NDEF works — a phone app for a handful, an encoder for a print run. Nothing is baked into the tag beyond the URL, so a tag written today keeps working as long as the destination page does.
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. Taps land there too, described as a tap rather than a scan.
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, and the attribution report for reading these touches back as sales.
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 one-way hash of the client IP — never the IP itself. There is no field anywhere in Stitchwork that holds a customer's IP address, so there is nothing to leak, subpoena or hand over. The hash stays stable behind a CDN, so your own proxying doesn't distort it.
The join endpoint
The in-store handoff needs one small route on your domain — it has to be your domain, because that is the only place your own first-party cookie can be read. It is a short piece of code and it is the same shape in every runtime; we provide it and set it up with you during onboarding rather than publishing it here.
Tell Stitchwork where it lives under Settings → Web tracking → Join URL.
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"}.
See the in-store handoff for what it does for the person on your floor.
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.