The claim moment
A shopper reserves a gift at the basket. They claim it after the order is placed. You have three 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).
Option C: your server asks GWP
Section titled “Option C: your server asks GWP”If your server sends the confirmation email while it creates the order (and again when an order is edited), it can ask GWP for the order's gifts rather than carry them through checkout itself as in Option B. It calls GWP while it builds the email, and puts the block it gets back into it. GWP still sends no email.
It needs a signed-in shopper. GWP finds the order's gifts through your own customer number, so your pages must publish the signed-in customer's number in window.dataLayer, and the order must be placed from an account.
How it fits together:
- You tell us where the customer number is. Dashboard → Widget → Redemption link: choose In your order confirmation email, then fill in Signed-in customer number (dataLayer).
userData:user_idreadsuser_idfrom the newestdataLayerentry whoseeventisuserData, so a sign-out (a new entry without the key) reads as no customer. A key with noevent:prefix reads the newest entry that carries it. - The tag keeps our copy of the shopper's gifts current. While the device holds a gift, it sends the list of gifts it holds with the customer number to
POST /v1/gift-syncon each page load and after every change. When the last gift goes (the shopper removes it, an order uses it, or they withdraw consent where essential mode is off), it sends an empty list once, so that we delete our copy. Until then the device keepsgwp_gsync, a short hash of your site key and the customer number, so it knows that list is owed (what we store). A signed-in shopper who never picks a gift sends nothing. A gift picked while signed out joins the list once the shopper signs in. It sends a list only once the shopper's consent lets the widget run, which includes essential mode if you allow it. - Your server asks at order creation. It calls
POST /v1/api/order-giftsand puts the returnedhtmlin the email. - A gift belongs to one order. Once an order takes it, it leaves the shopper's list, and the tag removes it from their device on its next sync.
The request
Section titled “The request”POST https://cdn.gwpingenuity.com/v1/api/order-giftsAuthorization: Bearer gwpk_…Content-Type: application/json
{ "orderNo": "00012345", "customerNo": "1234567890123", "orderTotal": 54.20 }| Field | Required | Notes |
|---|---|---|
orderNo | yes | Your order number, up to 128 characters. Send the same one when an edit re-sends the email |
customerNo | yes | The number your pages publish in the dataLayer: letters, digits, _ and -, up to 64 characters |
orderTotal | no | The order total in your settlement currency. Gifts whose minimum spend is above it are left out |
clids | no | Up to 5 click ids, if your server can see the ordering browser's selection. Only gifts from that set come back |
The key is an API key with the purpose Order confirmation email, created in your dashboard → Settings → API keys (creating one needs Admin Gift with Purchase for the site). It acts for your store only and does nothing else: a reporting key is refused here, and this key is refused for reporting. It is a server-side credential: the endpoint sends no CORS headers, so a browser cannot call it, and a key must never reach one. Keys expire, so create the next one before the current one runs out.
The response
Section titled “The response”{ "orderNo": "00012345", "gifts": [ { "title": "…", "advertiser": "…", "imageUrl": "https://cdn.gwpingenuity.com/img/o/…", "claimUrl": "https://…", "terms": "…" } ], "html": "<table role=\"presentation\" …>…</table>"}htmlis the gift block, ready for your email body: in your store's own email design where we have set one up with you, otherwise a neutral one. It is""when there are no gifts, so a no-gift email stays exactly as it is today.giftsis the same thing as data, if you would rather build the block yourself.imageUrlmay benullandtermsmay be"". Escape everything, and useclaimUrlverbatim.- No gifts is a normal answer. You also get it while the feature is switched off or not set up for your store.
Rules that will bite you if you ignore them
Section titled “Rules that will bite you if you ignore them”- Never let it hold up an order. Connect within 1 second, give up after 3 seconds in total, and never retry inside order creation. On a timeout, or anything other than a
200, send the email without the block. GWP answers503itself if it cannot answer within 2 seconds. - An edit asks again and gets the same gifts. Call with the same order number: you get the gifts the order already had, plus any the shopper has added since, up to your gifts-per-order cap. A gift the shopper removed drops out.
- One order, one customer. The first customer number to ask about an order number owns it. Another customer number on the same order gets an empty answer.
- Gifts have a shelf life. Only gifts picked in the last 30 days (7 in essential mode) count, the same lifetimes as the widget's own storage.
- Volume. Each key can make 1,200 requests a minute. Repeated failed authentications from one address are throttled.
- We never store the customer number. We keep a keyed hash of it: with the shopper's gift list while it holds a gift (at most 30 days after the last sync), and for 60 days with each order number your server asks about, gift or not, since that is what ties an order to one customer. Details are in our Privacy Policy.
Test three orders: one with a gift (the email shows it, and the claim link works), one without (the email is unchanged), and an edit of the first (the same gift, once).
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.