Custom, headless and in-house
The second shape we run in production, and genuinely the more flexible of the two: you control your confirmation page and your emails, so both claim moments are open to you.
The full generic guide is Install the tag, and the mechanics are explained once in How the integration works. This page covers what is specific to a bespoke stack.
Where the tag goes
Section titled “Where the tag goes”Wherever your framework puts a third-party script: the base template, the document <head> or end of <body>, a Next.js <Script>, a tag manager container. It boots on DOMContentLoaded or immediately if the document is already past that, guards against double-inclusion, and re-evaluates on pushState / replaceState / popstate / hashchange, so a client-side routed basket works without a full page load.
If your basket lives at an unusual path, tell us and we set the patterns: */basket, */bag, */checkout/cart and locale prefixes like /en-gb/cart all work, and matching is on the pathname only (query and hash are stripped before matching and are never sent to us).
Reading the cart
Section titled “Reading the cart”Pick whichever costs you least.
datalayer is the usual choice when you already run GA4/GTM. The tag walks window.dataLayer newest-first and accepts, on each entry (or its ecommerce object):
| Read | From |
|---|---|
| Value | value, cartValue, cart_total, or a basketTotal string |
| Line items | items, or a basketProducts array (productId / item_id / sku / id, plus quantity / qty) |
| Currency | currency, currencyCode, or pageAttributes[0].currency on any entry |
Two behaviours that exist because of real installs:
- Late hydration is handled. A
dataLayer.pushis not a DOM mutation, so the tag wrapsdataLayer.pushand re-evaluates on it. Server-rendered pages that push an empty basket at parse time and the real basket afterwindow.loadare a supported case, not a bug. And the cart is re-read at selection time, so what gets attributed is the real basket, not the placeholder. The wrap is installed once, at boot, and only ifwindow.dataLayeralready exists at that moment: if your app creates or replacesdataLayerafter hydration, dispatchgwp:cartchangeafter each push instead. - The wrapper is transparent. Your own GTM tags see the push exactly as before; we call the original
pushand return its value.
window, for when you would rather publish one object than have us guess at shapes:
window.gwp = window.gwp || {};window.gwp.cart = { value: 129.99, // number, in your settlement currency currency: "GBP", items: [{ id: "SKU-1", qty: 2 }],};// Tell us when it changes (client-side cart updates, mini-cart edits):window.dispatchEvent(new Event("gwp:cartchange"));Missing currency defaults to GBP; missing items defaults to []; a non-numeric value makes the read fail.
dom takes a totalSelector whose text we parse, plus a currency. It is the last resort, and the same caveat as on Shopify applies: an unreadable total is "unknown", not zero, so minimums silently stop applying rather than the widget disappearing.
On your confirmation page we also try to read your order id, in this order: window.gwp.orderId, a configured orderIdSelector, a dataLayer transactionId / orderId / ecommerce.transaction_id, then an ?order= or ?orderId= query parameter. That id is a reporting join only: if it cannot be read, the gift is still claimable and still attributable.
If your checkout is on another subdomain
Section titled “If your checkout is on another subdomain”Common on in-house stacks: the basket is on www.example.com, the confirmation page on checkout.example.com. localStorage does not cross that hop, so the selection is also mirrored to a first-party cookie on the registrable domain (.example.com, probe-derived so multi-part public suffixes like .co.uk are handled), SameSite=Lax, 30 days. A selection made on www is readable on checkout.. This is proven in production, including a live order placed through a real checkout.
Two things must be true for it to work:
- Both hostnames must be registered with us. Browser POSTs are Origin-gated: we allow a registered domain and its subdomains, and return
403otherwise. One entry forexample.comcoverswww.andcheckout.. - The confirmation URL must be in your confirmation patterns. If checkout lands on
/checkout/complete, that path needs to be there. It is a config change, not a deploy.
The claim moment
Section titled “The claim moment”You have two, and you can use both.
(a) The on-page redemption card. Register your confirmation page as a confirmation page type and the tag renders a redemption card there for the persisted selection. Pressing Claim posts /v1/clickout and navigates to the claim URL. Give your confirmation page a mount point just like your basket: a slot selector, or a fallback position we can find an anchor for.
(b) Your own transactional email, via the platform-neutral hook. It fires on every platform, regardless of any Shopify-specific setting:
document.addEventListener("gwp:selection", (e) => { // e.detail.gifts = [{ title, advertiser, claimUrl, imageUrl }] // All strings. claimUrl / imageUrl may be "". // claimUrl is the absolute claim link carrying the gift's own click id — // use it verbatim, do not re-encode or re-serialise it. persistToMyCheckoutState(e.detail.gifts);});
// Or read the current state at any time:window.GWP.selection(); // → { gifts: [ … ] }The event fires on add, remove and replace made in the widget, plus the initial restore of an existing selection, plus the two revocations the widget performs on its own — the cart falling below the store minimum, and consent being withdrawn — which arrive as { gifts: [] } after it has unmounted and cleared its own stored state. Both the event and the accessor are scoped to pages where the widget actually mounted. On any other page, such as a checkout step whose URL is not in your registered patterns, the accessor exists but returns { gifts: [] } and no event fires, even though the selection is alive in the carrier cookie. So capture the payload on the basket page and carry it forward in your own state (an order property, a dataLayer push, a hidden field, your own API); do not poll for it at checkout-submit.
Then render it in your email exactly as the Shopify Liquid snippet does: show nothing when gifts is empty; per gift, title plus "from {advertiser}"; render the button only when claimUrl is non-empty and the image only when imageUrl is non-empty; HTML-escape everything. A fully worked JavaScript version is in the install guide.
GWP has no hook into your checkout. Carrying the payload into the order you create is the one piece of work on your side.
Custom-platform checklist
Section titled “Custom-platform checklist”- Tag on the basket page (and the confirmation page, if you use the on-page card)
- Basket and confirmation URL patterns agreed, including any locale prefix and any checkout subdomain path
- Every hostname the tag runs on registered with us, including staging and any local dev host (local development)
- Adapter chosen and the cart read verified in the console
- Order-id read verified on a real confirmation page
- Mount points confirmed on both page types
-
gwp:selectionwired into your order/email pipeline, captured on the basket page - CSP updated if you have one
Where next
Section titled “Where next”- Install the tag: snippet, page registration, adapters, verification steps, troubleshooting and CSP in full.
- The claim moment: the worked JavaScript version of the email path, including the
gwp:selectionpayload shape. - Shopify, the other proven platform, if you also run a Shopify storefront.
- 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.)