Shop purchases and refunds
In the project mode Shop / sales every completed purchase is its own conversion. X3 stores it with
its value, currency and order id, counts it in revenue, ROAS, cost per purchase and average order value,
and reports it to every enabled platform: Meta Purchase (with value, currency, order_id), Google
Ads (conversion value, currency and the order id as orderId) and TikTok CompletePayment.
Record purchases from your server – the place that knows the order was paid. The browser event
purchase alone is not trusted for reporting (see Browser event).
From your server (REST)
POST /v1/projects/{project}/purchases with a secret key (sk_3x_…, scope conversions:write):
POST /v1/projects/{project}/purchases
Authorization: Bearer sk_3x_…
Idempotency-Key: 6f1c2b8e-…
Content-Type: application/json
{
"orderId": "1042",
"value": 89.90,
"currency": "EUR",
"items": 2,
"sessionId": "0d3c…",
"email": "buyer@example.com",
"clientIp": "203.0.113.7",
"clientUserAgent": "Mozilla/5.0 …",
"eventSourceUrl": "https://shop.example.com/checkout/thanks"
}| Field | |
|---|---|
orderId | your order number, 1–100 characters [A-Za-z0-9_.:#/-] – the dedupe key |
value, currency | gross order value ≥ 0 and its ISO 4217 code; sent to the platforms exactly like this |
items | optional number of items |
sessionId | optional: the browser's getIds().sessionId – attribution and click ids come from the visit |
leadId | optional: an existing lead (its hashed contact data is used for matching) |
email, phone, externalId | optional matching data, hashed on arrival, never stored in plaintext |
eventId | optional: share it with a browser pixel's Purchase event for platform-side dedupe (default order:<orderId>) |
occurredAt | optional ISO time of the purchase (default now, not in the future) |
isTest, dryRun | test data is never reported; dryRun validates and answers without storing |
Answer (200):
{ "result": "created", "orderId": "1042", "conversion": { "id": "…", "status": "pending", "orderId": "1042" }, "reported": ["meta", "google_ads"], "projectValue": 89.9, "projectCurrency": "EUR", "heldReason": null }- Duplicate submit – the same
orderIdagain answers"result": "duplicate"and reports nothing new. Repeat purchases of the same person with a new order id are reported every time. - Without session or click id – fine: the purchase is stored and reported with the contact data you send (platforms match on hashed e-mail/phone, IP and user agent).
- Foreign currency – the platforms get value and currency exactly as sent, right away. Reports convert
into the project currency with the ECB rate of the purchase day (the same rates as ad spend); until a rate
exists
projectValueisnulland the purchase is left out of revenue (counted asunconverted) – the upload never waits for it. - Currency – an active ISO 4217 code (
EUR,USD,CHF…); anything else →422. - Unknown lead →
404; invalid input →422with the field.
Refunds
POST /v1/projects/{project}/purchases/refunds:
{ "orderId": "1042", "value": 20, "at": "2026-10-17T09:30:00Z", "note": "one item returned" }- Omit
valueto refund everything that is left. Refunds add up; revenue counts net. - The same
orderId+value+atagain answers"result": "unchanged"(safe to retry). Withoutatthe request is retried safely with anIdempotency-Keyheader (the server helper always sends one); without both, the samevaluefor the same order on the same day (project time zone) counts as a retry and answers"unchanged"– sendator anIdempotency-Keyfor a second refund of the same amount that day. - More than what is left of the order →
422(refund_exceeds_value); an unknown order →404(„order … not found – record the purchase first“). - A refund never deletes or re-reports the conversion; Sync shows „20,00 € erstattet · netto 69,90 €“.
Server helper (@x3growth/sdk/server)
import { createX3Server, requestContext } from "@x3growth/sdk/server";
const x3 = createX3Server({ apiUrl: process.env.X3_API_URL!, apiKey: process.env.X3_SECRET_KEY!, project: process.env.X3_PROJECT_ID });
// after the payment succeeded (webhook of your payment provider, order confirmation …)
await x3.purchase({ orderId: order.id, value: order.total, currency: order.currency, sessionId: order.x3SessionId, email: order.email, ...requestContext(request) });
// later, when money goes back
await x3.refund({ orderId: order.id, value: 20 });The helper validates the input before sending (order id, value ≥ 0, ISO currency, session id format),
sends an Idempotency-Key with every call and throws X3ServerError with the API's status and code
(404 unknown order, 422 refund too high). It works in any server-side JavaScript runtime with fetch (Next.js route
handlers, Express, workers); in other languages call the REST endpoints above.
Browser event
The SDK's built-in event purchase (value, currency, order_id, optional items) records the
purchase on the visit. Because anyone can send browser events, X3 holds the resulting conversion
(„wartet auf Bestätigung Deines Servers“) until your server confirms the same orderId with
purchases.create. Only a purchase definition with "trigger": { "kind": "purchase", "allowWeb": true }
reports browser purchases right away – then the abuse guards of web conversions (daily cap, test traffic)
apply. Held purchases that are never confirmed are skipped after 7 days.
Definition and counting rule
Shop and mixed projects get the definition Kauf (trigger.kind = "purchase", Meta Purchase, Google
„Kauf“, TikTok CompletePayment) automatically; switch it off in Settings › Reporting to store purchases
without reporting them. The rule depends on the project mode:
| Mode | Rule |
|---|---|
| Leads | one person = one lead conversion, permanently; a closing (definition closing: true, e.g. „Kunde“ or „offer accepted“) is reported at most once per lead – the first wins, later ones are stored as skipped (skipReason: closing_already_reported, skipRef = the first) |
| Shop | every purchase is its own conversion and is reported every time; dedupe only by order id / event id |
| Mixed | leads follow the leads rule, purchases the shop rule |
The rule applies to conversions recorded after a mode switch; earlier reports stay as they are.