Purchase Flow
Implements the full client side purchase sequence: build BillingFlowParams, launch launchBillingFlow(), receive callbacks in PurchasesUpdatedListener, handle every response code, then verify, acknowledge, and grant access.
Phase 0: Intent
Use this skill when you need to:
- Launch a purchase from a "Buy" tap for a one time product or a subscription.
- Wire a
PurchasesUpdatedListenerand handle everyBillingResponseCode. - Decide where to verify, where to acknowledge, and how to grant entitlements idempotently.
- Support pending transactions (cash at convenience store, carrier billing, slow card auth).
- Bundle multiple items into a single transaction (PBL 7.0+).
Out of scope: connecting BillingClient, querying ProductDetails, subscription upgrades or downgrades, and retry or error taxonomy beyond the launch results listed below.
Phase 1: Discovery
Confirm the preconditions before you build params.
| Check | How | Why |
|---|---|---|
BillingClient connected |
billingClient.isReady == true |
Calls fail without a live connection. |
ProductDetails cached |
Held from a prior queryProductDetailsAsync |
Required input to ProductDetailsParams. |
| Pending purchases enabled | BillingClient.Builder.enablePendingPurchases(...) was called |
Users with delayed payment cannot buy otherwise. |
| Existing ownership known | Ran queryPurchasesAsync() recently |
Prevents ITEM_ALREADY_OWNED surprises. |
| Offer token resolved (subs only) | Selected from subscriptionOfferDetails |
Missing token returns DEVELOPER_ERROR. |
Record the product type (INAPP vs SUBS), the selected basePlanId, and the chosen offerToken before moving on.
Phase 2: Plan
Decide the following before you call newBuilder().
Account identifiers
- obfuscatedAccountId: attach a one way hash of your internal user id. Never pass raw email or plain text. Helps Google Play detect fraud across devices.
- obfuscatedProfileId: attach only if your app has multiple profiles under one account (family streaming, kid profiles).
Offer personalization
- Set
setIsOfferPersonalized(true)only if the price was chosen by automated decision making for this user. EU regulation requires a disclosure, which Google Play renders for you. - Default is
false. Do not settruefor uniform promotional pricing.
Offer token selection (subscriptions)
Group offers by base plan, then pick the best eligible offer. Google already filters offers the user cannot redeem.
val offersByPlan = productDetails
.subscriptionOfferDetails
?.groupBy { it.basePlanId }
?: emptyMap()
val chosenOffer = offersByPlan[selectedBasePlanId]
?.firstOrNull() ?: return
Whatever the user tapped in your UI must map to the exact offerToken you pass. Show the first pricingPhases entry (free trial, intro price) in the UI and the recurring phase after it.
Backend contract
Decide whether acknowledge and grant happen server side (primary) or client side (fallback). Server side keeps verify, grant, and acknowledge in one transaction. Keep client side as a fallback when the server is unreachable.
Phase 3: Execute
Build params for a one time product
val productDetailsParams = BillingFlowParams.ProductDetailsParams
.newBuilder()
.setProductDetails(productDetails)
.build()
val flowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetailsParams))
.setObfuscatedAccountId(hashUserId(currentUser.id))
.build()
Build params for a subscription
val productDetailsParams = BillingFlowParams.ProductDetailsParams
.newBuilder()
.setProductDetails(productDetails)
.setOfferToken(chosenOffer.offerToken)
.build()
val flowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetailsParams))
.build()
Launch the flow
val result = billingClient.launchBillingFlow(activity, flowParams)
if (result.responseCode != BillingResponseCode.OK) {
handleLaunchError(result)
}
You must pass the current Activity. OK here means the dialog was shown, not that payment succeeded. The real result arrives in PurchasesUpdatedListener.
Handle launch response codes
| Code | Meaning | Action |
|---|---|---|
OK |
Dialog shown | Wait for listener callback. |
ITEM_ALREADY_OWNED |
User owns product or has unacknowledged purchase | Refresh via queryPurchasesAsync() and process it. |
DEVELOPER_ERROR |
Bad params, missing offer token, wrong package name | Fix params, check Console listing. |
FEATURE_NOT_SUPPORTED |
Device or Play Store does not support the product type | Hide the buy button on this device. |
BILLING_UNAVAILABLE |
No Google account or Play Services missing | Prompt user to sign in to Play. |
ITEM_UNAVAILABLE |
Not available in region or deactivated | Check Console availability. |
NETWORK_ERROR |
Cannot reach Play servers | Retry after delay, do not loop aggressively. |
Receive purchase results
val listener = PurchasesUpdatedListener { result, purchases ->
when (result.responseCode) {
BillingResponseCode.OK ->
purchases?.forEach { handlePurchase(it) }
BillingResponseCode.USER_CANCELED -> Unit
BillingResponseCode.ITEM_ALREADY_OWNED ->
refreshPurchases()
else -> handleError(result)
}
}
Process a successful purchase (5 steps)
Verify server side. Post
purchase.purchaseTokento your backend. Backend callspurchases.products.get(one time) orpurchases.subscriptionsv2.get(subs). CheckpurchaseState == 0,productId,packageName,orderIdnot seen before, and for subsexpiryTimeMillis.Check
purchase.purchaseState. Grant only onPURCHASED. Show pending UI onPENDING. Log onUNSPECIFIED_STATE.Grant entitlement idempotently. Use
purchaseTokenas a unique key. A retry, an RTDN, and a second device query must not stack grants.Acknowledge or consume within 3 days.
// Non consumable and subscriptions val ackParams = AcknowledgePurchaseParams.newBuilder() .setPurchaseToken(purchase.purchaseToken) .build() billingClient.acknowledgePurchase(ackParams) { r -> /* check OK */ }// Consumable val consumeParams = ConsumeParams.newBuilder() .setPurchaseToken(purchase.purchaseToken) .build() billingClient.consumeAsync(consumeParams) { r, t -> /* check OK */ }Prefer server side acknowledgement. Fall back to client side only after retries fail.
Notify the user. Show specific confirmation ("You now have Premium") immediately from the client. For subs, show next billing date and a link to
https://play.google.com/store/account/subscriptions. Return the user to the content they were trying to unlock.
Handle pending transactions
fun handlePurchase(purchase: Purchase) {
when (purchase.purchaseState) {
Purchase.PurchaseState.PENDING -> {
showPendingMessage(purchase)
return
}
Purchase.PurchaseState.PURCHASED -> {
if (!purchase.isAcknowledged) verifyAndGrantAccess(purchase)
}
}
}
Transitions arrive via a later PurchasesUpdatedListener fire, an RTDN on your server, or a fresh queryPurchasesAsync().
Call queryPurchasesAsync at the right moments
- On connect: right after
startConnection()succeeds. Catches cross device purchases and reinstalls. - On app launch: keeps local entitlement cache honest against refunds and expirations.
- On foreground resume: catches cancellations in Play Store and pending purchases that completed in the background.
Multi line item purchases (PBL 7.0+)
Add multiple ProductDetailsParams entries to the list. All items must share a product type (subs or inapp). One purchase token covers the bundle, so acknowledge once, then iterate purchase.products to grant every item.
Phase 4: Verify
Prove the flow works before calling it done.
-
launchBillingFlow()returnsOKfor a real product in an internal test track. -
PurchasesUpdatedListenerfires withOKand a non nullpurchaseslist on success, and withUSER_CANCELEDwhen the user dismisses the dialog. - Every branch in the response code table above has a code path and a log line.
- Server side verification runs before
grantEntitlement. Granting twice with the same token produces the same result (idempotency test). - Every
PURCHASEDpurchase is acknowledged or consumed within 3 days. Monitor unacknowledged count. - A test with a delayed payment method (use the tester card or a convenience store flow in a test region) surfaces a pending state and does not grant access.
- A subscription purchase without
setOfferTokenreturnsDEVELOPER_ERROR(negative test) and your code surfaces it cleanly. -
queryPurchasesAsync()is wired to connect, app launch, andonResume. - For multi line bundles,
purchase.productsyields every product id and each gets an entitlement.