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
orderIdyour order number, 1–100 characters [A-Za-z0-9_.:#/-] – the dedupe key
value, currencygross order value ≥ 0 and its ISO 4217 code; sent to the platforms exactly like this
itemsoptional number of items
sessionIdoptional: the browser's getIds().sessionId – attribution and click ids come from the visit
leadIdoptional: an existing lead (its hashed contact data is used for matching)
email, phone, externalIdoptional matching data, hashed on arrival, never stored in plaintext
eventIdoptional: share it with a browser pixel's Purchase event for platform-side dedupe (default order:<orderId>)
occurredAtoptional ISO time of the purchase (default now, not in the future)
isTest, dryRuntest 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 orderId again 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 projectValue is null and the purchase is left out of revenue (counted as unconverted) – the upload never waits for it.
  • Currency – an active ISO 4217 code (EUR, USD, CHF …); anything else → 422.
  • Unknown lead → 404; invalid input → 422 with the field.

Refunds

POST /v1/projects/{project}/purchases/refunds:

{ "orderId": "1042", "value": 20, "at": "2026-10-17T09:30:00Z", "note": "one item returned" }
  • Omit value to refund everything that is left. Refunds add up; revenue counts net.
  • The same orderId + value + at again answers "result": "unchanged" (safe to retry). Without at the request is retried safely with an Idempotency-Key header (the server helper always sends one); without both, the same value for the same order on the same day (project time zone) counts as a retry and answers "unchanged" – send at or an Idempotency-Key for 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:

ModeRule
Leadsone 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)
Shopevery purchase is its own conversion and is reported every time; dedupe only by order id / event id
Mixedleads follow the leads rule, purchases the shop rule

The rule applies to conversions recorded after a mode switch; earlier reports stay as they are.