Skip to content
GWP Book a demo

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.

PartWhat it isWho owns it
The tag (w.js)One async script on your storefrontYou paste it; we ship it
ConfigWhich pages are baskets, where the block renders, how to read the cart, branding, caps, which offers are liveHeld on your GWP account, fetched at runtime
Cart adapterThe one read the tag makes of your basket totalYou choose the type; we configure it
SelectionThe gift the shopper picked, held in first-party storage for 30 daysThe shopper's browser
Click id (clid)The opaque token that ties selection → claim → conversionMinted server-side by us
Claim momentWhere the shopper actually gets the gift linkConfirmation page, your order email, an email we send from your domain, or your own systems
PostbackThe advertiser telling us the gift convertedThe advertiser's server
<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-site is 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 src origin. The tag calls /v1/* and loads images from the same host it was served from, so one host entry (https://cdn.gwpingenuity.com) covers script-src, connect-src and img-src.
  • It publishes exactly one public API on window.GWP. That is selection(), a read accessor documented in §5, plus a boolean boot flag, window.__gwp_loaded__. A CI gate boots the built bundle and fails if window.GWP ever 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 */cart matches /cart, /en-gb/cart and /checkout/cart, but not /collections/cart-accessories.
  • A pattern with no * at all is treated as a plain substring of the path. /cart on its own would match /collections/cart-accessories too. 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 + pathname and 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.

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:

  • root is the drawer element (document.querySelector(root)),
  • openWhen is evaluated as root.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.

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, a dataLayer.push, CMP consent decision events, and a gwp:cartchange event 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's cart-update PubSub 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 are not cached, so a config change reaches shoppers on their next config fetch: the next page load or route change.

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:

TypeWhat it readsConfigure
datalayerwindow.dataLayer, walked newest-first: ecommerce.value, cartValue, cart_total, plus a currency from currency / currencyCodeNothing. Shape detection is built in
windowwindow.gwp.cart = { value, currency, items: [{ id, qty }] }You publish the global
domdocument.querySelector(totalSelector).textContent, parsed to a numbertotalSelector, 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.

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), gwp_gsync (§5(d) stores only: a short hash of the site and the signed-in customer's number, kept while we hold their gift list, so the tag knows it owes us the empty list), gwp_em (separate-email stores only, localStorage only, for at most 24 hours: the shopper's sign-in email address, sealed so that only we can read it; see §5(e)), 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:

  • Without consent, shoppers still get gifts, and we keep only what the gift needs. The config fetch happens first (it carries only your site key and origin + pathname). If the shopper has refused cookies, or has not answered your banner, the widget runs in essential mode: the gifts show, nothing is stored until the shopper picks one, and then only that choice is kept, for at most 7 days, to link the gift to their order, with the markers tied to it where your store uses them (gwp_attrs, gwp_gsync). If your server asks us for an order's gifts (§5(d)), that link also needs the signed-in shopper's customer number: while their device holds a gift, the widget sends the number to us with those gifts, and once more with an empty list when the last one goes (until then the device keeps gwp_gsync). We keep only a keyed hash of it, with their gift list while it holds a gift (at most 30 days after the last sync) and with each order number your server asks about (60 days), and use it only to put those gifts in that order's email. A signed-in shopper who never picks a gift sends nothing. No session identifier, frequency caps, A/B tests, views or impressions are recorded for them, and the sign-in email capture of §5(e) never runs. That relies on the strictly-necessary exception for keeping a record of the shopper's own selections. Under auto, an explicit refusal or an unanswered banner means essential mode; strict also treats a page with no consent platform that way. You choose which consent category unlocks full mode (for example a OneTrust category), and you can switch essential mode off so the widget shows nothing without consent. If consent is later withdrawn, the identifier, the counters and any sealed sign-in email are erased, and the shopper's chosen gift is kept on the 7-day essential lifetime. With essential mode switched off, the chosen gift is erased too, and on a §5(d) store the widget sends the empty list once so that our copy goes with it.
  • 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/select for 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/clickout, /v1/associate, /v1/gift-sync and /v1/redeem all check the Origin header against your registered domains and return 403 otherwise.

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 five concrete paths, and a store can use more than one.

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:

  1. POST /v1/clickout (recorded, fail-soft),
  2. 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:

AttributeValue
gwp_gift_count"0"–"5", as a string
gwp_gift_{i}_titleGift title as shown on the card (i is 1-based)
gwp_gift_{i}_advertiserAdvertiser name
gwp_gift_{i}_claim_urlAbsolute claim URL carrying that gift's click id, byte-identical to the on-page link. May be ""
gwp_gift_{i}_imageAbsolute 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.

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.

For stores whose server sends the confirmation email while it creates the order, and would rather ask us for the gifts than carry them through checkout. You tell us which dataLayer key carries the signed-in customer's number. While a signed-in shopper's device holds a gift, the tag keeps our copy of their gift list in step with it: it sends the list with that number to POST /v1/gift-sync on each page load and after each change, and an empty list once when the last gift goes, so that we delete our copy. A signed-in shopper who holds no gift sends nothing. When your server creates the order, it calls POST /v1/api/order-gifts with the order number and the customer number, and puts the gift block it gets back into the email. A gift belongs to one order: once an order takes it, it leaves the list, and the tag drops it from the device. We keep a keyed hash of the customer number, never the number: with the gift list while it holds a gift, and with each order number your server asks about for 60 days. The request and its rules are in the claim moment guide.

For stores that would rather not change their own emails. After the order, we email the shopper one claim link per gift, from your store's own email domain once it is verified with us. The tag needs two things for that, the shopper's address and the order number:

  • On your sign-in page, and only in full mode (never in essential mode), the tag reads the one email field you name when the sign-in form is submitted, holds the address in memory, and keeps it only once the sign-in has evidently worked within 20 seconds: the account hash you publish in the dataLayer matches, or the shopper leaves the sign-in page. Otherwise it is dropped. It never reads a password or any other field. It seals the address on the device to our key, so nothing on the device or your page can read it back, and keeps the sealed copy in gwp_em for at most 24 hours.
  • On the confirmation page, it reads the order number from the element or pattern you configure and sends it, the click ids and the sealed address to POST /v1/redeem. Without a sealed address it asks the shopper where to send the gift, and sends what they type.

We keep the address sealed, with a keyed hash of it for a per-address daily cap, and delete it 90 days after the request.

clk_ + 32 lower-case hex characters
e.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, subId1 and 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.
selection claim conversion
POST /v1/select → claim URL carries → advertiser POSTs the clid back
mints the clid ?gwp_clid=clk_… POST /v1/conversion

The 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:

  1. Authentication. The secret (body field or X-GWP-Secret header) is checked against the advertiser's stored hash. Server-side only, never in browser code. Bad secret → 401.
  2. Known click id. The clid must exist and belong to that advertiser → otherwise rejected_unknown_clid.
  3. Idempotency. (clid, advertiser_order_id) is the dedupe key. A repeat is rejected_replay and returns ok: true. A retry is a safe no-op, not a double credit.
  4. 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.
  5. 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).
  6. 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/reversal and 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.

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:

RequestWhere it goes
GET /v1/config, POST /v1/events, /v1/select, /v1/clickout, /v1/associate, /v1/gift-sync, /v1/redeem, /v1/error, /v1/retention-optinThe origin the tag itself was served from (cdn.gwpingenuity.com)
Gift and brand imagesThe 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.jsYour 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 beyond two values, each only if you switch on the feature that needs it. If your server asks us for an order's gifts (§5(d)), the tag sends the signed-in customer's number from the dataLayer key you named, only while the shopper's device holds a gift (or once, to remove them), and we keep only a keyed hash of it. If we email the gift link for you (§5(e)), the tag reads the email address the shopper signs in with (the one field you name, never the password, and only in full mode), seals it on the device so that only we can read it, and sends it with the order number to /v1/redeem; we keep it sealed for 90 days. 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.

LeverWhoseHow fastWhat it does
Remove the tag: delete the <script> from your template, or pause the tag-manager tagYours. No involvement from us at allAs fast as your own deploy or tag-manager publishTotal 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 modeYours, from your dashboard (or ask us)Next config fetchChanges 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-wideOurs. GWP staff only. It is not on the retailer dashboard, and there is no self-serve equivalentNext config fetch, with no redeploy on your sideThe 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.

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.js is a single, unversioned route. A new release replaces the bytes at the same URL. There is no /w/1.4.2.js to pin to and we do not offer one.
  • Updates reach shoppers within minutes. Our servers pick up a new release within five minutes, and /w.js is served with Cache-Control: public, max-age=300, so a browser that already holds a copy fetches the new one within five more. Plan for up to about ten minutes for every new page load to be on a new build. A tab that is already open keeps the bundle it loaded until the shopper navigates. There is still no update prompt and nothing to redeploy on your side.
  • You can add an integrity attribute, 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:

Terminal window
# 1. what we say the bytes are
curl -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 are
curl -s https://cdn.gwpingenuity.com/w.js | shasum -a 256 | cut -c1-12
# the same 12 characters — by construction, on every release

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

  • 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.com with [INTEGRATION] in the subject, and what to put in it.