In-App Purchase
VLPlay SDK wraps App Store StoreKit (iOS) and Google Play Billing 7 (Android) into a single 2-step purchase flow that's identical across platforms from your game's perspective. Step 1 (init-payment) registers a pending order with VLPlay backend before the native store sheet opens, returning a purchaseCode that ties the entire purchase to a single record. Step 2 (complete-payment) submits the receipt for server-to-server verification with Apple / Google and delivers the package to your game-server callback. If anything between steps fails (network drop, app force-stop, sandbox replay), the flow is idempotent — 1034 PURCHASE_HAS_EXIST is treated as success and the SDK consumes the transaction normally.
This 2-step architecture replaces the deprecated single-call /purchase/apple + /purchase/google endpoints (which are kept ABI-compatible but no longer reachable from new SDK code as of 2026-05-04). The new flow gives partners three operational benefits: (1) the purchaseCode is created BEFORE the player is charged, so accounting can reconcile attempted-but-not-completed purchases; (2) delivery_pending state surfaces game-server delivery failures so VLPlay can retry without re-charging; (3) cold-launch sandbox replay (Apple's "killed app re-delivers transaction" behavior) works correctly without spurious double-charges.
This page covers the full flow: configuring products in the VLPlay CMS + App Store Connect / Play Console, fetching the catalog at runtime, triggering a purchase from a player tap, what your delegate / listener will see, and how to handle the four failure modes.
Configuring Products
Each product needs three matching configurations:
-
VLPlay CMS Package record with:
code— your internal product slug (e.g.pack_001).storeProductId— the App Store / Play Console product ID (typicallycom.yourgame.pack_001on iOS,vn.yourgame.pack_001on Android — they can differ per platform).type—APPLEorGOOGLE(drives V3 init-payment lookup).price— VND amount (must match Apple/Google price tier).title,description,items— for catalog rendering.status: 1(active).
-
App Store Connect IAP (for iOS):
- Product ID matching
storeProductId. - Cleared for Sale status.
- Localizations (vi + en minimum).
- Price tier matching
priceVND. - Review screenshot.
- Product ID matching
-
Google Play Console IAP (for Android):
- Product ID matching
storeProductId. - Active status.
- Localizations + price.
- Type: Consumable (for IN_GAME currency) or Non-consumable (for unlocks).
- Product ID matching
Mismatched mappings (Package missing
storeProductId, App Store IAP "Missing Metadata") causegetProductCatalogto return fewer items than CMS lists, orinit-paymentto return1032 PACKAGE_GAME_NOT_FOUND. See iOS Troubleshooting / Android Troubleshooting.
Fetching the Catalog
Call getProductCatalog to fetch the active package list for the current game. The SDK queries GET /api/v1/client/purchase/products?clientId=... and returns a list of StorePackage records.
- iOS
- Android (Kotlin)
SDKManager.default()?.getProductCatalog { status, packages, error in
guard status, let list = packages else {
print("Catalog error:", error?.localizedDescription ?? "")
return
}
for pkg in list {
// VLPackage / StorePackage shape — see API Reference for fields
let id = pkg["storeProductId"] as? String ?? ""
let name = pkg["productName"] as? String ?? ""
let price = pkg["price"] as? Double ?? 0
let currency = pkg["currency"] as? String ?? "VND"
let items = pkg["items"] as? [[String: Any]] ?? []
print("\(id) — \(name) — \(price) \(currency) — \(items.count) items")
}
}
VLPlaySDKManager.getProductCatalog(object : VLPlaySDKManager.ProductCatalogListener {
override fun onSuccess(packages: List<StorePackage>) {
packages.forEach { pkg ->
Log.d("VLPlay", "${pkg.storeProductId} — ${pkg.name} — ${pkg.priceVND} VND")
}
}
override fun onFailure(message: String, errorCode: Int) {
Log.e("VLPlay", "Catalog fail [$errorCode]: $message")
}
})
The returned list is per-platform — VLPlay BE filters Package records by type = APPLE | GOOGLE based on the SDK that's calling. So your iOS app gets only type=APPLE packages, Android gets type=GOOGLE. If you want to render a unified shop across both, you can mirror the same code across two records (one APPLE, one GOOGLE) with different storeProductId values.
Catalog response shape
Each StorePackage exposes:
| Field | Type | Description |
|---|---|---|
storeProductId / productId | String | The Apple/Google product ID. |
code | String | VLPlay internal slug. Same across platforms. |
name / productName / title | String | Localized product display name. |
description | String | Product description for UI. |
price | Number | VND amount. |
currency | String | "VND". |
discountPrice | Number? | If a sale is active. |
items | Array | List of {itemId, itemName, quantity} for inventory rendering. |
isActive | Boolean | Whether the product is currently buyable. |
sortOrder | Number | UI sort hint. |
Triggering a Purchase
The simplest entry point: call purchasePackage with the product the player tapped. The SDK orchestrates the entire 2-step flow.
- iOS
- Android (Kotlin)
guard let sdk = SDKManager.default() else { return }
sdk.purchasePackage(
withProductId: "com.yourgame.pack_001",
productName: "100 Gems",
amount: 22000, // VND, must match Package.price in CMS
parentController: self, // UIViewController hosting the buy button
completion: { status, error in
if status {
// Purchase succeeded + receipt verified server-side
// Items already delivered to game-server via VLPlay's backend webhook
self.refreshInventoryFromGameServer()
} else {
print("Purchase failed:", error?.localizedDescription ?? "unknown")
}
}
)
The completion fires once on terminal state. sdkManagerDidPurchaseSuccessfully also fires on success — both notifications are emitted, so you can pick whichever pattern fits your code.
VLPlaySDKManager.purchasePackage(
productId = "vn.yourgame.pack_001",
productName = "100 Gems",
amount = 22000.0, // VND, must match Package.price in CMS
activity = this,
listener = object : VLPlaySDKManager.PurchaseListenerV3 {
override fun onSuccess(transactionId: String, productId: String, deliveryPending: Boolean) {
// Charged + verified
if (deliveryPending) {
// Game-server delivery failed. VLPlay BE retries on a 2-min schedule.
// Show a "delivery pending" state in your UI; player will see items appear later.
showDeliveryPendingNotice()
} else {
refreshInventoryFromGameServer()
}
}
override fun onFailure(message: String, errorCode: Int) {
Log.e("VLPlay", "Purchase fail [$errorCode]: $message")
}
override fun onCancel() {
// Player tapped cancel on Google Play sheet — no charge, no record
}
},
)
Behind the Scenes — The 2-Step Flow
- Step 1 —
init-payment(POST /api/v1/client/purchase/init-payment). Body includesgameId,bundleId,productId,paymentMethod: APPLE|GOOGLE,rechargeType: IN_GAME,roleId,serverGameId,extraData. BE creates a Purchase record inPENDINGstate, generates apurchaseCode(e.g.GG2605...), and returns it. - Native store sheet opens — StoreKit on iOS, Google Play Billing on Android. Player completes (or cancels) the purchase.
- Step 2 —
complete-payment(POST /api/v1/client/purchase/complete-payment). Body includespurchaseCode(from step 1),receiptData(Apple base64 receipt or Google purchaseToken),externalTransactionId(Apple transactionId or Google orderId),extraData. BE verifies the receipt with Apple/Google, then calls your game-server delivery webhook. On success, returnsstatusCode: 0; on game-server failure, returns1036 REQUEST_TO_GAME_FAILwithpaymentStatus: delivery_pending.
Both endpoints serve Apple AND Google traffic — only the paymentMethod body field differs. Your game doesn't need to switch APIs based on platform.
Cold-Launch Sandbox Replay
Apple sandbox + Google Play Billing both replay un-finished transactions on cold launch. The SDK detects this and runs retroactive init-payment for replayed transactions, then proceeds to complete-payment. BE returns 1034 PURCHASE_HAS_EXIST for the retroactive init-payment — the SDK treats this as success and proceeds to verify.
This means: don't add your own retry logic on top of the SDK. Don't call consumeAsync manually. Let the SDK drive the queue — it correctly handles re-delivery without double-charging.
Purchase History
Fetch the player's confirmed purchase history (across both Apple and Google):
- iOS
- Android (Kotlin)
SDKManager.default()?.listSuccessfulTransactions(atPage: 1, size: 20) { status, items, error in
guard status, let list = items else { return }
for item in list {
let code = item["code"] as? String ?? ""
let productId = item["productId"] as? String ?? ""
let amount = item["amount"] as? Double ?? 0
let createdAt = item["createdAt"] as? String ?? ""
let paymentStatus = item["paymentStatus"] as? String ?? "?"
print("\(code) — \(productId) — \(amount) VND — \(paymentStatus) — \(createdAt)")
}
}
VLPlaySDKManager.listSuccessfulTransactions(
page = 1,
size = 20,
object : VLPlaySDKManager.PurchaseHistoryListener {
override fun onSuccess(items: List<TransactionItem>, total: Int, page: Int, limit: Int) {
items.forEach { t ->
Log.d("VLPlay", "${t.code} — ${t.productId} — ${t.amount} ${t.currency}")
}
}
override fun onFailure(message: String, errorCode: Int) {
// Special case: 1049 = TRANSACTION_NOT_FOUND, treated as empty list internally
}
}
)
The list endpoint is GET /api/v1/client/purchase/?page=&limit= (note trailing slash). It returns both APPLE and GOOGLE records filtered by the player's sdkUserId. Per-item fields:
{
"_id": "...",
"code": "GG260504346...",
"gameUserId": "...",
"productId": "com.yourgame.pack_001",
"bundleId": "com.yourgame",
"type": "APPLE",
"rechargeType": "IN_GAME",
"paymentStatus": "success | failed | pending | delivery_pending",
"amount": 22000,
"transactionId": "...",
"originalTransactionId": "...",
"roleId": "char-12345",
"roleName": "PlayerOne",
"serverGameId": "server-01",
"gameId": "65a1f3b...",
"createdAt": "2026-05-04T12:34:56.789Z",
"updatedAt": "..."
}
Failure Modes
| Code | Meaning | Your handling |
|---|---|---|
| 1030 | Product/bundle config mismatch | Apple/Google receipt's bundle_id doesn't match BE config. Re-check your Bundle Identifier. |
| 1031 | Bundle ID mismatch | Same as above. |
| 1032 | Package not found | Hand productId to your VLPlay rep — Package record needs storeProductId populated. |
| 1034 | Duplicate transaction | Treated as success internally — SDK consumes and fires success. You should never see this in your callback. |
| 1036 | Game-server delivery failed | Treated as success on the SDK side, but deliveryPending=true in callback. Show "items will arrive shortly" UI; VLPlay retries delivery on a 2-min schedule. |
| 1049 | Transaction not found | History endpoint returning empty list — coerced to success + empty list by SDK. |
| 1050 | Already completed | Replay path completed by another process. SDK treats as success. |
Game-Server Delivery Webhook
When complete-payment succeeds, VLPlay backend POSTs to your game-server's delivery webhook (URL configured per game in CMS). The webhook receives:
{
"purchaseCode": "GG260504346...",
"gameUserId": "...",
"productId": "com.yourgame.pack_001",
"amount": 22000,
"currency": "VND",
"items": [{ "itemId": "gem", "quantity": 100 }],
"roleId": "char-12345",
"serverGameId": "server-01",
"transactionId": "...",
"platform": "APPLE"
}
Respond with HTTP 200 + {"success": true} to confirm delivery. Anything else triggers 1036 delivery_pending and a retry. The retry budget is 8 attempts over 24 hours; after that, VLPlay marks the order FAILED and refunds via the platform store dispute system.
Make your delivery webhook idempotent on
purchaseCode— VLPlay may retry several times. The wrong way is to credit items every time the webhook is called; the right way is to track deliveredpurchaseCodes in your DB and short-circuit on duplicate.
AppsFlyer Funnel
The SDK fires AppsFlyer events at every step of the V3 flow (gated by the appsFlyerTracking CMS flag):
sdk_init → at SDKManager bootstrap
purchase_initiated → on getProductCatalog
product_selected → on player tap
sdk_payment_init → at start of init-payment
sdk_payment_callback_success / fail / cancel → at native store sheet result
sdk_payment_validated → at start of complete-payment
sdk_payment_validated_success / fail → at complete-payment result
sdk_payment_completed → at consume / finishTransaction
af_purchase → AppsFlyer's standard purchase event with revenue + currency
Your AppsFlyer dashboard exposes the full funnel without any additional code on your side. Custom events on top of this funnel can be added via hitActivityFirebase (Android) or AppsFlyerLib directly (iOS — the legacy hitActivity family is dead code in SDK 1.0; see the iOS API Reference).