The claim moment
A shopper reserves a gift at the basket. They claim it after the order is placed. You have two ways to deliver that moment; they are not exclusive.
Option A: on your order-confirmation page (no code)
Section titled “Option A: on your order-confirmation page (no code)”Put a <div data-gwp-slot></div> on your confirmation template and register the URL pattern. The widget mounts in redemption mode, reads your order id, calls /v1/associate to link the order to the click id, and renders a claim button. That button posts a click-out and navigates the shopper to the partner's redemption URL with the click id attached.
This requires nothing from you but the mount point and the order-id read.
Option B: in your own order-confirmation email (the generic hook)
Section titled “Option B: in your own order-confirmation email (the generic hook)”If your confirmation page is not a good surface (single-page checkouts, PSP-hosted thank-you pages, an email-first journey), carry the selection into the email you already send. GWP sends no email. It is your message, from your domain, under your lawful basis.
Two equivalent surfaces, both platform-neutral:
// 1. The event — fires on `document`document.addEventListener("gwp:selection", (e) => { // e.detail = { gifts: [{ title, advertiser, claimUrl, imageUrl }] }});
// 2. The accessor — same payload, read on demandconst { gifts } = window.GWP?.selection?.() ?? { gifts: [] };Payload contract:
| Field | Type | Notes |
|---|---|---|
title | string | The gift title exactly as rendered on the card. |
advertiser | string | The partner brand name as rendered. |
claimUrl | string | Absolute https claim link carrying this gift's own click id. May be "". Render no button in that case. |
imageUrl | string | Absolute gift image URL. May be "". Render no image in that case. |
Rules that will bite you if you ignore them:
- Scope. The event fires, and the accessor returns real data, only on pages where the widget actually mounted: a registered basket or confirmation page, post-consent, with a resolved slot. Anywhere else the accessor exists and returns
{ gifts: [] }, even though a live selection is sitting in the shopper's cookie. Capture on the basket page and carry it in your own checkout state. Polling at checkout-submit on a URL the widget does not mount on returns empty. - The event covers changes made in the widget (add, remove, replace) plus the initial restore of a saved selection. It also covers the two revocations the widget performs on its own: the cart dropping below your minimum, and consent being withdrawn. Both arrive as
{ gifts: [] }, dispatched once the widget has unmounted and cleared its own stored state, so the accessor returns{ gifts: [] }from that moment too. Event, accessor and stored state never disagree. A revocation only retracts what that page actually advertised: a shopper who never chose a gift sees no events at all, and you never get two retractions in a row. - Re-read at send time anyway. Call
window.GWP.selection()at the last point the widget is still on the page (at basket submit, as the worked example below does), and re-read your own stored copy again when you build the email. A captured copy is a cache, not a source of truth: the cart can change, or consent can be withdrawn, after you captured it, and the gift belongs to one order only. - Use
claimUrlverbatim. Attribution rides a query parameter inside it. Do not shorten it, wrap it in a click tracker, re-encode it or strip parameters. - An empty
giftsarray is meaningful. It is what a removal, and a revocation, look like. Overwrite your stored state with it; do not merge. window.GWPexposes exactly one property,selection. It is a read accessor returning a plain snapshot: it cannot reconfigure the widget, redirect its network calls, or reach into its (closed) shadow root. Anything else appearing on that global is a defect. Tell us.
Worked example: basket → your order → your email
Section titled “Worked example: basket → your order → your email”Step 1: capture on the basket page. Mirror the payload into your own checkout state. Keep it fail-soft: never let this block or break the basket.
// loaded on your basket page(function () { var latest = { gifts: [] };
document.addEventListener("gwp:selection", function (e) { latest = (e && e.detail) || { gifts: [] }; // Persist to YOUR session/cart so checkout can read it. try { fetch("/api/cart/gift-selection", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "same-origin", body: JSON.stringify(latest), keepalive: true }).catch(function () {}); } catch (_) {} });
// Belt and braces: re-read at basket submit, still on the basket page. document.querySelector("#basket-form")?.addEventListener("submit", function () { try { latest = window.GWP?.selection?.() ?? latest; navigator.sendBeacon?.( "/api/cart/gift-selection", new Blob([JSON.stringify(latest)], { type: "application/json" }) ); } catch (_) {} });})();Step 2: attach the gifts to the order. When your checkout creates the order, copy the captured gifts onto the order record and clear the captured state. This matters: one selection belongs to one order. If you leave it in the session, the shopper's next confirmation email will re-advertise a claim link for a gift they already claimed.
// your checkout service, at order creationconst gifts = session.gwpGifts ?? [];await orders.create({ ...order, gwpGifts: gifts });session.gwpGifts = []; // consume it — never carry it into the next orderStep 3: render the block in your existing email template. Show nothing when there are no gifts, so a no-gift order's email is byte-for-byte what it is today. Escape everything: title and advertiser are partner-authored text.
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]) );
function giftBlockHtml(gifts) { if (!Array.isArray(gifts) || gifts.length === 0) return ""; // no gift → nothing const cards = gifts.map((g) => ` <table role="presentation" width="100%" style="border:1px solid #E5E7EB;border-radius:12px;margin:12px 0"> <tr> ${g.imageUrl ? `<td width="88" style="padding:12px"> <img src="${esc(g.imageUrl)}" width="72" height="72" alt="" style="border-radius:8px;display:block"> </td>` : ""} <td style="padding:12px;font-family:Arial,sans-serif"> <div style="font-weight:700;font-size:16px;color:#111827">${esc(g.title)}</div> <div style="font-size:13px;color:#6B7280;margin-top:2px">from ${esc(g.advertiser)}</div> ${g.claimUrl ? `<a href="${esc(g.claimUrl)}" style="display:inline-block;margin-top:10px;padding:10px 16px;border-radius:999px; background:#1668E3;color:#fff;text-decoration:none;font-weight:700;font-size:14px"> Claim your gift</a>` : ""} </td> </tr> </table>`).join("");
return `<h3 style="font-family:Arial,sans-serif;font-size:15px;margin:24px 0 4px"> Your free gift${gifts.length > 1 ? "s" : ""} </h3>${cards}`;}Step 4: send it promptly and test both paths. Place one test order with a gift (the email shows one card per gift, and the claim link works), and one without (the email is unchanged).
Where next
Section titled “Where next”- Verify and troubleshoot, including the confirmation leg:
/v1/associate, the claim button and the click-out. - The Shopify claim moment: the Shopify version of this, where the claim block is rendered by a Liquid snippet from
gwp_*cart attributes.