Purchases and subscriptions
A purchase in an app is real only when Apple or Google confirms it. X3 therefore treats app purchases like browser purchases on the web (shop purchases): the app reports a hint, which counts on the session and in the conversion value schema at once, but the purchase conversion is held until a server confirms the same order id. Then it counts in revenue and ROAS and goes to Meta, Google Ads and TikTok.
App: X3.purchase(...) → event "purchase" on the session, conversion held (server_confirmation)
Confirmation with the same order id: App Store Server Notification · Google Play RTDN · RevenueCat · your server
→ conversion released, revenue counted, platforms informed
No confirmation: → the conversion stays held and is never reportedThere is no automatic StoreKit or Play Billing observation: your app reports purchases explicitly, which avoids double counting with your own purchase listeners.
Purchase hints from the app
Report the purchase after the store confirmed it to the app, with the store's transaction id as the order id, and attach the account token to the purchase so that store notifications name the person:
// iOS, StoreKit 2
var options: Set<Product.PurchaseOption> = []
if let token = X3.purchaseAccountToken() { options.insert(.appAccountToken(token)) }
let result = try await product.purchase(options: options)
if case .success(.verified(let transaction)) = result {
X3.purchase(transaction) // orderId = String(transaction.id), product, quantity, price and currency (iOS 16+)
await transaction.finish()
}// Android, Play Billing
val params = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productParams))
.apply { X3.purchaseAccountToken()?.let { setObfuscatedAccountId(it) } }
.build()
billingClient.launchBillingFlow(activity, params)
// in onPurchasesUpdated, for a PURCHASED purchase:
X3.purchase(X3Purchase(orderId = purchase.orderId!!, value = 59.99, currency = "EUR", productId = "pro_yearly"))// React Native / Flutter: the same fields
X3.purchase({ orderId: transactionId, value: 59.99, currency: "EUR", productId: "pro_yearly" });| Field | |
|---|---|
orderId | required: StoreKit 2 Transaction.id as a decimal string, the Play orderId (GPA.…) or your own order id; must be the id the confirming server reports |
value, currency | gross amount ≥ 0 and ISO 4217 code |
productId | optional product identifier (up to 80 characters) |
quantity | default 1 |
store | apple, google or other, default by platform |
purchase sends at once. The purchase also counts towards the revenue tiers of the
conversion value schema on iOS when its
currency is the schema currency.
Account token. purchaseAccountToken() returns a UUID derived from the bundle id and the hashed
user id after identify (else nil / null). It reveals neither the user id nor the
e-mail address, but it is a pseudonymous identifier of the person. Apple returns it as
appAccountToken in every App Store Server Notification, Google as
obfuscatedExternalAccountId, so X3 can name the person even for renewals that happen while the app
is closed. The server recomputes the token from the bundle id and the user id hash, so nothing else
needs to be stored.
How X3 finds the person of a confirmed purchase, in this order: a held hint with the same order id
(its session) → the account token (or RevenueCat's app_user_id) through the identity of the
app → the original transaction's earlier notification → none (the purchase still counts in revenue).
App Store Server Notifications
App Store Server Notifications V2 are signed messages Apple sends to one URL per environment for every purchase, renewal, refund and subscription change.
- In Einstellungen › Apps › Nordlicht (iOS) › Käufe (Purchases) copy the URL
https://api.x3growth.io/v1/m/store/apple/<store token>. The store token is secret; rotate it there if it leaks (then update App Store Connect). - If App Store Connect already sends notifications to your own server, enter that URL as Weiterleitung (Forwarding) for Production and, if you use one, for Sandbox. X3 forwards every notification unchanged (forwarding).
- In App Store Connect › Your app › App Information › App Store Server Notifications set the Production URL and the Sandbox URL to the X3 URL, version 2.
- Click Request a Test Notification in App Store Connect; the setup checklist shows it as verified.
X3 verifies every notification: the JWS certificate chain must lead to Apple Root CA G3, the bundle id must match the app, and each notification is processed once (by its UUID). Sandbox notifications are test traffic.
| Notification | X3 |
|---|---|
SUBSCRIBED, DID_RENEW, ONE_TIME_CHARGE, OFFER_REDEEMED with a price | purchase confirmed or created (orderId = transactionId, value = price / 1000, currency) |
SUBSCRIBED with a free trial offer and price 0 | event trial_started on the install's latest session, no purchase |
REFUND | refund of that transaction (net revenue, the conversion stays) |
TEST | marks the setup as verified |
others (DID_FAIL_TO_RENEW, EXPIRED, …) | stored in Store-Benachrichtigungen (Store notifications), no conversion |
Google Play RTDN
Google Play Real-time Developer Notifications travel through Google Cloud Pub/Sub.
- In Google Cloud create a Pub/Sub topic (for example
play-rtdn) and grantgoogle-play-developer-notifications@system.gserviceaccount.comthe role Pub/Sub Publisher on it. - Create a push subscription on the topic with the endpoint
https://api.x3growth.io/v1/m/store/google/<store token>from Einstellungen › Apps › Nordlicht (Android) › Käufe. Enable authentication with a service account of your project and enter its e-mail as Push-Dienstkonto in the same tab (and the audience when it differs from the default): X3 then checks Google's signed token of exactly that account on every push. Without it a notification is unverified – anyone who knows the URL could send one, and any Google service account can sign a token: X3 stores it but never forwards it to your backend and never applies its refund (purchases still need the Play Developer API of step 4 to confirm the purchase token). - In the Play Console › Monetize with Play › Monetization setup enter the topic name
(
projects/<project>/topics/play-rtdn) under Real-time developer notifications and send a test notification. - Optional but recommended: upload the JSON key of a Google Cloud service account that has access to your app in the Play Console (Users and permissions, permission View financial data, orders and cancellation survey responses). With it X3 fetches order id, price, currency and the obfuscated account id of each purchase from the Play Developer API. Without it X3 records only the notification type and token, so renewals cannot be valued.
| Notification | X3 |
|---|---|
SUBSCRIPTION_PURCHASED, SUBSCRIPTION_RENEWED, one-time ONE_TIME_PRODUCT_PURCHASED | purchase confirmed or created (orderId = Play order id; renewals carry GPA.….0, .1 …) |
SUBSCRIPTION_PURCHASED in a free trial phase | event trial_started, no purchase |
voidedPurchaseNotification | refund of that order |
testNotification | marks the setup as verified |
Since Pub/Sub delivers to one push endpoint per subscription, add a second subscription on the same topic for your own backend, or let X3 forward (forwarding).
RevenueCat
If you use RevenueCat, connect its webhook instead of (or next to) the store notifications:
- In RevenueCat open Project settings › Integrations › Webhooks and add a webhook with the URL
https://api.x3growth.io/v1/m/store/revenuecat/<store token>(per app, from the Käufe tab). - Set an Authorization header value (16 to 200 characters) in RevenueCat and the same value in X3 (Käufe › RevenueCat). X3 rejects requests without it.
- Set your app user id in RevenueCat to the same user id you pass to
X3.identify(Purchases.logIn), soapp_user_idnames the person.
| RevenueCat event | X3 |
|---|---|
INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASE, PRODUCT_CHANGE | purchase confirmed or created (orderId = transaction_id, value = price_in_purchased_currency) |
INITIAL_PURCHASE with period_type = TRIAL | event trial_started |
CANCELLATION with cancel_reason = CUSTOMER_SUPPORT | refund |
EXPIRATION, BILLING_ISSUE | stored, no conversion |
TEST | marks the setup as verified |
Events of the SANDBOX environment are test traffic. Use either RevenueCat or the direct store
notifications as the confirmation source per app; both together are deduplicated by order id, but
one source is easier to reason about.
Your server
If your backend already validates purchases (receipt validation, your own App Store Server API client), confirm them with the existing purchase API, using the store transaction id as the order id so it matches the app's hint:
import { createX3Server } from "@x3growth/sdk/server";
const x3 = createX3Server({ apiUrl: "https://api.x3growth.io", apiKey: process.env.X3_SECRET_KEY!, project: process.env.X3_PROJECT_ID });
await x3.purchase({
orderId: transaction.transactionId, // Apple transactionId or Google orderId
value: 59.99,
currency: "EUR",
externalId: user.id, // hashed on arrival, links the person
});The REST endpoint is POST /v1/projects/{project}/purchases (scope conversions:write);
refunds go to POST /v1/projects/{project}/purchases/refunds
(shop purchases). A held app hint with the
same order id is confirmed, not duplicated.
Refunds, renewals and trials
- Renewals are purchases of their own (new transaction or order id) and count every time; the original transaction links them to the same person.
- Refunds reduce net revenue; the conversion is kept and not re-reported (refunds).
- Trials are the server event
trial_startedon the install's latest session: use it in goals, funnels, the conversion value schema or a conversion definition (for example MetaStartTrial). - Test purchases (Sandbox, Play test purchases, RevenueCat sandbox) are test traffic and never reported to ad platforms.
The table Store-Benachrichtigungen (Store notifications) on the Käufe tab lists every notification with type, order id, product, value, whether X3 linked it to a session or identity, the conversion and the forwarding state.
Forwarding
Apple allows one notification URL per environment, and many backends already use it. X3 forwards every accepted Apple, Google or RevenueCat body unchanged to the forward URL you set per app (Apple: separate URLs for Production and Sandbox), with these headers:
| Header | Value |
|---|---|
content-type | the original content type |
x-x3-forwarded | 1 |
x-x3-signature | sha256=<hex HMAC-SHA256 of the body with the app's store token> |
Forward URLs must be https; X3 does not follow redirects, times out after 10 seconds and retries
after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours (giving up after 72 hours).
Your server verifies the payload as before (Apple's JWS, Google's Pub/Sub format), and can check the
X3 signature too:
import { createHmac, timingSafeEqual } from "node:crypto";
export function isFromX3(body: string, header: string | null, storeToken: string): boolean {
if (!header?.startsWith("sha256=")) return false;
const expected = createHmac("sha256", storeToken).update(body).digest("hex");
const given = header.slice("sha256=".length);
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}