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 reported

There 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
orderIdrequired: 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, currencygross amount ≥ 0 and ISO 4217 code
productIdoptional product identifier (up to 80 characters)
quantitydefault 1
storeapple, 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.

  1. 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).
  2. 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).
  3. 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.
  4. 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.

NotificationX3
SUBSCRIBED, DID_RENEW, ONE_TIME_CHARGE, OFFER_REDEEMED with a pricepurchase confirmed or created (orderId = transactionId, value = price / 1000, currency)
SUBSCRIBED with a free trial offer and price 0event trial_started on the install's latest session, no purchase
REFUNDrefund of that transaction (net revenue, the conversion stays)
TESTmarks 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.

  1. In Google Cloud create a Pub/Sub topic (for example play-rtdn) and grant google-play-developer-notifications@system.gserviceaccount.com the role Pub/Sub Publisher on it.
  2. 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).
  3. 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.
  4. 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.
NotificationX3
SUBSCRIPTION_PURCHASED, SUBSCRIPTION_RENEWED, one-time ONE_TIME_PRODUCT_PURCHASEDpurchase confirmed or created (orderId = Play order id; renewals carry GPA.….0, .1 …)
SUBSCRIPTION_PURCHASED in a free trial phaseevent trial_started, no purchase
voidedPurchaseNotificationrefund of that order
testNotificationmarks 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:

  1. 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).
  2. 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.
  3. Set your app user id in RevenueCat to the same user id you pass to X3.identify (Purchases.logIn), so app_user_id names the person.
RevenueCat eventX3
INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASE, PRODUCT_CHANGEpurchase confirmed or created (orderId = transaction_id, value = price_in_purchased_currency)
INITIAL_PURCHASE with period_type = TRIALevent trial_started
CANCELLATION with cancel_reason = CUSTOMER_SUPPORTrefund
EXPIRATION, BILLING_ISSUEstored, no conversion
TESTmarks 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:

server/confirm-purchase.ts
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_started on the install's latest session: use it in goals, funnels, the conversion value schema or a conversion definition (for example Meta StartTrial).
  • 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:

HeaderValue
content-typethe original content type
x-x3-forwarded1
x-x3-signaturesha256=<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:

server/verify-x3-forward.ts
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));
}