Stitchwork Docs

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:

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.

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

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' });
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.

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: 'ellie@vance.co.uk', 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.

FieldNotes
emailRequired. Validated, lower-cased, and the key the customer is upserted on.
nameOptional. 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.
phoneOptional.
sourceOptional 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": "ellie@vance.co.uk", "source": "live_chat"}'
{ "version": "v1", "data": {
    "customer":    { "id": 412, "name": "ellie@vance.co.uk", "email": "ellie@vance.co.uk", … },
    "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.

A visitor is never silently reassigned

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.

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.

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 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
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 & NFC. 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

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'  }
One marker apart. No _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?".

Writing tags in bulk

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.

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

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

  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.