Shopify
Shopify is the platform we have taken end to end, so what follows describes an install we have done rather than one we expect to work. It is written in the order you would do it.
If you have not read How the integration works, read it first: the mechanics below (page detection, adapters, the click id, the gwp:selection hook) are explained there once and not repeated here. The generic, platform-agnostic install steps live in Install the tag.
1. Put the tag in theme.liquid
Section titled “1. Put the tag in theme.liquid”Online Store → Themes → Edit code → layout/theme.liquid, immediately before the closing </body> tag:
<script async src="https://cdn.gwpingenuity.com/w.js" data-site="YOUR_SITE_KEY"></script>That is a site-wide placement, and it is what we recommend on Shopify even if you do not use a cart drawer:
- A cart drawer opens with no URL change, on any page. A snippet pasted into
sections/main-cart.liquid(or a cart-only template) is not on the page when the drawer slides out, so a drawer install is impossible from there. - Site-wide costs you very little. The tag makes one
GET /v1/configcall per page, the server answers "not a basket page" for everything else, the tag mounts nothing and disarms its document-wide DOM observer. With the cart drawer enabled it keeps one deliberately cheap watcher (a MutationObserver scoped to your drawer element, plus a handful of passive cart-event listeners), because a drawer can open on any page. - If you have a real
/cartpage, no drawer, and prefer a narrower blast radius, a cart-template-only paste is supported. You lose the drawer, nothing else.
Via Google Tag Manager instead: a Custom HTML tag containing the same snippet, fired on all page views. Not a trigger scoped to your cart page. A cart-page trigger guarantees a dead drawer.
Before you go site-wide, we check your page-detection patterns with you. Basket detection is a glob match on the pathname (*/cart, */cart/*), with !*/collections/*, !*/products/*, !*/blogs/* and !*/pages/* excluded so a product handle called "bag" is never mistaken for a basket. New accounts get those anchored defaults. Older accounts may hold looser patterns from before the defaults were anchored, and a loose pattern plus a site-wide tag means the widget can appear on a collection page. It is a config change on our side, not a code change on yours. But it has to happen before the tag moves, not after.
What Shopify's thank-you page can and cannot do. It is part of Shopify's checkout and never evaluates theme.liquid, so the tag cannot load there and the on-page redemption card that other platforms use does not exist on Shopify. That is not a gap we are working around; it is the reason the order-email claim path in §5 exists.
2. Let the widget place itself, or point it at a slot
Section titled “2. Let the widget place itself, or point it at a slot”You do not have to add a mount point. With no selector configured, the tag injects its own container at one of three positions, whichever you pick:
| Position | Where it lands |
|---|---|
before_checkout | Immediately before the checkout CTA: matched by a[href*="checkout"], button[name*="checkout"], a checkout-ish submit input, or a bounded text scan for "checkout" / "pay now" / "place order" |
after_summary | After the first visible [data-order-summary], or an element whose class/id contains "summary" or "totals" |
top_of_basket | The top of main / [role="main"] / #main / #content, provided the first child there is ordinary in-flow content |
A new account starts at after_summary with the selector [data-gwp-slot]. That combination is deliberately forgiving: if you drop <div data-gwp-slot></div> into your cart template, the block renders exactly there with no configuration at all, and if you don't, we fall through to auto-injection. On a Shopify cart page we usually switch the position to before_checkout; it mounted correctly on a stock theme in our end-to-end rehearsal with no theme edit at all.
If you want the block somewhere else exactly, give us any CSS selector and we render inside that element. An explicit selector skips the visibility check deliberately: retailers point us at containers their own JavaScript fills later. If your theme is bespoke and likely to change, an element you own (<div data-gwp-slot></div>, or your own id) is the most durable choice: a selector you control cannot be broken by an agency deploy.
Every candidate anchor is checked for being genuinely painted before it is used: hidden, zero width, display:none, display:contents and inherited visibility:hidden all disqualify it. Dawn ships its cart drawer closed as visibility: hidden while keeping a full layout box, which is exactly the trap this check exists for.
3. The cart drawer
Section titled “3. The cart drawer”Off by default. Turning it on takes two CSS selectors, and about five minutes in a browser.
| Field | What it is | Evaluated as |
|---|---|---|
root | The element wrapping the whole drawer | document.querySelector(root) |
openWhen | What is true of that element only while the drawer is open | root.matches(openWhen) |
Finding them
Section titled “Finding them”-
Open your storefront, add an item, open the mini-cart the way a shopper would.
-
Right-click inside the open drawer → Inspect, then walk up the DOM until you reach the element wrapping the entire drawer. Usually a custom element:
cart-drawer,theme-drawer, or a<div class="drawer drawer--cart">. Tag plus#idwhere one exists. That isroot. -
With DevTools still on that element, close the drawer and watch what changes on it. An attribute appears or disappears (
open,aria-expanded="true"), or a class toggles (.active,.is-open). Written as a selector the element matches, that isopenWhen. -
Prove both directions in the console before you send them to us:
document.querySelectorAll(ROOT).length // must be exactly 1document.querySelector(ROOT).matches(OPEN_WHEN) // true open, false closedA hook that stays true when the drawer is closed mounts the widget into a drawer nobody can see. One that is never true means it never mounts.
Do not use "is it visible?" as the open signal. Shopify's Horizon sets display: contents on its drawer element, so it has no layout box open or closed. Every geometry test calls it invisible forever. This is precisely why the two selectors are explicit rather than inferred.
Recipes we have read off real theme source
Section titled “Recipes we have read off real theme source”| Theme | root | openWhen |
|---|---|---|
| Shopify Horizon 4.x | theme-drawer#cart-drawer | [open] |
| Shopify Dawn 15.x | cart-drawer | .active |
| Swanky / Focal lineage | cart-drawer#mini-cart | [open] |
None of that is contractual. On a bespoke or agency-maintained theme the markup can change in any deploy, silently, and the first symptom is the widget quietly not appearing. There is no error to catch, because "no element matched" looks identical to "the drawer is closed". If you have an agency relationship, ask them for a stable hook (a <div id="gwp-drawer"> inside the mini-cart template that they undertake to keep) and give us that plus the theme's own open-state attribute. Otherwise, re-check the selectors after a theme deploy.
What we accept
Section titled “What we accept”Both values are stored as free text with a sanity clamp, not a full CSS parser: 200 characters or fewer, at most 5 comma-separated selectors, and rejected if they contain <, {, }, ; or control characters, or have unbalanced [] / (). (> and + are valid combinators and are fine.) Enabling the drawer with either field empty is refused. The widget wraps every call anyway, so an invalid selector that somehow gets through is treated as "no drawer", never as an exception on your page.
How it behaves once it is on
Section titled “How it behaves once it is on”- Auto-injection inside the drawer is fixed, deliberately: always the top of the scrollable items region, above the line items, scrolling away with them, leaving your sticky footer (subtotal, checkout CTA, Shopify's accelerated-checkout block) exactly as your theme built it. The
fallbackPositionfield is inert for this surface. We measured the alternatives on Dawn 15.x and Horizon 4.x: anything else lands between the subtotal and the Checkout button and pushes Checkout below the fold on a phone. If you need the block somewhere specific, give us a selector authored for the drawer (placement.drawer.selector) and we render inside that element verbatim. A<div id="gwp-drawer">your theme owns is the durable choice on a bespoke theme. - A cart-page URL always wins. If a URL resolves to a basket or confirmation page, that mount happens and the drawer does not. There is never more than one gift block on screen. On Dawn with
cart_type: drawerthe drawer markup is rendered on/carttoo, so a shopper who opens the drawer while standing on/cartsees the block on the page behind it, not in the drawer. That is the intended trade. - Re-injection is automatic. Dawn and several bespoke themes replace the drawer's inner HTML on every cart update. Dawn re-fetches the drawer section and swaps the node asynchronously with no DOM event at all. The widget watches the drawer element (attributes, children and subtree, scoped to that one element, not the document) plus
shopify:cart:lines-update, Horizon'stheme-drawer:open/openable-element:open, Dawn'scart-updatePubSub topic and thecart:refresh/cart:updated/cart:updatelineage, and re-injects itself without re-counting the impression. - Frequency caps are counted per surface. The drawer and the cart page keep independent session/day counters, so a cap of 3 permits up to 3 drawer views and 3 cart-page views. If you tuned that number when the tag was cart-page only, re-tune it.
- Verify on the storefront, not in the dashboard. Your drawer is on your DOM; we cannot match-count a selector we cannot see. Open the store, open the drawer, confirm the block renders. Once traffic flows, install health reports drawer coverage as its own line ("Detected in your cart drawer"). A drawer-only install never reads as full cart-page coverage.
4. Reading the cart
Section titled “4. Reading the cart”Shopify themes expose no standard cart global, so the proven adapter here is dom: we read one element's text and parse a number out of it.
cartAdapter: { type: "dom", config: { totalSelector: "<your selector>", currency: "GBP" } }-
The text is stripped to digits and dots, so
£1,234.50parses correctly,1.234,50does not, and something likeFrom £12needs a moment's attention. Send us the selector and we verify the parse with you. -
The read is document-scoped, not scoped to the drawer. On a drawer-first store, pick an element that carries the current total wherever the drawer can open. The drawer's own subtotal node usually qualifies, because the drawer markup is present site-wide. Check it in the console on a product page, not just on
/cart:document.querySelector(TOTAL_SELECTOR)?.textContent -
A read that fails is "unknown", not "£0". The widget does not go dark: it renders, and the store minimum and any per-offer minimum simply cannot be applied. So a broken selector does not look broken: it looks like gifts being offered below your threshold. Verify the selector; do not assume silence means it works.
There is a second option if your theme team prefers it: publish window.gwp.cart = { value, currency, items: [{ id, qty }] } from your own code and we switch the adapter to window. The tag reads that global and nothing else. Be aware that a value rendered once from Liquid goes stale the moment an AJAX cart update changes the total without a page load, so if you take this route, update the global on your own cart-update event and dispatch window.dispatchEvent(new Event("gwp:cartchange")) so we re-read. We have not yet shipped this configuration on a Shopify store. The dom adapter is what is proven there.
5. The claim moment: your order-confirmation email
Section titled “5. The claim moment: your order-confirmation email”GWP sends nothing. Your store's own order-confirmation email carries a claim block for each gift the shopper picked. That is deliberate: it is your email, your sending domain, your lawful basis, your deliverability.
It works in two halves.
Half one — the widget stages the selection on the cart
Section titled “Half one — the widget stages the selection on the cart”When the channel is switched on for your account, every selection change writes Shopify cart attributes via your own storefront's POST /cart/update.js, on your origin, with the shopper's cart cookie. Flat numbered keys, because Liquid cannot parse JSON. Every value is a string.
| Attribute | Value |
|---|---|
gwp_gift_count | "0"–"5", how many gifts are selected, as a string |
gwp_gift_{i}_title | Gift title as rendered on the card (i is 1-based) |
gwp_gift_{i}_advertiser | Advertiser name as rendered |
gwp_gift_{i}_claim_url | Absolute claim URL carrying that gift's own click id, byte-identical to the on-page claim link. May be "" |
gwp_gift_{i}_image | Absolute gift image URL, or "" |
Properties of that write worth knowing before your platform team asks:
- Attributes only. No line items, no quantities, no prices, no discounts, no shipping. The write cannot change what a shopper is charged.
- Slots 1–5 are always written. Unused slots are blanked in the same request, so a selection that shrinks from four gifts to two can never leave a live claim link on key 3 for a gift the shopper removed.
- Revocation clears them. The selection can be dropped in several ways: the cart falls below your minimum, the selection expires, another tab removes it, consent is withdrawn, or you switch the channel off. Whichever happens, the next basket view issues one clearing write (
gwp_gift_count: "0", slots 1–5 blanked). - It is scoped to the cart it was staged on. When an order completes, Shopify mints a fresh cart; the widget notices the cart token changed, consumes the selection and clears the stamp, so a repeat order inside the 30-day selection window cannot re-advertise the same gift in a second email.
- Basket pages only. A write on a confirmation page would stamp the shopper's next order.
- Fail-soft, absolutely, and bounded. A failed write never surfaces on your page and never blocks the selection UI. Writes are serialised (one in flight, newest-wins), deduplicated against the last confirmed payload, capped at 20 per page lifetime with a small reserve kept aside for clearing writes, and suspended if an identical payload fails three times in a row. So a theme that re-renders on every
/cart/*.jsresponse cannot turn this into a write loop against your origin. A pending write is flushed withkeepaliveonpagehide, because the shopper clicking Checkout straight after choosing a gift is the case that matters most. - It survives accelerated checkout. The data rides your cart server-side, not the shopper's browser, so a checkout that completes on another origin (Shop Pay and friends) still produces an order carrying the attributes.
Half two — you paste one Liquid block
Section titled “Half two — you paste one Liquid block”Shopify Admin → Settings → Notifications → Customer notifications → Order confirmation → Edit code, wherever in the email body you want the gift block to appear.
It renders nothing when no gift was selected, so orders without a gift are byte-identical to before. That makes it safe to paste ahead of any traffic.
{%- comment -%} GWP gift claim block — Shopify Order confirmation email (Customer notifications). Paste into: Shopify Admin → Settings → Notifications → Customer notifications → Order confirmation → Edit code, wherever the gift block should appear in the email body. Renders nothing when no GWP gift was selected — zero-gift orders are unchanged.
Reads the gwp_* note attributes the GWP widget stages on the cart before checkout: gwp_gift_count ("0".."5"), then per gift i (1-based) gwp_gift_{i}_title / _advertiser / _claim_url / _image. All values are strings; _claim_url and _image may be "" (the card renders without a button / image). Email-client-safe by construction: table layout, inline styles only, no external CSS or fonts.{%- endcomment -%}{%- assign gwp_count = attributes.gwp_gift_count | plus: 0 -%}{%- if gwp_count > 5 -%}{%- assign gwp_count = 5 -%}{%- endif -%}{%- if gwp_count > 0 -%}<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:520px;margin:24px 0;border-collapse:separate;"> <tr> <td style="padding:0 0 6px;font-family:-apple-system,'Segoe UI',Helvetica,Arial,sans-serif;font-size:16px;line-height:1.4;font-weight:700;color:#111827;"> {%- if gwp_count == 1 %}Your free gift{% else %}Your free gifts{% endif -%} </td> </tr> {%- for gwp_i in (1..gwp_count) -%} {%- capture gwp_key -%}gwp_gift_{{ gwp_i }}_title{%- endcapture -%} {%- assign gwp_gift_title = attributes[gwp_key] -%} {%- capture gwp_key -%}gwp_gift_{{ gwp_i }}_advertiser{%- endcapture -%} {%- assign gwp_gift_advertiser = attributes[gwp_key] -%} {%- capture gwp_key -%}gwp_gift_{{ gwp_i }}_claim_url{%- endcapture -%} {%- assign gwp_gift_claim_url = attributes[gwp_key] -%} {%- capture gwp_key -%}gwp_gift_{{ gwp_i }}_image{%- endcapture -%} {%- assign gwp_gift_image = attributes[gwp_key] -%} {%- if gwp_gift_title != blank -%} <tr> <td style="padding:6px 0;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;border:1px solid #e5e7eb;border-radius:10px;background-color:#ffffff;border-collapse:separate;"> <tr> {%- if gwp_gift_image != blank -%} <td width="56" valign="top" style="padding:14px 0 14px 14px;"> <img src="{{ gwp_gift_image | escape }}" width="56" height="56" alt="{{ gwp_gift_title | escape }}" style="display:block;width:56px;height:56px;border:0;border-radius:8px;outline:none;" /> </td> {%- endif -%} <td valign="top" style="padding:14px;font-family:-apple-system,'Segoe UI',Helvetica,Arial,sans-serif;"> <div style="font-size:14px;line-height:1.45;font-weight:700;color:#111827;">{{ gwp_gift_title | escape }}</div> {%- if gwp_gift_advertiser != blank -%} <div style="font-size:12.5px;line-height:1.45;color:#6b7280;padding-top:2px;">from {{ gwp_gift_advertiser | escape }}</div> {%- endif -%} {%- if gwp_gift_claim_url != blank -%} <table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;margin-top:10px;"> <tr> <td style="border-radius:8px;background-color:#1668E3;padding:9px 18px;font-family:-apple-system,'Segoe UI',Helvetica,Arial,sans-serif;font-size:13px;line-height:1;font-weight:700;text-align:center;"> <a href="{{ gwp_gift_claim_url | escape }}" target="_blank" style="display:inline-block;font-family:-apple-system,'Segoe UI',Helvetica,Arial,sans-serif;font-size:13px;line-height:1;font-weight:700;color:#ffffff;text-decoration:none;background-color:#1668E3;">Claim your gift</a> </td> </tr> </table> {%- endif -%} </td> </tr> </table> </td> </tr> {%- endif -%} {%- endfor -%}</table>{%- endif -%}Notes for whoever edits the template:
- The block is deliberately email-client-safe: table layout, inline styles, no external CSS, no webfonts, no
<style>block to be stripped. - Everything is
| escaped, including the claim URL and the image URL. - It caps at 5 gifts regardless of what the attribute says, and skips any slot whose title is blank.
- A gift with no claim URL renders as a card without a button rather than a broken link; a gift with no image renders without the image cell.
- Restyle it freely: the only load-bearing parts are the attribute names, the
gwp_gift_countloop and{{ gwp_gift_claim_url }}on the anchor.
Verifying it — and the one thing we cannot see for you
Section titled “Verifying it — and the one thing we cannot see for you”Claim links in the email are byte-identical to on-page claim links by design, so no signal ever tells us your snippet is live. A green install-verification badge covers the tag; it does not cover the email. Test it yourself:
- Add items above your minimum, pick a gift, check out (a test gateway is fine).
- Open the confirmation email. You should see one card per gift: image where the offer has one, title, "from {advertiser}", and a Claim your gift button.
- Place a second order without picking a gift and confirm that email is unchanged.
Shopify's notification Preview will not show it. Preview renders a sample order that has no gwp_* attributes, so the block correctly renders nothing there. A real test order is the only true test.
6. Consent
Section titled “6. Consent”Shopify's Customer Privacy API is in the detector chain, ahead of the generic Google Consent Mode read, so any Shopify consent app that writes to it is understood. The tag also listens for visitorConsentCollected and consentTrackingApiLoaded on document (both are dispatched non-bubbling, which is why a window listener would miss them) and re-evaluates when the shopper answers the banner. So an Accept renders the widget without a reload, and a later Reject unmounts it and erases every gwp_* key.
Three postures are available on your account: auto, strict and off.
- Under
autoandstrict, an explicit denial and an unanswered banner both mean nothing is rendered, nothing is stored and nothing is sent.strictadditionally requires an explicit grant. - Under
offthe tag does not read a CMP at all: it treats consent as granted, because you have told us you gate it upstream (typically by not loading the tag until you have permission).
7. CSP
Section titled “7. CSP”Many Shopify storefronts send no restrictive CSP at all and need no change. If yours does:
| Directive | Value | Why |
|---|---|---|
script-src (or script-src-elem) | https://cdn.gwpingenuity.com | Loads w.js |
connect-src | https://cdn.gwpingenuity.com | The /v1/* calls |
connect-src | 'self' | Only if the order-email channel is on: the tag calls your own /cart.js and /cart/update.js |
img-src | https://cdn.gwpingenuity.com | Gift and brand images. Omit it and you lose nothing hard: the tiles fall back to a monogram |
One hostname covers the script, the API and the images, because the tag derives its API base from the origin it was served from.
8. Shopify checklist
Section titled “8. Shopify checklist”- Tag in
theme.liquidbefore</body>(or a GTM tag firing on all pages) - Basket URL patterns confirmed with us before going site-wide
-
totalSelectoragreed and verified in the console, on a non-cart page too - Placement confirmed on the cart page (or a
<div data-gwp-slot></div>you own) - Drawer
root+openWhenverified in the console both open and closed - Order-email channel switched on, Liquid block pasted into the Order confirmation notification
- Test order with a gift: email shows the card, button link works
- Test order without a gift: email unchanged
- Frequency caps re-tuned for per-surface counting if you use the drawer
Where next
Section titled “Where next”- Custom, headless and in-house: the other shape we run in production, and the platform-neutral
gwp:selectionhook that carries a selection into any email system. - Install the tag, the generic guide: page registration, all three adapters, verification steps, troubleshooting and CSP in full.
- Platform guides: where every platform stands, including the four we have not installed.
- Ask us something. Email
support@gwpingenuity.comwith[INTEGRATION]in the subject. (/support is the shopper help page, not developer support.)