Skip to main content

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:

  1. VLPlay CMS Package record with:

    • code — your internal product slug (e.g. pack_001).
    • storeProductId — the App Store / Play Console product ID (typically com.yourgame.pack_001 on iOS, vn.yourgame.pack_001 on Android — they can differ per platform).
    • typeAPPLE or GOOGLE (drives V3 init-payment lookup).
    • price — VND amount (must match Apple/Google price tier).
    • title, description, items — for catalog rendering.
    • status: 1 (active).
  2. App Store Connect IAP (for iOS):

    • Product ID matching storeProductId.
    • Cleared for Sale status.
    • Localizations (vi + en minimum).
    • Price tier matching price VND.
    • Review screenshot.
  3. 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).

Mismatched mappings (Package missing storeProductId, App Store IAP "Missing Metadata") cause getProductCatalog to return fewer items than CMS lists, or init-payment to return 1032 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.

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")
}
}

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:

FieldTypeDescription
storeProductId / productIdStringThe Apple/Google product ID.
codeStringVLPlay internal slug. Same across platforms.
name / productName / titleStringLocalized product display name.
descriptionStringProduct description for UI.
priceNumberVND amount.
currencyString"VND".
discountPriceNumber?If a sale is active.
itemsArrayList of {itemId, itemName, quantity} for inventory rendering.
isActiveBooleanWhether the product is currently buyable.
sortOrderNumberUI 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.

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.

Behind the Scenes — The 2-Step Flow

  1. Step 1 — init-payment (POST /api/v1/client/purchase/init-payment). Body includes gameId, bundleId, productId, paymentMethod: APPLE|GOOGLE, rechargeType: IN_GAME, roleId, serverGameId, extraData. BE creates a Purchase record in PENDING state, generates a purchaseCode (e.g. GG2605...), and returns it.
  2. Native store sheet opens — StoreKit on iOS, Google Play Billing on Android. Player completes (or cancels) the purchase.
  3. Step 2 — complete-payment (POST /api/v1/client/purchase/complete-payment). Body includes purchaseCode (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, returns statusCode: 0; on game-server failure, returns 1036 REQUEST_TO_GAME_FAIL with paymentStatus: 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):

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)")
}
}

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

CodeMeaningYour handling
1030Product/bundle config mismatchApple/Google receipt's bundle_id doesn't match BE config. Re-check your Bundle Identifier.
1031Bundle ID mismatchSame as above.
1032Package not foundHand productId to your VLPlay rep — Package record needs storeProductId populated.
1034Duplicate transactionTreated as success internally — SDK consumes and fires success. You should never see this in your callback.
1036Game-server delivery failedTreated 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.
1049Transaction not foundHistory endpoint returning empty list — coerced to success + empty list by SDK.
1050Already completedReplay 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 delivered purchaseCodes 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).