Conversion API
For the backend engineer at a partner brand. You give away a gift; a retailer's shopper picks it at their basket; when that shopper converts on your site you tell us, and we pay the retailer their share.
1. The lifecycle
Section titled “1. The lifecycle” shopper picks your gift shopper claims it shopper converts at the retailer's basket after checkout on your site │ │ │ ▼ ▼ ▼ GWP mints a click id claim link carries it your server POSTs it back clk_ + 32 hex chars → ?gwp_clid=clk_4f8c… → POST /v1/conversion1. The click id is minted at selection. When a shopper chooses your gift in the retailer's basket, GWP's widget calls its own POST /v1/select and the server mints an opaque click id: clk_ followed by 32 lower-case hex characters, 36 characters total. It is generated server-side; the browser never invents one. It encodes nothing about the shopper, the cart or the price. The moment of minting is also the moment the attribution clock starts (§6).
2. It is carried on the claim URL. The claim link (on the retailer's confirmation page, in their order-confirmation email, or wherever they place it) is your offer's redemption URL with the click id appended:
https://your-brand.example/gwp-gift?gwp_clid=clk_4f8c1d2b3a90f1e7c6d5b4a39281706fThe parameter is gwp_clid by default. If your offer is served through an affiliate network instead, the click id rides that network's own sub-id slot (clickref, sid, u1, subId1…) and the network echoes it back in its remittance. In that case you have nothing to build and this page does not apply to you. Ask us which lane your offer is on if you are unsure.
3. Your site captures it. The shopper lands on your page with gwp_clid in the query string. Store it against the session/basket the same way you store any other click reference: a first-party cookie, a session field, a hidden order attribute. It needs to survive until the order completes.
4. You post it back on conversion. When the order is placed (or when it passes whatever validation you consider "a sale"), your server posts the click id with your own order id. That is the whole integration.
What "converts" means is your call. We do not define it. Order placed, payment captured, trial started, code redeemed: post the one that matches the commercial terms.
2. What you need from us
Section titled “2. What you need from us”| Value | What it is | Where it lives |
|---|---|---|
| Advertiser key | Identifies your brand account. Not secret. | advertiser_key in every request |
| Secret | sk_…. Authenticates server-to-server calls | request body or X-GWP-Secret header, server-side only |
| Pixel signing key | Derived from the secret; the HMAC key for the pixel only | server-side only. Never send it, only signatures made with it |
| Attribution window | Days from gift selection during which a conversion is credited | agreed per advertiser at setup (§6) |
Both the secret and the pixel signing key are shown once, when your account is created. Store them in your secret manager immediately. Lost them? We rotate and re-issue. The old secret keeps working for a grace period (24 hours by default) so in-flight postbacks are not dropped mid-rotation.
3. POST /v1/conversion
Section titled “3. POST /v1/conversion”POST https://cdn.gwpingenuity.com/v1/conversionContent-Type: application/jsonJSON body. No SDK required.
Authentication
Section titled “Authentication”Present the secret either as a body field or as a header:
"secret": "sk_…" in the JSON bodyX-GWP-Secret: sk_… as a request headerIf both are present the body field wins. The secret is compared, in constant time, against a hash held at rest. We never store the plaintext. During a rotation the previous secret is also accepted, for the grace window only.
A wrong secret, a missing secret, and an unknown or malformed advertiser_key all return the same HTTP 401. The response deliberately does not tell you which of them was wrong. Check both.
Parameters
Section titled “Parameters”Several fields accept both snake_case and camelCase. They are exact synonyms. Pick one style and stay with it. snake_case is the canonical form used throughout this page.
| Parameter | Alias | Type | Required | Notes |
|---|---|---|---|---|
advertiser_key | advertiserKey | string | yes | Your advertiser key. |
secret | none | string | yes, unless sent as X-GWP-Secret | Never in browser code. |
gwp_clid | clid | string | yes | The click id you captured from the claim URL, verbatim. clk_ + 32 hex. |
advertiser_order_id | advertiserOrderId | string | yes | Your order reference. Must be non-empty (whitespace-only is rejected). Half of the idempotency key (see §5). |
order_value | orderValue | number | conditional | Order value in major units (e.g. 42.50). Required if your offer pays a percentage of order value; optional for flat-fee offers. Must be a JSON number, finite and ≥ 0. |
currency | none | string | no | GBP, USD or EUR. Must equal the retailer's settlement currency. We do not convert across currencies. Omit it to inherit the retailer's. |
Unrecognised fields are ignored. There are no other parameters: anything else you have seen in an older integration note is not read by this endpoint.
The caller's IP address and User-Agent are recorded from the request itself for forensics and invalid-traffic scoring. You do not send them.
Responses
Section titled “Responses”Every response is JSON. The body carries ok, a reason, and, on the paths that go through conversion ingestion, a status mirroring the HTTP status.
Accepted:
{ "ok": true, "status": 200, "reason": "accepted" }HTTP 200.
The body never contains commission amounts. Your earnings and ours are reported through the dashboard, not this endpoint.
accepted means recorded and attributed. It is not a settlement guarantee. Conversions may be held before they become payable: briefly, for a settlement hold, or longer if a conversion is set aside for review. The response is the same either way. A reversal (§7) landing while a conversion is held removes it before anyone is paid.
Error and rejection cases
Section titled “Error and rejection cases”Rejections, HTTP 200, "ok": false (except the replay case, see below):
reason | What happened | What to do |
|---|---|---|
rejected_unknown_clid | The click id does not exist, or belongs to a different advertiser. Also returned if the offer behind it has been deleted. | Check you are sending the click id verbatim, and against the right advertiser key. Truncation and case-folding both break it. |
rejected_replay | This (gwp_clid, advertiser_order_id) pair has already been recorded. Returns "ok": true. A replay is a safe no-op, not a failure. | Nothing. This is what makes retries safe (§5). |
rejected_outside_window | The shopper selected the gift longer ago than your attribution window allows. | Nothing to fix per-order. If you see this constantly, your window is too short for your sales cycle. Talk to us (§6). |
rejected_invalid | Missing/blank advertiser_order_id; a negative or non-finite order_value; a percentage-model offer with no positive order_value; or a computed commission above the per-conversion sanity ceiling (10,000 currency units, usually a minor/major-unit slip, e.g. pence sent as pounds). | Fix the payload. Send order_value in major units. |
rejected_currency | currency is not one of GBP/USD/EUR, or does not match the retailer's settlement currency. | Send the retailer's currency, or omit the field entirely. |
rejected_cap | This single click id already has 5 conversions against it. | Expected only if you are posting many orders against one gift selection, which is not a supported shape. Check your click id storage is not being reused across sessions. |
rejected_budget | The offer's funded budget is exhausted. | Nothing you can do. Contact us to top up the offer. |
rejected_unfunded | Your account's prepaid balance / credit limit would be exceeded. | Contact us. |
rejected_network_billed | Your account settles through an affiliate network, so direct postbacks are refused. The network reports these sales. | Remove the direct postback, or ask us to change your lane. Posting both would credit the same sale twice. |
rejected_manual_credited | That selection was already credited by a manually-entered count on our side. | Contact us; the duplicate is on our end, not yours. |
Transport-level errors:
| HTTP | Body | Meaning |
|---|---|---|
401 | {"ok":false,"status":401,"reason":"auth"} | Bad or missing secret, or unknown advertiser key. |
429 | {"ok":false,"reason":"rate_limited"} | Over the rate limit (§4). Retry after the current minute. |
400 | {"ok":false} | The body was not valid JSON, or a field had the wrong type (for example order_value sent as a string). |
Retry guidance
Section titled “Retry guidance”2xxwithok:true→ done.429,5xx, timeout, connection error → retry. Retries are safe: the same(gwp_clid, advertiser_order_id)is deduplicated (§5). Back off: the rate limit is a fixed one-minute window, so waiting out the minute clears it.400,401, or arejected_*reason → do not retry blindly. These are deterministic; the same request will fail identically. Log it and alert.
4. Rate limits
Section titled “4. Rate limits”120 requests per minute, per advertiser key per source IP, in a fixed 60-second window. Over the limit you get HTTP 429.
That budget is shared between /v1/conversion, /v1/reversal and /v1/pixel. They are counted together. If you batch a backlog, spread it or handle the 429 and resume.
5. Idempotency
Section titled “5. Idempotency”The idempotency key is the pair (gwp_clid, advertiser_order_id).
Post the same pair twice and the second call is rejected as rejected_replay with "ok": true. Nothing is written twice; no double credit is possible.
This is what makes the endpoint safe to retry on a timeout, safe to re-drive from a queue, and safe to replay from a nightly reconciliation job.
Two consequences worth designing for:
- Make
advertiser_order_idstable. If your retry sends a freshly generated reference for the same order, it is a different key and will be credited a second time. Use your durable order id, not a request id. - The key includes the click id. The same order id posted against a different click id is a different key. This is deliberate (it is how one shopper claiming two different gifts is handled) but it means the order id alone is not a uniqueness guarantee on our side.
6. The attribution window
Section titled “6. The attribution window”The clock starts when the shopper selected the gift at the retailer's basket, not when they clicked the claim link, and not at checkout.
selectedAt ────────── attribution window ──────────▶ │ a conversion posted here ────┘ → rejected_outside_windowThe window length is a per-advertiser setting agreed at setup. There is no network-wide default. Direct integrations typically run a short window (days); network-sourced offers need a much longer one because the network's own validation runs for weeks. Ask us what yours is set to, and tell us if your purchase cycle is longer than it.
Note what this means in practice: a shopper who selects a gift on Monday, claims the link on Tuesday and buys three weeks later is measured from Monday.
7. Reversals
Section titled “7. Reversals”Refunds, cancellations and failed payments are reported by reversing the conversion you previously posted.
POST https://cdn.gwpingenuity.com/v1/reversalContent-Type: application/jsonSame authentication as /v1/conversion (body secret or X-GWP-Secret header), and the same shared rate-limit budget.
| Parameter | Alias | Type | Required |
|---|---|---|---|
advertiser_key | advertiserKey | string | yes |
secret | none | string | yes, unless sent as X-GWP-Secret |
gwp_clid | clid | string | yes |
advertiser_order_id | advertiserOrderId | string | yes |
reason | none | string | no (free text, stored for audit) |
The click id and order id must be the same pair you posted the conversion with; that pair is how we find the row.
Responses:
| HTTP | Body | Meaning |
|---|---|---|
200 | {"ok":true,"status":200,"reason":"reversed"} | Reversed. Also returned if it was already reversed: reversals are idempotent. |
200 | {"ok":false,"status":200,"reason":"reversal_unknown"} | No conversion found for that pair under your advertiser key. |
401 | {"ok":false,"status":401,"reason":"auth"} | Bad or missing secret. |
429 | {"ok":false,"reason":"rate_limited"} | Over the shared rate limit. |
400 | {"ok":false} | Malformed body. |
What a reversal does: the conversion is netted out of every owed sum, the offer's funded budget is credited back, and your account's balance is credited back by the same gross amount.
Report reversals promptly. A conversion is held before it becomes payable to the retailer; a reversal that lands inside that period removes it before anyone is paid, which is why there is no clawback on the retailer side.
There is no "un-reverse". Re-posting the conversion afterwards will not restore it. A corrected sale needs a fresh advertiser_order_id, or a conversation with us.
8. Worked example: cURL
Section titled “8. Worked example: cURL”curl -sS -X POST https://cdn.gwpingenuity.com/v1/conversion \ -H 'Content-Type: application/json' \ -H 'X-GWP-Secret: sk_REPLACE_WITH_YOUR_SECRET' \ -d '{ "advertiser_key": "YOUR_ADVERTISER_KEY", "gwp_clid": "clk_4f8c1d2b3a90f1e7c6d5b4a39281706f", "advertiser_order_id": "ORDER-10231", "order_value": 42.50, "currency": "GBP" }'{"ok":true,"status":200,"reason":"accepted"}The same call with the secret in the body instead of the header:
curl -sS -X POST https://cdn.gwpingenuity.com/v1/conversion \ -H 'Content-Type: application/json' \ -d '{"advertiser_key":"YOUR_ADVERTISER_KEY","secret":"sk_REPLACE_WITH_YOUR_SECRET", "gwp_clid":"clk_4f8c1d2b3a90f1e7c6d5b4a39281706f", "advertiser_order_id":"ORDER-10231","order_value":42.50,"currency":"GBP"}'And a reversal:
curl -sS -X POST https://cdn.gwpingenuity.com/v1/reversal \ -H 'Content-Type: application/json' \ -H 'X-GWP-Secret: sk_REPLACE_WITH_YOUR_SECRET' \ -d '{ "advertiser_key": "YOUR_ADVERTISER_KEY", "gwp_clid": "clk_4f8c1d2b3a90f1e7c6d5b4a39281706f", "advertiser_order_id": "ORDER-10231", "reason": "refunded" }'9. Worked example: server-side
Section titled “9. Worked example: server-side”Node 18+, no dependencies. The shape to copy is the outcome handling: parse the body, treat rejected_replay as success, retry only transport failures.
// server-side only — process.env, never a bundled client configconst GWP_BASE = "https://cdn.gwpingenuity.com";const GWP_KEY = process.env.GWP_ADVERTISER_KEY;const GWP_SECRET = process.env.GWP_SECRET;
const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
export async function reportConversion(order) { const body = { advertiser_key: GWP_KEY, gwp_clid: order.gwpClid, // captured from ?gwp_clid on landing advertiser_order_id: order.id, // stable — same value on every retry order_value: order.total, // NUMBER, major units. Omit for flat-fee offers. currency: order.currency, // omit to inherit the retailer's };
for (let attempt = 0; attempt < 4; attempt++) { let res; try { res = await fetch(`${GWP_BASE}/v1/conversion`, { method: "POST", headers: { "Content-Type": "application/json", "X-GWP-Secret": GWP_SECRET, // never leaves the server }, body: JSON.stringify(body), signal: AbortSignal.timeout(5000), }); } catch (err) { await sleep(2 ** attempt * 1000); // network/timeout — retry continue; }
const out = await res.json().catch(() => ({}));
// Recorded, or already recorded. Both are success. if (out.ok && (out.reason === "accepted" || out.reason === "rejected_replay")) { return { ok: true, reason: out.reason }; } // Deterministic refusal — retrying sends the identical request. if (res.status === 400 || res.status === 401 || (res.status === 200 && !out.ok)) { console.error("[gwp] conversion refused", res.status, out.reason, order.id); return { ok: false, reason: out.reason ?? "unknown" }; } if (RETRYABLE.has(res.status)) { await sleep(2 ** attempt * 1000); // 429 clears within the minute continue; } return { ok: false, reason: out.reason ?? `http_${res.status}` }; } return { ok: false, reason: "exhausted" }; // queue it — the call is idempotent}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));Capturing the click id on landing, for reference:
// on your gift landing page's server routeconst clid = new URL(req.url, "https://example.invalid").searchParams.get("gwp_clid");if (clid) session.gwpClid = clid; // carry through to order creation10. Pixel fallback
Section titled “10. Pixel fallback”If you genuinely cannot make a server-to-server call, a 1×1 image pixel on your order-confirmation page works, but read the caveats first.
GET https://cdn.gwpingenuity.com/v1/pixel ?advertiser_key=…&gwp_clid=…&advertiser_order_id=…&sig=…The signature is mandatory, and you must compute it server-side. The pixel never carries the secret. It carries a per-conversion HMAC:
sig = HMAC-SHA256( pixel_signing_key , "<gwp_clid>|<advertiser_order_id>" )lower-case hex. The key is the pixel signing key we issued you, not the raw secret. They are different strings. Because the signature is bound to this click id and this order id, it cannot be replayed for a different conversion.
// server-side, when rendering the confirmation page$sig = hash_hmac('sha256', $clid . '|' . $orderId, $pixelSigningKey);<img src="https://cdn.gwpingenuity.com/v1/pixel?advertiser_key=YOUR_ADVERTISER_KEY&gwp_clid=<?= $clid ?>&advertiser_order_id=<?= $orderId ?>&order_value=42.50¤cy=GBP&sig=<?= $sig ?>" width="1" height="1" alt="" />Every value in that tag is a server-side template expression on purpose. A click id is clk_ plus exactly 32 hex characters. Paste a truncated one and the pixel silently records nothing.
| Query parameter | Alias | Required |
|---|---|---|
advertiser_key | none | yes |
gwp_clid | clid | yes |
advertiser_order_id | order_id (note: different alias from the POST) | yes |
sig | none | yes |
order_value | none | conditional, as for the POST. Non-numeric values are ignored. |
currency | none | no |
The caveats:
- It always returns a 1×1 GIF. Success, bad signature, unknown click id, rate limit and outright error are byte-identical to your page. You get no feedback whatsoever.
- It fires from the shopper's browser, so it is subject to ad blockers, privacy modes, prefetch, and the page simply not being reached.
- Every rejection in §3 still applies. You just cannot see which one fired.
Use POST /v1/conversion if you possibly can. If you are on the pixel, ask us to check your postback log after go-live; that log is the only place the outcomes are visible.
11. Go-live checklist
Section titled “11. Go-live checklist”- Secret and pixel signing key are in a server-side secret store, and appear in no client bundle, tag manager, or repository.
-
gwp_clidis read from the landing URL and persisted to the order. -
advertiser_order_idis your durable order id and identical on retries. -
order_valueis a JSON number in major units (required if your offer pays a percentage). -
currencymatches the retailer's settlement currency, or is omitted. - Response bodies are parsed for
ok/reason, not just the HTTP status. -
rejected_replayis treated as success. - Refunds and cancellations call
/v1/reversalwith the same pair. - A failed postback raises an alert somewhere a human sees it.
Send us a test order before go-live and we will confirm what landed in your postback log.
12. Getting help
Section titled “12. Getting help”Something here not answering your question? Email support@gwpingenuity.com with [INTEGRATION] in the subject line. The same mailbox answers shoppers asking about a gift they claimed, and the prefix tells us to put yours in front of an engineer instead. It is a monitored mailbox; we aim to reply within two working days. We would rather answer than have you guess at a parameter.
Include, in the first email:
- Your advertiser key. Never your secret or your pixel signing key. We do not need them, cannot help faster for having them, and a secret sent by email is a secret you have to rotate.
- The
gwp_clidandadvertiser_order_idof a specific call you are asking about. - The exact request body you sent (with the secret redacted) and the exact response body you got back. The
reasonfield is where the answer usually is. - Whether you are on the POST or the pixel, and whether your offer is direct or served through an affiliate network.
- What you already tried.
Two things worth knowing about that address. It is not a 24/7 incident line, and there is no published emergency SLA — but the endpoint is idempotent by design, so a backlog you queue and re-drive later loses nothing (§5). And /support is the shopper help page, for people asking about a gift they claimed; it is not developer support.