How the integration works
One page, read once. Everything below is true on every platform; the install guide and the conversion API build on it rather than repeat it.
The moving parts
Section titled “The moving parts”| Part | What it is | Who owns it |
|---|---|---|
The tag (w.js) | One async script on your storefront | You paste it; we ship it |
| Config | Which pages are baskets, where the block renders, how to read the cart, branding, caps, which offers are live | Held on your GWP account, fetched at runtime |
| Cart adapter | The one read the tag makes of your basket total | You choose the type; we configure it |
| Selection | The gift the shopper picked, held in first-party storage for 30 days | The shopper's browser |
Click id (clid) | The opaque token that ties selection → claim → conversion | Minted server-side by us |
| Claim moment | Where the shopper actually gets the gift link | Confirmation page, your order email, or your own systems |
| Postback | The advertiser telling us the gift converted | The advertiser's server |
1. The tag
Section titled “1. The tag”<script async src="https://cdn.gwpingenuity.com/w.js" data-site="YOUR_SITE_KEY"></script>- Self-contained. A single IIFE bundle with no external dependencies and no dynamic imports. It does not fetch a framework, a stylesheet or a font.
- Async and non-blocking. It boots on
DOMContentLoaded(or immediately, if the document is already past that), and every entry point is wrapped so a failure can never surface on your page. - Boots once. A global flag guards against a double-inclusion (via a tag manager and the theme, say) booting two widgets.
data-siteis required. Without it (or without an API base) the tag logs nothing and stops. The site key is public.- The API base is derived from the script's own
srcorigin. The tag calls/v1/*and loads images from the same host it was served from, so one host entry (https://cdn.gwpingenuity.com) coversscript-src,connect-srcandimg-src. - It publishes exactly one public API on
window.GWP. That isselection(), a read accessor documented in §5, plus a boolean boot flag,window.__gwp_loaded__. A CI gate boots the built bundle and fails ifwindow.GWPever grows another key, and the widget's render tree lives in a closed shadow root, so page scripts cannot reach in and cannot be reached by our CSS.
Debugging: set window.GWP_DEBUG = true before the tag boots and it logs each decision it makes: site key, config, page type, slot, cart read, eligibility. It is silent otherwise.
2. How the tag knows a page is a basket page
Section titled “2. How the tag knows a page is a basket page”The tag does not guess. On every page it makes one call:
GET /v1/config?site=YOUR_SITE_KEY&url=<origin+pathname>and the server decides what that page is, by matching the pathname against the URL patterns held on your account:
- A pattern containing
*is compiled to a regex anchored to the whole path:*/cart,*/cart/*,*/basket,*/cart.php,*/order-confirmation,*/checkout/success,*/thank-you. So*/cartmatches/cart,/en-gb/cartand/checkout/cart, but not/collections/cart-accessories. - A pattern with no
*at all is treated as a plain substring of the path./carton its own would match/collections/cart-accessoriestoo. Every pattern should therefore carry a leading*/. (This is why the shipped defaults are all anchored globs.) ?is not a glob metacharacter and is not escaped either, so a pattern must never contain one. Query strings are stripped from the path before matching, so there is never a reason to write one.- A pattern starting with
!is an exclusion and always wins. The defaults exclude*/collections/*,*/products/*,*/blogs/*and*/pages/*so a product handle called "bag" is never mistaken for a basket. - The response carries
pageType: "basket" | "confirmation", or nothing at all. No page type, no render: the tag mounts nothing and goes quiet.
Two consequences worth internalising:
- Query strings and fragments are stripped before matching, and are never sent. The tag reports
origin + pathnameand nothing else, on every call. Storefront URLs carry?email=,?discount=and order tokens, and none of that leaves the browser. - Patterns are configuration, not code. If your basket lives somewhere unusual, the fix is a config change, not a redeploy.
The cart drawer
Section titled “The cart drawer”A slide-out mini-cart opens with no URL change, so it cannot be derived from the URL at all. It is declared with two CSS selectors instead:
rootis the drawer element (document.querySelector(root)),openWhenis evaluated asroot.matches(openWhen)to tell open from closed (e.g.[open],.active).
Both are optional and off by default. If a URL already resolves to a basket or confirmation page, that mount always wins. There is never more than one gift block on screen.
Staying awake
Section titled “Staying awake”Storefronts are not static documents, so the tag re-evaluates (debounced, ~150 ms). Two sets of signals wake it:
- Always on, every store: DOM mutations,
pushState/replaceState/popstate/hashchange,window.load, adataLayer.push, CMP consent decision events, and agwp:cartchangeevent you can dispatch yourself. - Drawer-enabled stores only: when a cart drawer is configured and the current page is neither a basket nor a confirmation page (i.e. the drawer is the only surface that could open), the tag additionally watches a set of theme cart events (
shopify:cart:lines-update,theme-drawer:open,openable-element:open,cart:refresh/cart:updated/cart:update, and Dawn'scart-updatePubSub topic) plus a MutationObserver scoped to the drawer element. On a real basket page those listeners are removed again.
If you are on a custom platform, gwp:cartchange is the one signal that works everywhere. Do not rely on dispatching a theme cart event. Re-evaluation is idempotent: it brings the page to the right state, it does not stack widgets.
Config responses may be edge-cached for up to a minute, so a config change can take up to about that long to reach shoppers.
3. The cart adapter
Section titled “3. The cart adapter”The tag needs exactly one number (the current basket total) plus, ideally, the line items. It never writes to the cart to get it. You pick one of three adapters:
| Type | What it reads | Configure |
|---|---|---|
datalayer | window.dataLayer, walked newest-first: ecommerce.value, cartValue, cart_total, plus a currency from currency / currencyCode | Nothing. Shape detection is built in |
window | window.gwp.cart = { value, currency, items: [{ id, qty }] } | You publish the global |
dom | document.querySelector(totalSelector).textContent, parsed to a number | totalSelector, and a currency |
Rules that hold for all three:
- Read-only, and fail-soft. Every adapter is wrapped: a missing global, a selector that matches nothing, an unparseable price string all return
null. They never throw onto your page. - A failed read means "unknown", and the widget fails open. The store-level minimum-cart gate and each offer's own minimum are skipped when the total cannot be read, so the gift block can render even though the basket value is unknown. This is deliberate: the alternative (treating an unreadable cart as £0) would silently suppress every offer on a store whose selector broke. The practical consequence is the one to plan for: a broken cart read does not look broken. It looks like gifts being offered below your threshold. The minimum is only ever enforced when the adapter returns a number, so verify the read (install guide § verify) rather than assuming silence means it works.
- Where the number is used: the store-level minimum cart value, each offer's own minimum, and a cart snapshot recorded with the selection. It never affects what the shopper is charged.
On a confirmation page the tag also tries to read your order id from window.gwp.orderId, a configured orderIdSelector, a dataLayer transactionId / orderId / ecommerce.transaction_id, or an ?order= / ?orderId= query parameter. That id is used to join our records to yours for reporting. It is not part of attribution: if it can't be read, the gift is still claimable and still attributable.
4. Gift selection
Section titled “4. Gift selection”When the page qualifies (right page type, offers available, cart above the minimum, frequency caps not spent, consent satisfied), the tag renders the gift block into the configured slot (an explicit CSS selector if you gave one, otherwise a container it injects at a configured fallback position).
The shopper picks a gift. That does one thing on the wire:
POST /v1/select → { "ok": true, "clid": "clk_…" }and one thing in the browser: the selection is persisted for 30 days in first-party storage. That is localStorage, mirrored to a first-party cookie on the registrable domain (SameSite=Lax) so it survives the hop from www. to a checkout subdomain. Keys used: gwp_sel (the selection), gwp_ses (a session reference), gwp_shows (frequency-cap counters), gwp_attrs (a marker for the Shopify email path), and a transient __gwp_probe cookie used once to find the registrable domain and immediately deleted.
Details that matter when you are reasoning about behaviour:
- Nothing is stored before consent is satisfied. The config fetch happens first (it carries only your site key and
origin + pathname), but no identifier is written and no shopper event is sent until the consent gate passes. Understrictmode the tag fails closed without an explicit grant; underautoit renders unless a CMP says no or the banner is unanswered. Underoffthe tag reads no CMP at all and treats consent as granted, because you have told us you gate it upstream. Underautoandstrict, if consent is later withdrawn, the widget unmounts and everygwp_*key is erased. - Multiple gifts are supported (1–5 per order, 1 by default).
- Frequency caps are counted per surface, so the cart drawer and the cart page keep independent counters.
- The server re-checks everything. A crafted
/v1/selectfor an offer your store isn't assigned, a paused offer, or a cart below the offer's minimum is refused. - Browser POSTs are origin-gated.
/v1/select,/v1/events,/v1/clickoutand/v1/associateall check theOriginheader against your registered domains and return403otherwise.
5. The claim moment
Section titled “5. The claim moment”The claim moment is where the shopper is handed the link that redeems the gift. It is the single most important thing to get right in an install, because it is where the shopper actually receives what they were promised — and it is the one part that differs by platform. There are three concrete paths, and a store can use more than one.
(a) The confirmation page
Section titled “(a) The confirmation page”If your confirmation page URL is registered as a confirmation page type, the tag renders a redemption card there for the persisted selection. Pressing Claim does exactly two things:
POST /v1/clickout(recorded, fail-soft),window.location.href = <claim URL>.
The tag also posts /v1/associate to link your order id to each click id. This is for reporting; it does not affect whether the conversion is attributed.
(b) Your order-confirmation email (Shopify)
Section titled “(b) Your order-confirmation email (Shopify)”GWP does not send this email. You do. On Shopify stores with the order-email channel enabled, the tag stages the selection on the cart as attributes, and your notification template renders the claim block:
| Attribute | Value |
|---|---|
gwp_gift_count | "0"–"5", as a string |
gwp_gift_{i}_title | Gift title as shown on the card (i is 1-based) |
gwp_gift_{i}_advertiser | Advertiser name |
gwp_gift_{i}_claim_url | Absolute claim URL carrying that gift's click id, byte-identical to the on-page link. May be "" |
gwp_gift_{i}_image | Absolute image URL, or "" |
The write is a POST /cart/update.js on your own origin, with the shopper's cart cookie, carrying an attributes object and nothing else. Slots 1..5 are always written (unused ones blanked in the same request), so a shrunk selection can never leave a stale claim link behind, and a revoked selection triggers a clearing write (gwp_gift_count: "0"). The whole path is fail-soft: a failed write never surfaces on your page and never blocks the selection. The Liquid snippet that reads these attributes is reproduced in full in the Shopify platform guide.
(c) The generic hook: every platform
Section titled “(c) The generic hook: every platform”Not on Shopify? Use 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 }] // claimUrl / imageUrl may be "" — guard before rendering a button.});
// Or read the current state at any time:window.GWP.selection(); // → { gifts: [ … ] }The event fires on every selection change made in the widget (add, remove, replace), plus the initial restore of an existing selection, and the two revocations the widget performs on its own: the cart falling below the store minimum, and consent being withdrawn. Those two arrive as { gifts: [] } once the widget has unmounted and cleared its own stored state, so the accessor, the event and (on Shopify) the cleared cart attributes all say the same thing. Both the event and the accessor are scoped to pages where the widget actually mounted: on any other page the accessor returns { gifts: [] } and no event fires, even though a selection is persisted. So read the payload on the basket page and carry it forward in your own state (an order property, a dataLayer push, your own API).
The payload fields map one-to-one onto the gwp_gift_{i}_* cart attributes in §5(b), so an email template built from either source renders the same thing.
6. The click id
Section titled “6. The click id”clk_ + 32 lower-case hex characterse.g. clk_4f8c1d2b3a90f1e7c6d5b4a39281706f (36 characters total)- Minted server-side, one per gift selection, at
POST /v1/select. The browser never invents one. - Opaque. It encodes nothing about the shopper, the cart or the price.
- Carried on the claim URL. By default it is appended as
gwp_clid:
https://advertiser.example/offer?gwp_clid=clk_4f8c1d2b3a90f1e7c6d5b4a39281706f- When an offer is served through an affiliate network, it rides that network's own sub-id parameter instead (
clickref,sid,u1,subId1and the like), appended raw so an already-encoded deep link is not re-serialised. - One composition, everywhere. The on-page claim button, the Shopify cart attribute and any email you build from the generic hook all carry the identical URL for a given gift.
- HTTPS only. A redemption URL that is not
https:, that doesn't parse, or that points somewhere the live config doesn't vouch for produces no link at all rather than a link somewhere unexpected.
7. How commission is attributed
Section titled “7. How commission is attributed”selection claim conversionPOST /v1/select → claim URL carries → advertiser POSTs the clid backmints the clid ?gwp_clid=clk_… POST /v1/conversionThe advertiser's server posts the click id back with their own order id:
POST /v1/conversion
{ "advertiser_key": "…", "secret": "…", "gwp_clid": "clk_4f8c1d2b3a90f1e7c6d5b4a39281706f", "advertiser_order_id": "ORDER-10231", "order_value": 42.00, "currency": "GBP"}What happens to it, in order:
- Authentication. The secret (body field or
X-GWP-Secretheader) is checked against the advertiser's stored hash. Server-side only, never in browser code. Bad secret →401. - Known click id. The clid must exist and belong to that advertiser → otherwise
rejected_unknown_clid. - Idempotency.
(clid, advertiser_order_id)is the dedupe key. A repeat isrejected_replayand returnsok: true. A retry is a safe no-op, not a double credit. - Attribution window. Measured from the moment the shopper selected the gift, not from the click, against the window agreed for that advertiser. Past it →
rejected_outside_window. - Validation. An order id is required; a percent-model offer needs a positive
order_value;currency, if sent, must be an allow-listed currency and match the retailer's settlement currency (there is no FX conversion). - Credit. The commission is computed and split between the retailer and GWP. A conversion may be held before it becomes payable: briefly, for a settlement hold, or longer if it is set aside for review; a reversal inside that period nets it out before anyone is paid, which is why there is no clawback on the retailer side. Reversals go to
/v1/reversaland are themselves idempotent.
Two things attribution does not depend on: your order id (that is a reporting join), and any A/B variant the shopper happened to see (an analytics dimension only — the split is identical either way).
Full parameter and error tables are in the conversion API reference.
8. What the tag deliberately does not do
Section titled “8. What the tag deliberately does not do”This is the list to hand your security or platform reviewer.
It never alters items or prices. The only thing it adds to your page is its own container element and the shadow host inside it, both removed on unmount. The only write it makes to your cart is on Shopify stores with the order-email channel switched on, and that write carries a gwp_* attributes object and nothing else: no line items, no quantities, no discounts, no shipping. It never touches checkout.
It patches two host-page functions, and discloses both. The tag wraps history.pushState / history.replaceState to notice SPA navigation, and dataLayer.push to notice late cart hydration. Both wrappers call the original first and return its value, and neither inspects the arguments. That is the whole of it: no other host global, prototype or built-in is modified.
It is fail-silent. Every boot path, evaluation, DOM query, storage access, cart read and network call is wrapped. A merchant-authored CSS selector that is invalid throws a DOMException inside our try, never on your page. A rate-limited or failed config fetch leaves the current state alone and retries later rather than tearing a live widget down. Errors are reported to our own /v1/error endpoint (message, stack, version and origin + pathname only) and never to your console unless you turn on GWP_DEBUG.
It loads nothing from third parties. The bundle has no runtime dependencies and pulls in no CDN script, stylesheet, webfont, iframe or tracking pixel. Its entire network surface is:
| Request | Where it goes |
|---|---|
GET /v1/config, POST /v1/events, /v1/select, /v1/clickout, /v1/associate, /v1/error, /v1/retention-optin | The origin the tag itself was served from (cdn.gwpingenuity.com) |
| Gift and brand images | The same origin. Image paths from config must be single-slash origin-relative; data:, http(s):// and protocol-relative values are refused and the tile falls back to a monogram |
GET /cart.js, POST /cart/update.js | Your own origin, Shopify order-email path only |
Fonts come from a font-family name in your branding config. No font file is ever fetched.
It sends no third-party cookies and no page PII. Storage is first-party only (localStorage plus a first-party cookie on your registrable domain). Reported URLs are origin + pathname; query strings and fragments are dropped in the browser before anything is sent, and again at ingest.
It renders nothing where it shouldn't. Every one of these is "render nothing": no page type, no offers, cart below the minimum, frequency cap spent, consent denied or unanswered, no safe anchor to mount into. The ones that occur before a mount leave the DOM exactly as they found it.
Switching it off, and who holds which lever
Section titled “Switching it off, and who holds which lever”Reviewers ask this. There are two levers and they are not both yours. Do not take a central kill switch to a change board as though it were a control you operate.
| Lever | Whose | How fast | What it does |
|---|---|---|---|
Remove the tag: delete the <script> from your template, or pause the tag-manager tag | Yours. No involvement from us at all | As fast as your own deploy or tag-manager publish | Total and final: nothing of ours executes. This is your real emergency control, and it is the one to write into a change record |
| Narrow the config: page-detection patterns, eligibility rules, the cart-drawer toggle, consent mode | Yours, from your dashboard (or ask us) | Next config fetch, within about 60 seconds | Changes where and when the block may render. Not an off-switch, but it is how you take it off one surface without a deploy |
| The kill switch: per store, and network-wide | Ours. GWP staff only. It is not on the retailer dashboard, and there is no self-serve equivalent | Next config fetch, within about 60 seconds, with no redeploy on your side | The config endpoint starts returning null, so the tag mounts nothing and disarms its document-wide observer. Every flip is audit-logged |
To ask us to use ours: email support@gwpingenuity.com with [INTEGRATION] in the subject and your site key. Be aware of what that is and is not: a monitored mailbox we aim to answer within two working days, not a 24/7 incident line, and we do not publish an emergency-response SLA today. If your risk assessment needs a same-hour off-switch, removing the tag is the one that meets it. Make sure whoever owns your storefront deploys knows that, and treat any faster action from us as a courtesy rather than a commitment.
What is true in all three cases: a shopper mid-page keeps whatever is already on screen. None of these levers reach into a page that has already rendered; they take effect on the next config fetch, which happens on the next page or route change.
Versioning, pinning and SRI
Section titled “Versioning, pinning and SRI”The short version: there is no version-pinned URL and no Subresource Integrity hash today. Here is exactly what that means and what we offer instead.
/w.jsis a single, unversioned route. A new release replaces the bytes at the same URL. There is no/w/1.4.2.jsto pin to and we do not offer one.- Updates reach the edge in minutes, and returning browsers within hours. We purge the CDN at publication and the shared edge TTL is 300 seconds (
s-maxage=300), so a shopper arriving fresh gets the new bundle almost immediately. A browser that already holds a copy is the slower case: themax-ageserved to browsers is not always the 300 seconds our Worker sets, because Cloudflare may rewrite it to14400(4 hours). Plan for up to about four hours for every returning shopper to be on a new build, not five minutes. There is still no update prompt and nothing to redeploy on your side. - You can add an
integrityattribute, and you should know what happens if you do. It will be valid until our next release and will then stop the bundle loading. That failure is safe for your storefront (a blocked script is exactly the fail-silent case the tag is built around, your page is unaffected and nothing throws), but it is silent: you simply lose the gift block, with no error anyone is watching for, until someone updates the hash. We do not publish release notifications, so nothing would tell you. If your policy requires SRI, tell us before you install rather than pinning quietly.
What we do offer: a self-identifying bundle. Every GET /w.js response carries an X-GWP-Widget-Version header whose value is the first 12 lower-case hex characters of the SHA-256 of the exact bytes in that response. You can verify it yourself, from any machine, with no cooperation from us:
# 1. what we say the bytes arecurl -sI https://cdn.gwpingenuity.com/w.js | grep -i x-gwp-widget-version# x-gwp-widget-version: <12 lower-case hex characters>
# 2. what the bytes actually arecurl -s https://cdn.gwpingenuity.com/w.js | shasum -a 256 | cut -c1-12# the same 12 characters — by construction, on every releaseThat gives a reviewer three things without a pinning mechanism: a stable identity for the build they actually reviewed, a one-line check that tells them whether it has changed since, and something concrete to quote in a change record. Our own deploy and uptime checks assert against the same header, so it is load-bearing for us too rather than decoration.
The build behind that hash is deterministic. The published bundle is produced by one command from one source commit; it embeds no build timestamp and no random identifiers, and nothing environment-specific is baked into it (it derives its API base at runtime from its own <script src>, as in §1). The same commit therefore reproduces byte-identical output, which is what makes the hash a genuine identity rather than a build counter. Our publish step also refuses to ship when the bundle it just built and the copy about to be uploaded disagree. One limit: our source is not public, so this is a property of our release process, not something you can re-run. The part you can re-run yourself is the hash check above.
No changelog is published today. If you need to know what changed between two builds (for a change record, or to decide whether a re-review is warranted), ask us and quote the two X-GWP-Widget-Version values. That is a person answering an email, not a feed you can subscribe to; we would rather say so than imply otherwise.
Where next
Section titled “Where next”- Retailer's developer: Install the tag covers snippet placement, page registration, adapter examples, the claim moment, browser verification steps, troubleshooting and CSP. Then the platform guides for Shopify, custom/headless and everything else.
- Advertiser: Conversion API has the endpoint, auth, parameters, response and error shapes, idempotency, attribution window, reversals, worked examples.
- Anyone with a question these pages do not answer: how to reach a person — email
support@gwpingenuity.comwith[INTEGRATION]in the subject, and what to put in it.