Stitchwork Docs

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.

Read-only, both of them

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

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

  1. Open Shopify's Dev Dashboard, signed in as someone in the organisation that owns the store, and create an app called Stitchwork.
  2. Give it these Admin API scopes: read_orders, read_customers, read_products, read_locations and read_all_orders. Without the last one Shopify only shows the last 60 days of orders, so older history cannot be brought in.
  3. 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.
  4. Release that version of the app and install it on the store.
  5. 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

  1. 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.
  2. By email address, including any other address already on a client's record (see Customers & identity).
  3. 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.
  4. 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.

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

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.

Connected your store? Skip to installing the tag

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

Put it in the theme, not in Custom Pixels

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.

ColumnHolds
external_sourceshopify. Required — without it nothing resolves, which is what stops these ids being confused with anyone else's.
external_idThe Shopify product id. Alias: shopify_product_id.
variant_external_idThe 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
Shopify's own CSV export does not contain the ids

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

These calls never throw

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

Connected your store? Nothing to do here

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.

A Shopify webhook is the reliable trigger

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.

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.

  1. The tag is live. Load any page and check for a __sw_v cookie on your domain. No cookie usually means consent has not been granted rather than a broken install — set window.swDebug = true to see which.
  2. 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.
  3. 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.
  4. 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

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