Shopify
Two halves, each worth having on its own. Connect your store and every order — on the website or rung up on Shopify POS in the shop — lands on the client's record as it happens, with your products and your order history brought in. Put the tag in your theme and what clients look at online joins the same record.
The connection reads orders, customers, products and locations from Shopify and never writes anything back. The tag is a theme edit you can undo by deleting a snippet. The general reference for the tag lives in Web tracking tag — the second half of this page is the Shopify-shaped version of it.
What you end up with
- Every Shopify order on the right client's record, online and from the till, within seconds of the sale.
- Your catalogue in Stitchwork with Shopify's own ids and pictures, and your past orders brought in.
- What each client told Shopify about email and text marketing, in their marketing permissions.
- Every visitor on a first-party
__sw_vcookie, page views tracked automatically. - Product views and cart adds tied to real products, so rankings and recommendations have something to rank.
- Logged-in shoppers matched to their customer record, with their whole prior anonymous trail attached retroactively.
- Money recorded in the currency the shopper actually saw.
- Your collection pages reorderable by Stitchwork's ranking, keyed on the Shopify ids your theme already renders.
Connect your store
An owner or admin connects the store in Settings → Shopify. Shopify only hands out access to an app made by the organisation that owns the store, so the first step is a small private app of your own, made in your own Shopify account. Nothing is listed on the Shopify App Store and nothing needs review.
Make the app in Shopify
- Open Shopify's Dev Dashboard, signed in as someone in the organisation that owns the store, and create an app called Stitchwork.
- Give it these Admin API scopes:
read_orders,read_customers,read_products,read_locationsandread_all_orders. Without the last one Shopify only shows the last 60 days of orders, so older history cannot be brought in. - Ask for access to protected customer data: customers' names, email addresses and phone numbers. Without it Shopify sends orders with the customer's details taken out, and they cannot be put on anybody's record.
- Release that version of the app and install it on the store.
- Copy the Client ID and Client secret from the app's settings.
An app made in the Shopify admin before 2026 works too: open it under Settings → Apps and sales channels → Develop apps, check its Admin API scopes include the five above, and copy the Admin API access token and the API secret key. Shopify shows that token only once; reinstalling the app gives a new one.
Connect it
In Settings → Shopify → Connect your Shopify store, say where the app was
made, then paste the store's .myshopify.com address and the two values you
copied. Stitchwork checks them with Shopify before it saves anything, and says which scope
is missing if one is. The card then shows the store's name, currency and time zone, whether
live updates are on, and how much has come in.
Where sales land
Each Shopify location — the Bath shop, the Harrogate shop, the warehouse — is mapped to one of your stores in Stitchwork, or to Don't import. Online orders, and orders made in the Shopify admin, go to the channel you choose for them, usually your website. A sale from a location set to Don't import is skipped and counted, so the card can say so; map the location, then run Import history to bring those sales in. Add your stores on Channels first if they are not there yet.
Live updates
Once connected, Stitchwork asks Shopify to tell it about new and changed orders, refunds, customers and products. Each delivery is signed by Shopify and checked before anything is written, and a delivery that arrives twice changes nothing the second time. A refund reduces what the order counts for, and a cancelled order says so. If the app is uninstalled in Shopify, the connection stops.
Import history
Import history brings in your catalogue first — every product and variant, with Shopify's ids and its picture — then orders from the last 12, 24 or 36 months, or everything. It shows its progress as it goes and runs from the open browser tab: close the tab and it stops, and Carry on picks up where it left off. It is safe to run again; an order already brought in is updated, never doubled. A product you imported earlier from Shopify's CSV export is adopted rather than duplicated.
Which client a sale belongs to
- A customer Shopify has sent before goes to the same client, even after they change their address in Shopify or two records are merged here.
- By email address, including any other address already on a client's record (see Customers & identity).
- By phone number, for a customer with no email address — typically somebody at the till who gave a number. The number must be on exactly one record whose name agrees with Shopify's. A number nobody has makes a new client kept by that number. A number that is already on a record under another name, such as a household landline, is not guessed at: the sale is kept without a client, and someone who knows them attaches it from the order.
- Otherwise a new client, named as Shopify names them.
A client's record is only ever filled in from Shopify, never overwritten: a name your team typed stands, and a new email address from Shopify is kept as one of their other addresses. A sale with no customer at all, a cash sale to somebody who gave nothing, is kept as a sale with no client.
Marketing consent from Shopify
When a customer subscribes or unsubscribes to email or text marketing in Shopify — at checkout, or from the link in a Shopify email — that statement is added to their marketing permissions, dated when they made it, with the address or number it was for. It is added only when it is news: the same state arriving again adds nothing, and a yes or a STOP recorded in Stitchwork since stands against an older statement from Shopify. Shopify's "not subscribed" and "pending" record nothing, because nobody decided anything.
What it does not do
- It never writes to Shopify: no orders, customers, tags or stock.
- An order or customer deleted in Shopify stays in Stitchwork, and customers merged in Shopify are not merged here.
- Product tags and stock levels are not brought in.
Disconnect stops new sales arriving and deletes the app's details from Stitchwork. Everything already brought in stays.
The tracking tag
The connection knows what people bought. The tag adds what they looked at before they bought it, on the website, so the associate who meets them sees it.
Before you start
Generate your keys at Settings → Web tracking → Generate keys. Only the
public pk_live_… key is needed for anything on this page; it is baked into the
tag script and is safe in your theme.
Import history has already loaded your catalogue with Shopify's product and variant ids, which is Route B below done for you. The rest of this section is for a store that is not connected.
Do your variants have SKUs?
Answer this first, because it decides how you load your catalogue. Stitchwork ties an event to a product by SKU, and falls back to the platform's own id when there is no SKU to use. Shopify stores vary enormously here: a store that grew out of wholesale usually has every SKU filled in, and a store built around a product configurator very often has none.
In Shopify admin, export your products (Products → Export → All products, CSV)
and look at the Variant SKU column.
| What you see | Use |
|---|---|
| SKUs on every variant | Route A — match on SKU. Simpler, and the export you just ran is most of the work. |
| Blank or patchy SKUs | Route B — match on Shopify ids. More setup, but nothing depends on data you don't have. |
You can also do both — a SKU is used when present and the id is the fallback, so a catalogue carrying each on the products that have them resolves either way round.
Install the tag
Online Store → Themes → ⋯ → Edit code, open
layout/theme.liquid, and paste this immediately before
</head>:
<script>window.sw=window.sw||function(){var q=window.sw.q=window.sw.q||[];q.length<500&&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 immediately — including
from snippets that render above this one. The tag fires page_view on load and
on every SPA navigation, which covers the section-rendering and predictive-search
navigation modern Shopify themes do.
A custom pixel runs in a sandboxed iframe with its own storage. The tag needs to read and
write a first-party cookie on your domain, which the sandbox will not let it do. Custom
pixels have exactly one job here — checkout — and everything else
belongs in theme.liquid.
Route A — load your catalogue by SKU
Take the Shopify product export and rename its columns to the ones the importer reads. The minimum useful shape:
product_sku,product_name,variant_sku,variant_price,currency,status,web_url
LOW-O32,O-32 Walnut,LOW-O32-WAL,3400,GBP,active,https://your-store.com/products/o-32-walnut
LOW-O32,O-32 Walnut,LOW-O32-KOA,3600,GBP,active,https://your-store.com/products/o-32-walnut
Import at Products → Import. Rows sharing a product_sku group
into one product with several variants. Full column list and the merge rules are in
Products & catalogue.
web_url is worth filling in even on this route: it lets a plain
page_view on a product page attribute without any event code at all. It is the
only event type that falls back to URL matching.
Route B — load your catalogue by Shopify id
Every Shopify product and variant has a numeric id, and your theme has it everywhere —
on the add-to-cart form, in /cart.js, in {{ product.id }}. Store
those ids against your products and events can name a product without ever mentioning a SKU.
| Column | Holds |
|---|---|
external_source | shopify. Required — without it nothing resolves, which is what stops these ids being confused with anyone else's. |
external_id | The Shopify product id. Alias: shopify_product_id. |
variant_external_id | The Shopify variant id. Alias: shopify_variant_id. |
product_sku,product_name,product_type,base_price,currency,status,external_source,external_id,variant_external_id,variant_price,option:finish
LOW-O32,O-32 Walnut,configurable,3400,GBP,active,shopify,8001,43815927283,3400,walnut
LOW-O32,O-32 Walnut,configurable,3400,GBP,active,shopify,8001,43815927284,3600,koa
The product export gives you Handle and Variant SKU, and no
numeric ids at all. To get them, use the Admin API — GET /admin/api/2024-10/products.json
returns id on each product and on each entry in its variants
array — or any of the export apps that add an ID column. This is the one genuinely
awkward step on this route, and it is why Route A is the easier path when you have SKUs.
product_sku is still what decides whether a row creates or updates a product,
on both routes. If your products genuinely have no SKU anywhere, use the Shopify handle as
product_sku — it is stable, unique per store, and gives the importer something
consistent to match on when you re-import.
Product views
In sections/main-product.liquid (or whatever your theme calls its product
section), after the product renders:
<script>
sw('track', 'view_product', {
sku: {{ product.selected_or_first_available_variant.sku | json }},
price: {{ product.selected_or_first_available_variant.price | divided_by: 100.0 | json }},
currency: {{ cart.currency.iso_code | json }}
});
</script>
On Route B, send the id instead of — or as well as — the SKU:
sw('track', 'view_product', {
product_external_id: {{ product.selected_or_first_available_variant.id | json }},
product_external_source: 'shopify',
price: {{ product.selected_or_first_available_variant.price | divided_by: 100.0 | json }},
currency: {{ cart.currency.iso_code | json }}
});
json filter, always
It quotes and escapes in one step. A product named O-32 "Special" breaks a hand-quoted string and takes the rest of your page's JavaScript with it.
Shopify prices are in minor units — price is 340000 for £3,400 —
so divided_by: 100.0 is not cosmetic. Send the major-unit figure and the
currency beside it.
Add to cart
Themes vary too much for one snippet, so hook the thing they all share: the form that posts
to /cart/add. Put this in theme.liquid, after the tag:
<script>
document.addEventListener('submit', function (e) {
var form = e.target.closest('form[action*="/cart/add"]');
if (!form) return;
var id = form.querySelector('[name="id"]');
if (!id || !id.value) return;
sw('track', 'add_to_cart', {
product_external_id: id.value, // the Shopify VARIANT id
product_external_source: 'shopify',
quantity: Number((form.querySelector('[name="quantity"]') || {}).value || 1),
currency: window.Shopify && window.Shopify.currency
? window.Shopify.currency.active : undefined
});
}, true);
</script>
The name="id" field on a Shopify add-to-cart form is the variant
id, which is exactly what Route B stores in variant_external_id. Stitchwork
keeps both grains: the variant for ranking variants, and its parent product for rolling up.
Themes that add to cart over fetch rather than submitting the form need the
call on their own success path instead. If your theme dispatches a cart event, listen for
that; otherwise put the sw('track', …) next to the fetch. To send a SKU here
rather than an id, read it from /cart.js — its items carry
sku, product_id and variant_id.
No try needed, on this or anything else. If Stitchwork is down, the key is
wrong, or an extension blocked the request, the call returns quietly and the event is
dropped. Nothing is retried and nothing blocks the page. Set
window.swDebug = true before the tag loads to see failures in the console
while you are wiring this up.
Identify logged-in customers
In theme.liquid, inside a {% if customer %} guard:
{% if customer %}
<script>
sw('identify', {
email: {{ customer.email | json }},
name: {{ customer.name | json }},
source: 'shopify_account'
});
</script>
{% endif %}
This is the moment that pays for everything else. Identifying attaches this browser's entire prior anonymous trail to the customer record — every page view and cart add from before they logged in, retroactively, with no backfill step. Their recommendations are seeded from that history too.
Fire it on every page while they are logged in; repeat calls are cheap and the tag holds the identity in its own slot so a pending consent banner cannot evict it. The four routes from anonymous to known are covered in Customers & identity.
Purchase, and why checkout is different
Every order already arrives on its own through the connection, online and from the till. This section is for a store that is not connected.
Shopify does not run theme code on checkout or the thank-you page. Outside Shopify Plus
there is no checkout.liquid to edit, so the snippet above cannot see an order.
Purchases are captured with a custom pixel instead:
Settings → Customer events → Add custom pixel, then subscribe to
checkout_completed.
The pixel sandbox has no access to your __sw_v cookie, so an event fired from
there arrives without a visitor and cannot join the shopper's trail. Send purchases from
your backend instead, where you can be certain of them:
create the order server-to-server with the secret key.
That path is also what feeds revenue, average order value and bestseller ranking — the
purchase event only ever fed the timeline.
Subscribe to Shopify's orders/create webhook and call
POST /api/v1/orders/external from your handler, passing the shopper's
email. Orders are matched per workspace on email, so the sale lands on the right customer
even though checkout could not tell you which browser it came from. Shopify's own
Customer events documentation
covers the pixel API if you would rather start there.
Currency
If you sell in more than one currency, send currency on every event that
carries a price, and send the presentment currency — the one the shopper is
looking at — not your store's base currency:
// Liquid
{{ cart.currency.iso_code | json }}
// JavaScript
window.Shopify.currency.active
Stitchwork holds no exchange rates and has no base currency. Money is reported per currency and never totalled across currencies, so a multi-currency store sees a figure per currency rather than one number that would not be revenue in any of them.
An event that arrives without a readable currency stores nothing rather than assuming your usual one, and reports in its own unknown bucket. That is deliberate: a guess and a fact are indistinguishable once they are in the same total.
Consent
Nothing to do. The tag detects Shopify's Customer Privacy API and holds every event until it reports consent, so the banner you already run is the only one involved. Global Privacy Control is honoured as a hard floor regardless.
Events fired before consent arrives are queued in memory, and the visitor id is held in memory too — promoted to the cookie only once consent is granted, at which point the queue flushes in order. Nothing is lost and nothing is written early. If you run a second CMP alongside Shopify's, see the consent section.
Ranking your collection pages
Stitchwork can reorder a collection page you already render. Your page stays the source of truth for stock, filters and paging; the ranking only contributes an order, and if the call fails your own order stands.
The public recommendations endpoint takes external_ids, so you can pass the
Shopify product ids your tiles already carry:
const ids = Array.from(document.querySelectorAll('[data-product-id]'))
.map(el => el.dataset.productId);
const tok = (document.cookie.match(/(?:^|; )__sw_v=([^;]+)/) || [])[1] || '';
const res = await fetch('https://app.stitchwork.io/api/v1/public/recommendations'
+ '?key=pk_live_your_key'
+ '&visitor_token=' + encodeURIComponent(tok)
+ '&external_ids=' + encodeURIComponent(ids.join(',')));
In a Shopify theme, data-product-id="{{ product.id }}" on each tile is all the
setup that needs. The full recipe — including how to handle the truncation flag and tiles
Stitchwork has no signal for — is in
Recommendations & ranking. There is also a drop-in
<sw-recs> element if you want a panel rather than a reorder.
Checking it worked
Do these in order — each one depends on the one above it.
-
The tag is live. Load any page and check for a
__sw_vcookie on your domain. No cookie usually means consent has not been granted rather than a broken install — setwindow.swDebug = trueto see which. - Events are arriving. View a product, add it to the cart, then open that visitor in Stitchwork. Both events should be there within a second or two.
- Events resolved to products. This is the one people miss. An event can arrive perfectly and still be tied to no product, in which case rankings stay empty and nothing anywhere says why. Check that your cart adds name a product, not just a URL.
- Identity is stitching. Browse logged out, then log in. The earlier anonymous page views should appear on your customer record — if they do, the whole chain is working.
What usually goes wrong
| Symptom | Usual cause |
|---|---|
| Events arrive, no product attached | The SKU or id sent does not match anything in your catalogue. On Route B, check external_source is set to shopify on the imported rows — resolution is skipped entirely without it. |
| Product views resolve, cart adds don't | The product page sends a SKU and the cart form sends a variant id, and only one of the two is loaded. Load both, or send both. |
| Nothing at all, no errors | Consent has not been granted, so events are queued rather than sent. Silence is the designed behaviour here. |
| Cart adds missing on some themes | The theme adds to cart over fetch and never submits the form, so the submit listener never fires. Move the call to the theme's own success path. |
| Money totals look wrong | Events are being sent without currency, or with the store's base currency instead of the presentment one. |
| Logged-in shoppers stay anonymous | The identify call is inside a template that only renders on the account page. Put it in theme.liquid. |