# OpenIAP Complete Reference > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Quick Reference: https://openiap.dev/llms.txt > Generated: 2026-05-08T10:48:46.777Z ## Table of Contents 1. Installation 2. Core APIs (Connection, Products, Purchase, Subscription) 3. Platform-Specific APIs (iOS, Android) 4. Types Reference 5. Error Codes & Handling 6. Implementation Patterns --- ## 1. Installation ### React Native / Expo ```bash # expo-iap (Expo projects - recommended) npx expo install expo-iap # react-native-iap (React Native CLI) npm install react-native-iap cd ios && pod install ``` ### Swift (iOS/macOS) ```swift // Swift Package Manager .package(url: "https://github.com/hyodotdev/openiap.git", from: "1.0.0") // CocoaPods pod 'openiap', '~> 1.0.0' ``` ### Kotlin (Android) ```kotlin // Gradle (build.gradle.kts) implementation("io.github.hyochan.openiap:openiap-google:1.0.0") // For Meta Horizon OS implementation("io.github.hyochan.openiap:openiap-google-horizon:1.0.0") ``` ### Flutter ```yaml # pubspec.yaml dependencies: flutter_inapp_purchase: ^5.0.0 ``` ### Godot Download `godot-iap` from the Godot Asset Library or GitHub Releases, extract it to `addons/godot-iap/`, then enable the plugin in Project Settings. ### Kotlin Multiplatform ```kotlin dependencies { implementation("io.github.hyochan.kmpiap:library:1.3.8") } ``` ### .NET MAUI ```xml ``` Requires .NET 9+, the MAUI workload, iOS 15.0+, and Android API 24+. --- ## Framework SDK Implementations ### react-native-iap - Package: `react-native-iap` on npm. - Implementation: Nitro Modules wrapper over `packages/apple` and `packages/google`. - Public surface: generated OpenIAP types plus `useIAP`, listener helpers, and platform-suffixed iOS/Android APIs. - Example app: `libraries/react-native-iap/example`. ### expo-iap - Package: `expo-iap` on npm. - Implementation: Expo Modules wrapper over the same native OpenIAP packages. - Public surface: same hook, listener, query, mutation, and platform API shape as `react-native-iap`, adapted for Expo managed/bare workflows. - Example app: `libraries/expo-iap/example`. ### flutter_inapp_purchase - Package: `flutter_inapp_purchase` on pub.dev. - Implementation: Dart API plus generated `types.dart`, bridged to native iOS and Android method channels. - Public surface: singleton `FlutterInappPurchase.instance`, typed `fetchProducts`, purchase streams, and resolver-style methods. ### godot-iap - Package: `godot-iap` for Godot 4.x. - Implementation: GDScript API with generated `types.gd`, plus native iOS GDExtension and Android AAR plugin. - Public surface: snake_case functions and Godot signals matching OpenIAP. ### kmp-iap - Package: `io.github.hyochan.kmpiap:library`. - Implementation: Kotlin Multiplatform common API with Flow-based events, Android implementation, and iOS cinterop through the OpenIAP ObjC facade. - Public surface: `KmpIAP` / shared instance resolver methods and flows. ### maui-iap - Package: `OpenIap.Maui` on NuGet. - Distribution: single public NuGet package. The Android/iOS binding projects are private implementation details and are flattened into `OpenIap.Maui` instead of being published as separate package dependencies. - Implementation: .NET MAUI projection with generated `Types.cs`, a static `Iap.Instance` facade, `IOpenIap` observables, and per-platform resolvers. - iOS/macCatalyst bridge: .NET-for-iOS binding over `OpenIAP.xcframework` and `OpenIapModule+ObjC.swift`; NuGet consumers get the official `OpenIap.Maui.Bindings.iOS.resources.zip` sidecar so no app-level `NativeReference` is required. - Android bridge: Xamarin.Android binding over the MAUI-owned `openiap-release.aar`, which wraps the unbound `openiap-play-release.aar` runtime dependency; resolved BillingClient / Play Services AARs are included in the main nupkg for app packaging. - Public surface: `QueryResolver`, `MutationResolver`, and `IOpenIap` implemented by `OpenIapIOS`, `OpenIapAndroid`, and `OpenIapMacCatalyst`; IAPKit helpers mirror the TypeScript SDKs via `Iap.KitApi(...)`, `Iap.ConnectWebhookStream(...)`, `Iap.ParseWebhookEventData(...)`, and `Iap.WebhookEventTypes`. - Example app: `libraries/maui-iap/example/OpenIap.Maui.Example`, mirroring the `expo-iap` example flows. --- ## Minimal Usage by Framework ### React Native / Expo ```typescript import { useIAP } from 'expo-iap'; // or 'react-native-iap' const { connected, fetchProducts, requestPurchase, finishTransaction } = useIAP({ onPurchaseSuccess: async (purchase) => { await finishTransaction({ purchase, isConsumable: true }); }, }); await fetchProducts({ skus: ['premium'], type: 'in-app' }); await requestPurchase({ request: { ios: { sku: 'premium' }, android: { skus: ['premium'] } }, type: 'in-app', }); ``` ### Flutter ```dart final iap = FlutterInappPurchase.instance; await iap.initConnection(); final products = await iap.fetchProducts( skus: ['premium'], type: ProductQueryType.inApp, ); iap.purchaseUpdated.listen((purchase) async { await iap.finishTransaction(purchase, isConsumable: true); }); ``` ### Godot ```gdscript GodotIapPlugin.purchase_updated.connect(_on_purchase_updated) GodotIapPlugin.init_connection() GodotIapPlugin.fetch_products(request) GodotIapPlugin.request_purchase(props) ``` ### Kotlin Multiplatform ```kotlin val iap = KmpIAP() iap.initConnection() val products = iap.fetchProducts(skus = listOf("premium")) iap.purchaseUpdatedListener.collect { purchase -> iap.finishTransaction(purchase = purchase, isConsumable = true) } ``` ### .NET MAUI ```csharp using OpenIap; using OpenIap.Maui; var iap = Iap.Instance; await ((MutationResolver)iap).InitConnectionAsync(); await ((QueryResolver)iap).FetchProductsAsync(new ProductRequest { Skus = ["premium"], Type = ProductQueryType.InApp, }); ((IOpenIap)iap).PurchaseUpdated.Subscribe(async purchase => { await ((MutationResolver)iap).FinishTransactionAsync( new PurchaseInput(purchase), isConsumable: true ); }); ``` --- # expo-iap API Reference > Reference documentation for expo-iap (Expo In-App Purchase module) > Adapt all patterns to match OpenIAP internal conventions. ## Overview expo-iap is the Expo-compatible version of react-native-iap, providing in-app purchase functionality for both iOS and Android in Expo projects. ## Installation ```bash npx expo install expo-iap ``` ## Connection Management ### initConnection Initialize connection to the app store. ```typescript import { initConnection } from 'expo-iap'; await initConnection(); ``` ### endConnection Close connection to the app store. ```typescript import { endConnection } from 'expo-iap'; await endConnection(); ``` ## Product Operations ### fetchProducts Fetch product information from the store. ```typescript import { fetchProducts } from 'expo-iap'; const products = await fetchProducts(['com.app.product1', 'com.app.sub_monthly']); ``` **Returns:** `Promise` ### Product Type ```typescript interface Product { productId: string; title: string; description: string; price: string; currency: string; localizedPrice: string; type: ProductType; // 'iap' | 'sub' // iOS only subscriptionPeriodNumberIOS?: string; subscriptionPeriodUnitIOS?: string; introductoryPrice?: string; introductoryPricePaymentModeIOS?: string; introductoryPriceNumberOfPeriodsIOS?: string; introductoryPriceSubscriptionPeriodIOS?: string; // Android only subscriptionOfferDetailsAndroid?: SubscriptionOffer[]; oneTimePurchaseOfferDetailsAndroid?: OneTimePurchaseOffer; } ``` ## Purchase Operations ### requestPurchase Initiate a purchase. ```typescript import { requestPurchase } from 'expo-iap'; // For consumables/non-consumables await requestPurchase({ sku: 'com.app.product1' }); // For subscriptions (Android) await requestPurchase({ sku: 'com.app.sub_monthly', subscriptionOffers: [{ sku: 'com.app.sub_monthly', offerToken: 'token' }] }); ``` ### finishTransaction Complete a transaction after processing. ```typescript import { finishTransaction } from 'expo-iap'; await finishTransaction({ purchase, isConsumable: true }); ``` ### getAvailablePurchases Get user's existing purchases (restore purchases). ```typescript import { getAvailablePurchases } from 'expo-iap'; const purchases = await getAvailablePurchases(); ``` ## Purchase Type ```typescript interface Purchase { productId: string; transactionId?: string; transactionDate: number; transactionReceipt: string; purchaseToken?: string; // Android // iOS only originalTransactionDateIOS?: number; originalTransactionIdentifierIOS?: string; // Android only purchaseStateAndroid?: number; isAcknowledgedAndroid?: boolean; packageNameAndroid?: string; obfuscatedAccountIdAndroid?: string; obfuscatedProfileIdAndroid?: string; } ``` ## iOS-Specific Functions ### clearTransactionIOS Clear finished transactions from the queue. ```typescript import { clearTransactionIOS } from 'expo-iap'; await clearTransactionIOS(); ``` ### getReceiptDataIOS Get the receipt data for validation. ```typescript import { getReceiptDataIOS } from 'expo-iap'; const receipt = await getReceiptDataIOS(); ``` ### syncIOS Sync transactions with the App Store. ```typescript import { syncIOS } from 'expo-iap'; await syncIOS(); ``` ### presentCodeRedemptionSheetIOS Show the offer code redemption sheet. ```typescript import { presentCodeRedemptionSheetIOS } from 'expo-iap'; await presentCodeRedemptionSheetIOS(); ``` ### showManageSubscriptionsIOS Open subscription management in App Store. ```typescript import { showManageSubscriptionsIOS } from 'expo-iap'; await showManageSubscriptionsIOS(); ``` ### isEligibleForIntroOfferIOS Check intro offer eligibility. ```typescript import { isEligibleForIntroOfferIOS } from 'expo-iap'; const eligible = await isEligibleForIntroOfferIOS('com.app.sub_monthly'); ``` ### beginRefundRequestIOS Start a refund request. ```typescript import { beginRefundRequestIOS } from 'expo-iap'; const result = await beginRefundRequestIOS('transaction_id'); ``` ## Android-Specific Functions ### acknowledgePurchaseAndroid Acknowledge a purchase (required within 3 days). ```typescript import { acknowledgePurchaseAndroid } from 'expo-iap'; await acknowledgePurchaseAndroid({ token: purchase.purchaseToken }); ``` ### consumePurchaseAndroid Consume a consumable purchase. ```typescript import { consumePurchaseAndroid } from 'expo-iap'; await consumePurchaseAndroid({ token: purchase.purchaseToken }); ``` ### getPackageNameAndroid Get the app's package name. ```typescript import { getPackageNameAndroid } from 'expo-iap'; const packageName = await getPackageNameAndroid(); ``` ## Cross-Platform Functions ### getActiveSubscriptions Get active subscriptions. ```typescript import { getActiveSubscriptions } from 'expo-iap'; const subscriptions = await getActiveSubscriptions(['com.app.sub_monthly']); ``` ### hasActiveSubscriptions Check if user has active subscriptions. ```typescript import { hasActiveSubscriptions } from 'expo-iap'; const hasActive = await hasActiveSubscriptions(['com.app.sub_monthly']); ``` ### deepLinkToSubscriptions Open subscription management on both platforms. ```typescript import { deepLinkToSubscriptions } from 'expo-iap'; await deepLinkToSubscriptions({ sku: 'com.app.sub_monthly' }); ``` ### getStorefront Get storefront information. ```typescript import { getStorefront } from 'expo-iap'; const storefront = await getStorefront(); // { countryCode: 'US', ... } ``` ## Event Listeners ### purchaseUpdatedListener Listen for purchase updates. ```typescript import { purchaseUpdatedListener } from 'expo-iap'; const subscription = purchaseUpdatedListener((purchase) => { console.log('Purchase updated:', purchase); // Process and finish transaction }); // Cleanup subscription.remove(); ``` ### purchaseErrorListener Listen for purchase errors. ```typescript import { purchaseErrorListener } from 'expo-iap'; const subscription = purchaseErrorListener((error) => { console.error('Purchase error:', error); }); // Cleanup subscription.remove(); ``` ## Error Codes | Code | Description | |------|-------------| | `E_UNKNOWN` | Unknown error | | `E_USER_CANCELLED` | User cancelled | | `E_ITEM_UNAVAILABLE` | Item not available | | `E_NETWORK_ERROR` | Network error | | `E_SERVICE_ERROR` | Store service error | | `E_ALREADY_OWNED` | Item already owned | | `E_NOT_PREPARED` | Not initialized | | `E_NOT_ENDED` | Connection not ended | | `E_DEVELOPER_ERROR` | Developer error | ## Usage Pattern ```typescript import { initConnection, endConnection, fetchProducts, requestPurchase, finishTransaction, purchaseUpdatedListener, purchaseErrorListener, } from 'expo-iap'; // Setup await initConnection(); const purchaseListener = purchaseUpdatedListener(async (purchase) => { // Verify purchase server-side // Then finish transaction await finishTransaction({ purchase, isConsumable: false }); }); const errorListener = purchaseErrorListener((error) => { console.error(error); }); // Fetch products const products = await fetchProducts(['com.app.premium']); // Make purchase await requestPurchase({ sku: 'com.app.premium' }); // Cleanup purchaseListener.remove(); errorListener.remove(); await endConnection(); ``` --- # Google Play Billing Library API Reference > Reference documentation for Google Play Billing Library 8.x > Adapt all patterns to match OpenIAP internal conventions. ## Overview Google Play Billing Library enables in-app purchases and subscriptions on Android devices. ## Version History | Version | Release Date | Key Features | |---------|--------------|--------------| | 8.0 | 2025-06-30 | Auto-reconnect, product-level status codes, one-time products with multiple offers, sub-response codes | | 8.1 | 2025-11-06 | Suspended subscriptions (`isSuspended`), `includeSuspended` parameter, pre-order details, product-level subscription replacement, `KEEP_EXISTING` mode | | 8.2 | 2025-12-09 | Billing Programs API (external content links, external offers), deprecates old External Offers API | | 8.2.1 | 2025-12-15 | Bug fix for `isBillingProgramAvailableAsync()` and `createBillingProgramReportingDetailsAsync()` | | 8.3 | 2025-12-23 | External Payments program (Japan only), developer billing options | **Current Version**: 8.3.0 (as of January 2026) ## Core Classes ### BillingClient The main interface for communicating with Google Play Billing. ```kotlin val billingClient = BillingClient.newBuilder(context) .setListener(purchasesUpdatedListener) .enablePendingPurchases() // New in 8.0: Auto-reconnect on service disconnect .enableAutoServiceReconnection() .build() ``` ### Auto Service Reconnection (8.0+) ```kotlin // Enables automatic reconnection when service disconnects BillingClient.newBuilder(context) .enableAutoServiceReconnection() .build() ``` When enabled, the library automatically re-establishes the connection if an API call is made while disconnected. This reduces `SERVICE_DISCONNECTED` errors. > **OpenIAP Note**: Auto-reconnection is **always enabled** internally since OpenIAP uses Billing Library 8.3.0+. No configuration needed. ### Connection Management ```kotlin billingClient.startConnection(object : BillingClientStateListener { override fun onBillingSetupFinished(billingResult: BillingResult) { if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { // Ready to query purchases } } override fun onBillingServiceDisconnected() { // Reconnect on next request } }) ``` ## Product Details ### QueryProductDetailsParams ```kotlin val productList = listOf( QueryProductDetailsParams.Product.newBuilder() .setProductId("product_id") .setProductType(BillingClient.ProductType.SUBS) // or INAPP .build() ) val params = QueryProductDetailsParams.newBuilder() .setProductList(productList) .build() billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList -> // Handle product details } ``` ### ProductDetails Properties | Property | Type | Description | |----------|------|-------------| | `productId` | String | Unique product identifier | | `productType` | String | "subs" or "inapp" | | `title` | String | Localized product title | | `name` | String | Product name | | `description` | String | Localized description | | `oneTimePurchaseOfferDetails` | Object | For INAPP products | | `subscriptionOfferDetails` | List | For subscription products | ### Subscription Offer Details ```kotlin data class SubscriptionOfferDetails( val basePlanId: String, val offerId: String?, val offerToken: String, val pricingPhases: PricingPhases, val offerTags: List ) ``` ### Pricing Phases ```kotlin data class PricingPhase( val formattedPrice: String, val priceAmountMicros: Long, val priceCurrencyCode: String, val billingPeriod: String, // ISO 8601 (P1W, P1M, P1Y) val billingCycleCount: Int, val recurrenceMode: Int // FINITE or INFINITE ) ``` ## Purchase Flow ### Launch Purchase Flow ```kotlin val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder() .setProductDetails(productDetails) .setOfferToken(offerToken) // For subscriptions .build() val billingFlowParams = BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf(productDetailsParams)) .build() val billingResult = billingClient.launchBillingFlow(activity, billingFlowParams) ``` ### PurchasesUpdatedListener ```kotlin val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases -> when (billingResult.responseCode) { BillingClient.BillingResponseCode.OK -> { purchases?.forEach { purchase -> handlePurchase(purchase) } } BillingClient.BillingResponseCode.USER_CANCELED -> { // User cancelled } else -> { // Handle error } } } ``` ## Purchase Verification & Acknowledgement ### Verify Purchase ```kotlin val purchase: Purchase // Check purchase state if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { // Verify signature server-side // Then acknowledge or consume } ``` ### Acknowledge Purchase (Subscriptions/Non-consumables) ```kotlin if (!purchase.isAcknowledged) { val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder() .setPurchaseToken(purchase.purchaseToken) .build() billingClient.acknowledgePurchase(acknowledgePurchaseParams) { billingResult -> // Handle result } } ``` ### Consume Purchase (Consumables) ```kotlin val consumeParams = ConsumeParams.newBuilder() .setPurchaseToken(purchase.purchaseToken) .build() billingClient.consumeAsync(consumeParams) { billingResult, purchaseToken -> // Handle result } ``` ## Query Existing Purchases ```kotlin // Query subscriptions billingClient.queryPurchasesAsync( QueryPurchasesParams.newBuilder() .setProductType(BillingClient.ProductType.SUBS) .build() ) { billingResult, purchasesList -> // Handle existing subscriptions } // Query in-app products billingClient.queryPurchasesAsync( QueryPurchasesParams.newBuilder() .setProductType(BillingClient.ProductType.INAPP) .build() ) { billingResult, purchasesList -> // Handle existing purchases } ``` ## Purchase Properties | Property | Type | Description | |----------|------|-------------| | `orderId` | String | Unique order identifier | | `purchaseToken` | String | Token for verification | | `purchaseState` | Int | PENDING, PURCHASED, UNSPECIFIED | | `purchaseTime` | Long | Timestamp in milliseconds | | `products` | List | Product IDs in purchase | | `isAcknowledged` | Boolean | Whether acknowledged | | `isAutoRenewing` | Boolean | Auto-renewal status | | `quantity` | Int | Quantity purchased | ## Response Codes | Code | Constant | Description | |------|----------|-------------| | 0 | OK | Success | | 1 | USER_CANCELED | User cancelled | | 2 | SERVICE_UNAVAILABLE | Network error | | 3 | BILLING_UNAVAILABLE | Billing not available | | 4 | ITEM_UNAVAILABLE | Item not available | | 5 | DEVELOPER_ERROR | Invalid arguments | | 6 | ERROR | Fatal error | | 7 | ITEM_ALREADY_OWNED | Already owned | | 8 | ITEM_NOT_OWNED | Not owned | ## Feature Support ```kotlin // Check if feature is supported val result = billingClient.isFeatureSupported(BillingClient.FeatureType.SUBSCRIPTIONS) if (result.responseCode == BillingClient.BillingResponseCode.OK) { // Subscriptions are supported } ``` ### Feature Types - `SUBSCRIPTIONS` - Subscription support - `SUBSCRIPTIONS_UPDATE` - Subscription upgrades/downgrades - `PRICE_CHANGE_CONFIRMATION` - Price change confirmation - `PRODUCT_DETAILS` - Product details API ## Product-Level Status Codes (8.0+) In Billing Library 8.0+, `queryProductDetailsAsync()` returns products that couldn't be fetched with a status code explaining why. ```kotlin billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList -> productDetailsList.forEach { productDetails -> when (productDetails.productStatus) { ProductDetails.ProductStatus.OK -> { // Product fetched successfully } ProductDetails.ProductStatus.NOT_FOUND -> { // SKU doesn't exist in Play Console } ProductDetails.ProductStatus.NO_OFFERS_AVAILABLE -> { // User not eligible for any offers } } } } ``` | Status | Description | |--------|-------------| | `OK` | Product fetched successfully | | `NOT_FOUND` | SKU doesn't exist in Play Console | | `NO_OFFERS_AVAILABLE` | User not eligible for any offers | ## Suspended Subscriptions (8.1+) ```kotlin val purchase: Purchase // Check if subscription is suspended due to billing issue if (purchase.isSuspended) { // User's payment method failed // Do NOT grant entitlements // Direct user to subscription center to fix payment } ``` ### Query Suspended Subscriptions (8.1+) ```kotlin // Include suspended subscriptions in query results val params = QueryPurchasesParams.newBuilder() .setProductType(BillingClient.ProductType.SUBS) .setIncludeSuspended(true) // New in 8.1 .build() billingClient.queryPurchasesAsync(params) { billingResult, purchases -> purchases.forEach { purchase -> if (purchase.isSuspended) { // Handle suspended subscription } } } ``` > **OpenIAP Note**: Use `includeSuspendedAndroid: true` in `PurchaseOptions` when calling `getAvailablePurchases()`. The `isSuspendedAndroid` field on purchases indicates suspension status. ## Sub-Response Codes (8.0+) `BillingResult` includes a sub-response code for more granular error information: ```kotlin val result = billingClient.launchBillingFlow(activity, params) when (result.subResponseCode) { BillingResult.SUB_RESPONSE_CODE_INSUFFICIENT_FUNDS -> { // User's payment method has insufficient funds } BillingResult.SUB_RESPONSE_CODE_USER_INELIGIBLE -> { // User doesn't meet offer eligibility requirements } } ``` | Sub-Response Code | Description | |-------------------|-------------| | `PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS` | User's payment method has insufficient funds | | `USER_INELIGIBLE` | User doesn't meet subscription offer eligibility | | `NO_APPLICABLE_SUB_RESPONSE_CODE` | No specific sub-code applies | ## Subscription Product Replacement (8.1+) Product-level replacement parameters for subscription upgrades/downgrades: ```kotlin val replacementParams = SubscriptionProductReplacementParams.newBuilder() .setOldProductId("old_subscription_id") .setReplacementMode(ReplacementMode.WITH_TIME_PRORATION) .build() val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder() .setProductDetails(newProductDetails) .setOfferToken(offerToken) .setSubscriptionProductReplacementParams(replacementParams) // New in 8.1 .build() ``` ### Replacement Modes | Mode | Description | |------|-------------| | `WITH_TIME_PRORATION` | Immediate, expiration time prorated | | `CHARGE_PRORATED_PRICE` | Immediate, same billing cycle | | `CHARGE_FULL_PRICE` | Immediate, full price charged | | `WITHOUT_PRORATION` | Takes effect on old plan expiration | | `DEFERRED` | Deferred, no charge | | `KEEP_EXISTING` | Keep existing payment schedule (8.1+) | ## External Payments Program (8.3+) Billing Library 8.3 (December 2025) added support for the External Payments program (Japan-only, as of launch). Developers enrolled in the program can offer alternative payment methods alongside Google Play billing. ### Enable Developer Billing Option ```kotlin // During BillingClient setup val billingClient = BillingClient.newBuilder(context) .setListener(purchasesUpdatedListener) .enablePendingPurchases() .enableAutoServiceReconnection() .enableDeveloperBillingOption( DeveloperBillingOptionParams.newBuilder() .setDeveloperProvidedBillingListener(developerBillingListener) .build() ) .build() ``` ### DeveloperProvidedBillingListener ```kotlin val developerBillingListener = DeveloperProvidedBillingListener { userInitiatedBillingDetails -> // User chose the developer-provided billing flow. // Launch your external payment UI here. } ``` ### Launch Purchase with External Payments Option ```kotlin val params = BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf(productDetailsParams)) .setBillingOption(BillingOption.EXTERNAL_PAYMENTS) // 8.3+ .build() billingClient.launchBillingFlow(activity, params) ``` ### Key Types (8.3+) | Type | Purpose | |------|---------| | `DeveloperBillingOptionParams` | Configures developer-billing support on `BillingClient` | | `DeveloperProvidedBillingListener` | Callback when user picks developer-provided billing | | `DeveloperProvidedBillingDetails` | Billing details to report back for reconciliation | | `BillingOption.EXTERNAL_PAYMENTS` | Purchase-flow flag requesting external payments | > **OpenIAP Note**: Exposed through the Android-specific `AlternativeBilling*` surface in OpenIAP. Enrolment with Google Play's External Payments program is required; availability is currently restricted to Japan. The Horizon flavor does NOT implement this. ## Best Practices 1. **Always acknowledge purchases** within 3 days or they will be refunded 2. **Verify purchases server-side** using Google Play Developer API 3. **Handle pending purchases** for payment methods that require additional steps 4. **Auto-reconnect is enabled by default** in OpenIAP (8.0+) 5. **Check product status codes** (8.0+) to understand why products weren't fetched 6. **Check isSuspended** (8.1+) before granting entitlements 7. **Cache product details** to avoid repeated queries --- # Meta Horizon IAP API Reference > External reference for Meta Horizon Store in-app purchase APIs. > Source: [Meta Horizon Documentation](https://developers.meta.com/horizon/documentation/) ## Overview Meta Horizon provides IAP functionality for Quest VR applications. There are two main integration paths: 1. **Platform SDK IAP** - Native Horizon IAP APIs 2. **Billing Compatibility SDK** - Google Play Billing Library-compatible wrapper ## Version Compatibility Matrix | Library | Version | Compatible With | |---------|---------|-----------------| | horizon-billing-compatibility | **1.1.1** (latest) | Google Play Billing **7.0** API | | Google Play Billing (Play flavor) | **8.3.0** (latest) | N/A | | react-native-iap | v14+ | Billing 7.0+, RN 0.79+, Kotlin 2.0+ | | expo-iap | latest | Billing 7.0+, Kotlin 2.0+ | **CRITICAL**: Horizon Billing Compatibility SDK implements Google Play Billing **7.0** API surface, NOT 8.x. When writing shared code for both Play and Horizon flavors: - Use only APIs that exist in **both** Billing 7.0 and 8.x - Horizon SDK does NOT support Billing 8.x features like auto-reconnect, product status codes, or `includeSuspended` - OpenIAP handles this automatically with flavor-specific implementations ### APIs Available in Both (Safe to use in shared code) - `BillingClient.Builder`, `BillingClient.newBuilder()` - `queryProductDetailsAsync()` - Core product query - `launchBillingFlow()` - Purchase flow - `acknowledgePurchase()` - Acknowledge (no-op in Horizon) - `consumeAsync()` - Consume purchase - `queryPurchasesAsync()` - Query purchases ### APIs Only in Billing 8.x (DO NOT use in shared code) - `enableAutoServiceReconnection()` - Auto-reconnect feature (8.0+) - Product-level status codes in `queryProductDetailsAsync()` response (8.0+) - One-time products with multiple offers (8.0+) - Sub-response codes in `BillingResult` (8.0+) - `isSuspended` on Purchase (8.1+) - `includeSuspended` parameter in `QueryPurchasesParams` (8.1+) - `SubscriptionProductReplacementParams` (8.1+) - Billing Programs API (`isBillingProgramAvailableAsync`, etc.) (8.2+) - External Payments / Developer Billing Options (8.3+) ## Billing Compatibility SDK For apps already using Google Play Billing Library, the Horizon Billing Compatibility SDK provides a minimal migration path. ### Compatibility - Compatible with **Google Play Billing Library 7.0** API - Supports: consumable, durable, and subscription IAP - Kotlin 2+ required ### Migration Steps Replace imports from: ```kotlin import com.android.billingclient.api.* ``` To: ```kotlin import com.meta.horizon.billingclient.api.* ``` ### Key Differences from Google Play Billing | Feature | Google Play | Horizon | |---------|-------------|---------| | `acknowledgePurchase()` | Required within 3 days | No-op (not required) | | Non-acknowledgement | Auto-refund after 3 days | No auto-refund | | `enablePendingPurchases()` | Enables pending purchases | No-op (for compatibility) | | `onBillingServiceDisconnected()` | Called on disconnect | Never invoked | ### Important Notes - Keep SKUs on Meta Horizon Developer Center same as Google Play Console product IDs - Only call `consumeAsync()` on consumable items - `acknowledgePurchase()` is no-op - no acknowledgement requirements ## Server-to-Server (S2S) APIs ### Authentication Access token format: `OC|App_ID|App_Secret` ### Verify Entitlement Verify that a user owns an item (app or add-on). **Endpoint:** ```http POST https://graph.oculus.com/$APP_ID/verify_entitlement ``` **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `access_token` | string | `OC\|App_ID\|App_Secret` format | | `user_id` | string | The user ID to verify | | `sku` | string | (Optional) SKU for add-on verification | **Example - Verify App Ownership:** ```bash curl -d "access_token=OC|$APP_ID|$APP_SECRET" \ -d "user_id=$USER_ID" \ https://graph.oculus.com/$APP_ID/verify_entitlement ``` **Example - Verify Add-on/IAP:** ```bash curl -d "access_token=OC|$APP_ID|$APP_SECRET" \ -d "user_id=$USER_ID" \ -d "sku=$SKU" \ https://graph.oculus.com/$APP_ID/verify_entitlement ``` **Response:** ```json { "success": true } ``` ### Refund IAP Entitlement Refund a DURABLE or CONSUMABLE entitlement (not yet consumed). **Endpoint:** ```http POST https://graph.oculus.com/$APP_ID/refund_iap_entitlement ``` **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `access_token` | string | `OC\|App_ID\|App_Secret` format | | `user_id` | string | The user ID | | `sku` | string | SKU of item to refund | **Note:** Can only refund items not yet consumed via `consumeAsync()`. ## Platform SDK IAP (Native) ### Product Types | Type | Description | |------|-------------| | `CONSUMABLE` | Can be purchased multiple times (e.g., coins) | | `DURABLE` | One-time purchase, permanent ownership | | `SUBSCRIPTION` | Recurring billing | ### Key APIs #### Get Products Retrieve product information and pricing. #### Launch Purchase Flow Initiate purchase for an item. #### Query Purchase History Get user's purchase history. #### Consume Purchase Mark consumable item as used (required for re-purchase). ## OpenIAP Type Mapping | OpenIAP Type | Description | |--------------|-------------| | `IapStore.Horizon` | Store identifier for Horizon | | `VerifyPurchaseHorizonOptions` | Horizon verification parameters | | `VerifyPurchaseResultHorizon` | Horizon verification result | ### VerifyPurchaseHorizonOptions ```typescript interface VerifyPurchaseHorizonOptions { userId: string; // Horizon user ID sku: string; // Product SKU accessToken: string; // Format: "OC|APP_ID|APP_SECRET" } ``` > **OpenIAP Note**: The GraphQL schema takes a single `accessToken` formatted as `OC|APP_ID|APP_SECRET` rather than separate `appId` / `appSecret` fields. Build the token server-side and pass it as one string. ### VerifyPurchaseResultHorizon ```typescript interface VerifyPurchaseResultHorizon { success: boolean; // Verification result } ``` ## Entitlement Check Apps must perform entitlement check within 10 seconds of launch for VRC.Quest.Security.1 compliance. ## React Native / Expo Support Meta Quest supports React Native and Expo applications. ### Requirements | Library | Minimum Version | Notes | |---------|-----------------|-------| | react-native-iap | v14+ | Billing 7.0+, Kotlin 2.0+, RN 0.79+ | | expo-iap | latest | Uses expo-horizon-core plugin | | React Native | 0.79+ | Required for Nitro modules | | Kotlin | 2.0+ | Required for both billing SDKs | ### Expo Integration Use `expo-horizon-core` plugin for Quest support: ```bash npx expo install expo-horizon-core ``` The plugin: - Removes unsupported dependencies/permissions - Configures Android product flavors - Specifies Meta Horizon App ID - Provides Quest-specific JS utilities ### Known Limitations on Quest - No GPS sensor (limited location accuracy) - No geocoding support - No device heading - No background location - Some Expo libraries need forks (expo-location, expo-notifications) ## Documentation Links - [Platform SDK IAP Package](https://developers.meta.com/horizon/documentation/android-apps/ps-platform-sdk-iap) - [S2S APIs](https://developers.meta.com/horizon/documentation/unity/ps-iap-s2s/) - [Billing Compatibility SDK](https://developers.meta.com/horizon/documentation/spatial-sdk/horizon-billing-compatibility-sdk/) - [Entitlement Check](https://developers.meta.com/horizon/documentation/android-apps/ps-entitlement-check/) - [React Native on Quest](https://developers.meta.com/horizon/documentation/android-apps/react-native-apps) - [Expo Quest Setup](https://blog.swmansion.com/how-to-add-meta-quest-support-to-your-expo-app-68c52778b1fe) - [Subscriptions](https://developers.meta.com/horizon/resources/subscriptions/) - [Setting up Add-ons](https://developers.meta.com/horizon/resources/add-ons-setup/) --- # react-native-iap API Reference (Legacy) > **WARNING**: This file contains outdated API names from older versions. > For the current API spec, refer to the official [OpenIAP documentation](https://openiap.dev/docs/apis). > > Key renames from legacy to current: > > - `getProducts` → `fetchProducts` > - `getSubscriptions` → `fetchProducts({ type: 'subs' })` > - `getPurchaseHistory` → `getAvailablePurchases` > - `requestSubscription` → `requestPurchase({ type: 'subs' })` > - `completePurchase` → `finishTransaction` ## Overview react-native-iap is a React Native library for in-app purchases on iOS and Android. expo-iap is built on top of this library. ## Installation ```bash npm install react-native-iap # or yarn add react-native-iap ``` ## Hook-Based API (Recommended) ### useIAP Hook ```typescript import { useIAP } from 'react-native-iap'; function PurchaseScreen() { const { connected, products, subscriptions, purchaseHistory, availablePurchases, currentPurchase, currentPurchaseError, initConnectionError, finishTransaction, fetchProducts, getSubscriptions, getAvailablePurchases, getPurchaseHistory, requestPurchase, requestSubscription, } = useIAP(); useEffect(() => { if (currentPurchase) { // Process purchase finishTransaction({ purchase: currentPurchase }); } }, [currentPurchase]); return (/* ... */); } ``` ### withIAPContext HOC Wrap your app with IAP context provider. ```typescript import { withIAPContext } from 'react-native-iap'; function App() { return ; } export default withIAPContext(App); ``` ## Imperative API ### Connection Management ```typescript import { initConnection, endConnection, fetchProducts, getSubscriptions, } from 'react-native-iap'; // Initialize const connected = await initConnection(); // Fetch products const products = await fetchProducts({ skus: ['com.app.product1'] }); const subs = await getSubscriptions({ skus: ['com.app.sub_monthly'] }); // Cleanup await endConnection(); ``` ### Product Types ```typescript interface Product { productId: string; price: string; currency: string; localizedPrice: string; title: string; description: string; type: 'inapp' | 'subs'; // iOS introductoryPrice?: string; introductoryPriceAsAmountIOS?: string; introductoryPricePaymentModeIOS?: string; introductoryPriceNumberOfPeriodsIOS?: string; introductoryPriceSubscriptionPeriodIOS?: string; subscriptionPeriodNumberIOS?: string; subscriptionPeriodUnitIOS?: string; discounts?: Discount[]; // Android subscriptionOfferDetails?: SubscriptionOffer[]; oneTimePurchaseOfferDetails?: OneTimePurchaseOffer; } interface SubscriptionOffer { basePlanId: string; offerId?: string; offerToken: string; offerTags: string[]; pricingPhases: PricingPhase[]; } interface PricingPhase { formattedPrice: string; priceCurrencyCode: string; priceAmountMicros: string; billingPeriod: string; billingCycleCount: number; recurrenceMode: number; } ``` ### Purchase Operations ```typescript import { requestPurchase, requestSubscription, finishTransaction, getAvailablePurchases, getPurchaseHistory, } from 'react-native-iap'; // Purchase consumable/non-consumable await requestPurchase({ sku: 'com.app.product1' }); // Purchase subscription (Android with offer token) await requestSubscription({ sku: 'com.app.sub_monthly', subscriptionOffers: [{ sku: 'com.app.sub_monthly', offerToken: 'token' }], }); // Finish transaction await finishTransaction({ purchase, isConsumable: true }); // Get available purchases (restore) const available = await getAvailablePurchases(); // Get purchase history const history = await getPurchaseHistory(); ``` ### Purchase Type ```typescript interface Purchase { productId: string; transactionId?: string; transactionDate: number; transactionReceipt: string; purchaseToken?: string; quantityIOS?: number; originalTransactionDateIOS?: number; originalTransactionIdentifierIOS?: string; verificationResultIOS?: string; appAccountToken?: string; // Android purchaseStateAndroid?: PurchaseStateAndroid; isAcknowledgedAndroid?: boolean; packageNameAndroid?: string; developerPayloadAndroid?: string; obfuscatedAccountIdAndroid?: string; obfuscatedProfileIdAndroid?: string; autoRenewingAndroid?: boolean; } ``` ## Event Listeners ```typescript import { purchaseUpdatedListener, purchaseErrorListener, } from 'react-native-iap'; // Purchase updates const purchaseUpdateSubscription = purchaseUpdatedListener( async (purchase: Purchase) => { const receipt = purchase.transactionReceipt; if (receipt) { // Verify with server await finishTransaction({ purchase }); } } ); // Purchase errors const purchaseErrorSubscription = purchaseErrorListener( (error: PurchaseError) => { console.warn('purchaseErrorListener', error); } ); // Cleanup purchaseUpdateSubscription.remove(); purchaseErrorSubscription.remove(); ``` ## iOS-Specific Functions ```typescript import { clearTransactionIOS, clearProductsIOS, getReceiptIOS, getPendingPurchasesIOS, getPromotedProductIOS, buyPromotedProductIOS, presentCodeRedemptionSheetIOS, validateReceiptIos, } from 'react-native-iap'; // Clear finished transactions await clearTransactionIOS(); // Clear cached products await clearProductsIOS(); // Get receipt for validation const receipt = await getReceiptIOS(); // Get pending purchases const pending = await getPendingPurchasesIOS(); // Handle promoted products const promotedProduct = await getPromotedProductIOS(); if (promotedProduct) { await buyPromotedProductIOS(); } // Show offer code redemption await presentCodeRedemptionSheetIOS(); ``` ## Android-Specific Functions ```typescript import { acknowledgePurchaseAndroid, consumePurchaseAndroid, flushFailedPurchasesCachedAsPendingAndroid, getPackageNameAndroid, isFeatureSupported, getBillingConfigAndroid, } from 'react-native-iap'; // Acknowledge purchase (non-consumables, subscriptions) await acknowledgePurchaseAndroid({ token: purchase.purchaseToken }); // Consume purchase (consumables) await consumePurchaseAndroid({ token: purchase.purchaseToken }); // Clear failed pending purchases await flushFailedPurchasesCachedAsPendingAndroid(); // Get package name const packageName = getPackageNameAndroid(); // Check feature support const supported = await isFeatureSupported('subscriptions'); // Get billing config const config = await getBillingConfigAndroid(); ``` ## Subscription Status (iOS) ```typescript import { getSubscriptionStatusIOS, getSubscriptionStatusesIOS, } from 'react-native-iap'; // Get status for single product const status = await getSubscriptionStatusIOS('com.app.sub_monthly'); // Get status for multiple products const statuses = await getSubscriptionStatusesIOS(); ``` ## Error Handling ```typescript import { IapIosSk2, ErrorCode } from 'react-native-iap'; try { await requestPurchase({ sku: 'com.app.product1' }); } catch (err) { if (err.code === ErrorCode.E_USER_CANCELLED) { // User cancelled } else if (err.code === ErrorCode.E_ITEM_UNAVAILABLE) { // Item not available } else if (err.code === ErrorCode.E_ALREADY_OWNED) { // Already owned } else { // Other error } } ``` ### Error Codes | Code | Description | |------|-------------| | `E_UNKNOWN` | Unknown error | | `E_USER_CANCELLED` | User cancelled | | `E_ITEM_UNAVAILABLE` | Item not available | | `E_NETWORK_ERROR` | Network error | | `E_SERVICE_ERROR` | Store service error | | `E_ALREADY_OWNED` | Item already owned | | `E_REMOTE_ERROR` | Remote error | | `E_NOT_PREPARED` | Not initialized | | `E_NOT_ENDED` | Not ended | | `E_DEVELOPER_ERROR` | Developer error | | `E_BILLING_RESPONSE_JSON_PARSE_ERROR` | JSON parse error | | `E_DEFERRED_PAYMENT` | Deferred payment | ## Complete Usage Example ```typescript import React, { useEffect } from 'react'; import { withIAPContext, useIAP, requestPurchase, finishTransaction, purchaseUpdatedListener, purchaseErrorListener, ProductPurchase, } from 'react-native-iap'; const productIds = ['com.app.product1']; const subscriptionIds = ['com.app.sub_monthly']; function Store() { const { connected, products, subscriptions, fetchProducts, getSubscriptions, } = useIAP(); useEffect(() => { if (connected) { fetchProducts({ skus: productIds }); getSubscriptions({ skus: subscriptionIds }); } }, [connected]); useEffect(() => { const purchaseSub = purchaseUpdatedListener( async (purchase: ProductPurchase) => { await finishTransaction({ purchase, isConsumable: false }); } ); const errorSub = purchaseErrorListener((error) => { console.error('Purchase error:', error); }); return () => { purchaseSub.remove(); errorSub.remove(); }; }, []); const handlePurchase = async (sku: string) => { try { await requestPurchase({ sku }); } catch (err) { console.error(err); } }; return (/* Render products and subscriptions */); } export default withIAPContext(Store); ``` ## Platform Differences | Feature | iOS | Android | |---------|-----|---------| | Subscription offers | Introductory price, Discounts | Offer tokens, Pricing phases | | Acknowledge | Automatic | Required within 3 days | | Consume | finishTransaction | consumePurchaseAndroid | | Receipt | getReceiptIOS | transactionReceipt in Purchase | | Promoted products | Supported | Not supported | | Offer codes | Supported | Promo codes via Play Store | --- # StoreKit 2 API Reference This document provides external API reference for Apple's StoreKit 2 framework. ## iOS 18+ Features | Feature | iOS Version | Description | |---------|-------------|-------------| | Win-back offers | iOS 18.0 | Re-engage churned subscribers | | `eligibleWinBackOfferIDs` | iOS 18.0 | Query win-back offer eligibility before purchase | | Consumable transaction history | iOS 18.0 | Opt-in via `SK2ConsumableTransactionHistory` Info.plist key | | StoreKit `Message` API | iOS 18.0 | Listener for billing issues, win-back, price increase, generic | | UI context for purchases | iOS 18.2 | Required for proper payment sheet display | | External purchase notice | iOS 17.4 | `ExternalPurchase.presentNoticeSheet()` | | `appTransactionID` | iOS 18.4 | Globally unique app transaction identifier (back-deployed to iOS 15) | | `originalPlatform` | iOS 18.4 | Original purchase platform (back-deployed to iOS 15) | | `Transaction.offerPeriod` | iOS 18.4 | Offer period information on Transaction | | `Transaction.advancedCommerceInfo` | iOS 18.4 | Advanced Commerce API data on Transaction | | `Transaction.appTransactionID` | iOS 18.4 | Per-Apple-Account identifier on Transaction | | Expanded offer codes | iOS 18.4 | Offer codes for consumables/non-consumables | | JWS promotional offers | WWDC 2025 | New `promotionalOffer` purchase option with JWS format | | `introductoryOfferEligibility` | WWDC 2025 | Set eligibility via purchase option | | `SubscriptionStatus` by Transaction ID | WWDC 2025 | `status(for: transactionID:)` | ### WWDC 2025 Updates - **SubscriptionStatus by Transaction ID**: `SubscriptionInfo.Status.status(for: transactionID:)` accepts any transaction ID, not just SKU. - **JWS-based promotional offers**: New `promotionalOffer` purchase option with compact JWS string. - **Introductory offer eligibility**: Override eligibility check with `introductoryOfferEligibility` purchase option. - Both new purchase options are back-deployed to iOS 15. ## appAccountToken A UUID that associates a purchase with a user account in your system. This property allows you to correlate App Store transactions with users in your backend. ### Important: UUID Format Requirement **The `appAccountToken` must be a valid UUID format.** If you provide a non-UUID string (e.g., `"user-123"` or `"my-account-id"`), Apple's StoreKit will silently return `null` for this field in the transaction response. #### Valid UUID Examples ```swift // Valid UUIDs - these will be returned correctly "550e8400-e29b-41d4-a716-446655440000" "6ba7b810-9dad-11d1-80b4-00c04fd430c8" UUID().uuidString // Generate new UUID ``` #### Invalid Examples (Will Return null) ```swift // Invalid - NOT UUID format, Apple returns null silently "user-123" "my-account-token" "abc123" ``` ### Usage in Purchase Options ```swift let appAccountToken = UUID() let result = try await product.purchase(options: [ .appAccountToken(appAccountToken) ]) ``` ### Retrieving from Transaction ```swift let transaction: Transaction if let token = transaction.appAccountToken { // Token will only be present if a valid UUID was provided during purchase print("App Account Token: \(token)") } ``` ### Best Practices 1. **Generate UUIDs per user**: Create and store a UUID for each user in your system 2. **Use consistent tokens**: Use the same UUID for all purchases from the same user 3. **Server-side mapping**: Map the UUID to your internal user ID on your server 4. **Don't use user IDs directly**: Convert your user IDs to UUIDs rather than using them directly ### References - [Apple Developer Documentation: appAccountToken](https://developer.apple.com/documentation/storekit/transaction/appaccounttoken) - [GitHub Issue: expo-iap #128](https://github.com/hyochan/expo-iap/issues/128) ## Product A type that describes an in-app purchase product. ### Properties ```swift let id: String // The product identifier let type: Product.ProductType // The type of product let displayName: String // Localized display name let description: String // Localized description let displayPrice: String // Localized price string let price: Decimal // Price as decimal let subscription: Product.SubscriptionInfo? // Subscription details ``` ### Methods #### products(for:) ```swift static func products(for identifiers: [String]) async throws -> [Product] ``` Fetches products from the App Store. #### purchase(options:) ```swift func purchase(options: Set = []) async throws -> Product.PurchaseResult ``` Initiates a purchase for this product. ## Transaction Represents a completed purchase transaction. ### Properties ```swift let id: UInt64 // Unique transaction ID let originalID: UInt64 // Original transaction ID let productID: String // Product identifier let purchaseDate: Date // When the purchase occurred let expirationDate: Date? // Subscription expiration date let revocationDate: Date? // When the transaction was revoked let isUpgraded: Bool // Whether this subscription was upgraded let environment: AppStore.Environment // sandbox or production ``` ### Methods #### currentEntitlements ```swift static var currentEntitlements: Transaction.Entitlements ``` A sequence of the customer's current entitlements. #### latest(for:) ```swift static func latest(for productID: String) async -> VerificationResult? ``` Gets the latest transaction for a product. #### finish() ```swift func finish() async ``` Marks the transaction as finished. ## AppStore Provides access to App Store functionality. ### Methods #### sync() ```swift static func sync() async throws ``` Syncs transactions with the App Store. #### showManageSubscriptions(in:) ```swift static func showManageSubscriptions(in scene: UIWindowScene) async throws ``` Shows the subscription management UI. #### beginRefundRequest(for:in:) ```swift static func beginRefundRequest(for transactionID: UInt64, in scene: UIWindowScene) async throws -> Transaction.RefundRequestStatus ``` Begins a refund request for a transaction. ## Win-Back Offers (iOS 18+) Win-back offers are a new offer type to re-engage churned subscribers. ### Automatic Presentation StoreKit Message automatically presents win-back offers when a user is eligible: ```swift // Message reason for win-back offers StoreKit.Message.Reason.winBackOffer ``` ### Manual Application Apply a win-back offer during purchase: ```swift let product: Product let winBackOffer: Product.SubscriptionOffer let result = try await product.purchase(options: [ .winBackOffer(winBackOffer) ]) ``` ### Checking Eligibility Discover eligible win-back offers before purchase via `Product.SubscriptionInfo.eligibleWinBackOfferIDs` (iOS 18+): ```swift let status = try await product.subscription?.status.first guard let renewalInfo = try status?.renewalInfo.payloadValue else { return } // iOS 18+: offer IDs the current Apple Account is eligible for let eligibleIDs = renewalInfo.eligibleWinBackOfferIDs let eligibleOffers = (product.subscription?.promotionalOffers ?? []).filter { $0.type == .winBack && eligibleIDs.contains($0.id ?? "") } ``` ### RenewalInfo Win-back offer information is available in renewal info: ```swift let renewalInfo: Product.SubscriptionInfo.RenewalInfo // Check if win-back offer is applied to next renewal if renewalInfo.renewalOfferType == .winBack { // Win-back offer will be applied } ``` ## UI Context for Purchases (iOS 18.2+) Beginning in iOS 18.2, purchase methods require a UI context to properly display payment sheets: ```swift // iOS/iPadOS/tvOS/visionOS: UIViewController let result = try await product.purchase(confirmIn: viewController) // macOS: NSWindow let result = try await product.purchase(confirmIn: window) // watchOS: No UI context required ``` > **OpenIAP Note**: UI context is handled automatically in OpenIAP using the active window scene. ## AppTransaction Updates (iOS 18.4+) ```swift let appTransaction = try await AppTransaction.shared // New in iOS 18.4 (back-deployed to iOS 15) let appTransactionID = appTransaction.appTransactionID // Globally unique per Apple Account let originalPlatform = appTransaction.originalPlatform // Original purchase platform ``` ### appTransactionID - Globally unique identifier for each Apple Account that downloads your app - Remains consistent across redownloads, refunds, repurchases, and storefront changes - Works with Family Sharing (each family member gets unique ID) - Back-deployed to iOS 15 ## Transaction Updates (iOS 18.4+) iOS 18.4 added three new read-only properties to `Transaction` (not just `AppTransaction`): ```swift let transaction: Transaction // iOS 18.4+ — all back-deployed to iOS 15 let txAppTransactionID = transaction.appTransactionID // Apple Account identifier let offerPeriod = transaction.offerPeriod // Offer.Period? let advancedCommerce = transaction.advancedCommerceInfo // AdvancedCommerceInfo? ``` | Property | Type | Notes | |----------|------|-------| | `appTransactionID` | String | Mirrors AppTransaction's identifier | | `offerPeriod` | Offer.Period? | Phase of the promotional/intro offer | | `advancedCommerceInfo` | AdvancedCommerceInfo? | Present for Advanced Commerce SKUs only | ## Advanced Commerce API (iOS 18.4+) For apps with large product catalogs: ```swift // Check if product has advanced commerce info if let advancedInfo = product.advancedCommerceInfo { // Handle large catalog monetization } ``` ## StoreKit Message API (iOS 18+) Listen for App Store–generated messages (billing issues, win-back offers, price increases, generic). ```swift // Somewhere near app launch Task { for await message in Message.messages { switch message.reason { case .billingIssue: // Show UI when user is ready; display from message.display(in:) break case .winBackOffer: break case .priceIncrease: break case .generic: break @unknown default: break } } } ``` | Reason | Trigger | |--------|---------| | `.billingIssue` | User has an unresolved billing problem on a subscription | | `.priceIncrease` | Price change that requires user consent | | `.winBackOffer` | User is eligible for a win-back offer | | `.generic` | All other system-initiated messages | > **OpenIAP Note**: To be surfaced by the cross-platform event layer — see `event.graphql` additions for message events. ## SubscriptionStatus by Transaction ID (WWDC 2025) ```swift // WWDC 2025: look up status using any transactionID, not just a SKU let status = try await Product.SubscriptionInfo.Status.status(for: transactionID) ``` ## Consumable Transaction History (iOS 18+) By default, `Transaction.all` omits finished consumables. Opt in by adding this key to **Info.plist**: ```xml SK2ConsumableTransactionHistory ``` With the key set, finished consumable transactions are included in `Transaction.all` and `Transaction.currentEntitlements`. ## External Purchase Support (iOS 17.4+) `ExternalPurchase.presentNoticeSheet()` / `ExternalPurchase.open(url:)` ship on iOS 17.4+. The follow-on custom-link APIs (`ExternalPurchaseCustomLink.isEligible`, `showNotice(type:)`, `token(for:)`) are iOS 18.1+. ### Present External Purchase Notice ```swift // Check if external purchase notice can be presented if await ExternalPurchase.canPresent { let result = try await ExternalPurchase.presentNoticeSheet() switch result { case .continue: // User wants to continue to external purchase case .dismissed: // User dismissed the notice } } ``` ### Present External Purchase Link ```swift let result = try await ExternalPurchase.open(url: externalURL) ``` > **OpenIAP Note**: `presentExternalPurchaseNoticeSheetIOS` and `presentExternalPurchaseLinkIOS` are available in the iOS package. --- # Webhook Event Mapping (ASN v2 ↔ RTDN ↔ openiap) This document is the source of truth for how kit normalizes Apple App Store Server Notifications v2 (ASN v2) and Google Play Real-Time Developer Notifications (RTDN) into the unified `WebhookEvent` shape defined in [`packages/gql/src/webhook.graphql`](../../packages/gql/src/webhook.graphql). When kit's webhook receivers are implemented (Phase 1, PR #2), they MUST follow this table. When extending the spec (new event types, new stores), update this document in the same PR. ## Subscription lifecycle | openiap `WebhookEventType` | Apple ASN v2 `notificationType` (`subtype`) | Google RTDN `subscriptionNotification.notificationType` | |---|---|---| | `SubscriptionStarted` | `SUBSCRIBED` (`INITIAL_BUY`, `RESUBSCRIBE`) | `SUBSCRIPTION_PURCHASED` (4) | | `SubscriptionRenewed` | `DID_RENEW` | `SUBSCRIPTION_RENEWED` (2) | | `SubscriptionExpired` | `EXPIRED` | `SUBSCRIPTION_EXPIRED` (13) | | `SubscriptionInGracePeriod` | `DID_FAIL_TO_RENEW` (`GRACE_PERIOD`) | `SUBSCRIPTION_IN_GRACE_PERIOD` (6) | | `SubscriptionInBillingRetry` | `DID_FAIL_TO_RENEW` (no subtype) | `SUBSCRIPTION_ON_HOLD` (5) | | `SubscriptionRecovered` | `DID_RENEW` (after a prior failure) | `SUBSCRIPTION_RECOVERED` (1) | | `SubscriptionCanceled` | `DID_CHANGE_RENEWAL_STATUS` (`AUTO_RENEW_DISABLED`) | `SUBSCRIPTION_CANCELED` (3) | | `SubscriptionUncanceled` | `DID_CHANGE_RENEWAL_STATUS` (`AUTO_RENEW_ENABLED`) | `SUBSCRIPTION_RESTARTED` (7) — fired when auto-renew is re-enabled while the period is still active | | `SubscriptionRevoked` | `REVOKE` | `SUBSCRIPTION_REVOKED` (12) | | `SubscriptionPriceChange` | `PRICE_INCREASE` | `SUBSCRIPTION_PRICE_CHANGE_CONFIRMED` (8), `SUBSCRIPTION_PRICE_CHANGE_UPDATED` (19) | | `SubscriptionProductChanged` | `DID_CHANGE_RENEWAL_PREF` | `SUBSCRIPTION_DEFERRED` (9) | | `SubscriptionPaused` | (no equivalent — iOS has no pause) | `SUBSCRIPTION_PAUSED` (10), `SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED` (11) — schedule update, not actual resume | | `SubscriptionResumed` | (no equivalent) | `SUBSCRIPTION_RECOVERED` (1) when fired after a `SUBSCRIPTION_PAUSED` — kit chooses Resumed vs Recovered based on the prior `subscriptions` row state | PR #123 review caught the earlier draft where codes 1 and 4 were swapped (`SUBSCRIPTION_RECOVERED` is code 1, `SUBSCRIPTION_PURCHASED` is code 4) and where `SUBSCRIPTION_RESTARTED` (7) was incorrectly mapped to `SubscriptionRecovered` instead of `SubscriptionUncanceled`. The mapping above reflects the corrected RTDN reference. ## One-time / common | openiap `WebhookEventType` | Apple ASN v2 | Google RTDN | |---|---|---| | `PurchaseRefunded` | `REFUND` | `oneTimeProductNotification.notificationType = ONE_TIME_PRODUCT_CANCELED` (2), or `voidedPurchaseNotification` | | `PurchaseConsumptionRequest` | `CONSUMPTION_REQUEST` | (no equivalent — Play handles consumption client-side) | | `TestNotification` | `TEST` | `testNotification` field present on the RTDN message | ## Field mapping | `WebhookEvent` field | Apple ASN v2 source | Google RTDN source | |---|---|---| | `id` | `notificationUUID` | Pub/Sub `messageId` | | `occurredAt` | `signedDate` | `eventTimeMillis` | | `environment` | `data.environment` (`Production` \| `Sandbox` \| `Xcode`) | `testNotification` present → `Sandbox`, else `Production` | | `purchaseToken` | `data.signedTransactionInfo.originalTransactionId` | `subscriptionNotification.purchaseToken` or `oneTimeProductNotification.purchaseToken` | | `productId` | `data.signedTransactionInfo.productId` | `subscriptionNotification.subscriptionId` or `oneTimeProductNotification.sku` | | `expiresAt` | `data.signedRenewalInfo.expirationDate` (decoded JWS) | resolved by calling `purchases.subscriptionsv2.get` (ASN/RTDN do not embed it directly) | | `renewsAt` | `data.signedRenewalInfo.renewalDate` | resolved by calling `purchases.subscriptionsv2.get` | | `cancellationReason` | `data.signedTransactionInfo.revocationReason` + ASN `subtype` | `purchases.subscriptionsv2.get` → `canceledStateContext.userInitiatedCancellation` / `systemInitiatedCancellation` | | `currency` | `data.signedTransactionInfo.currency` | from `purchases.subscriptionsv2.get` linked product price | | `priceAmountMicros` | `data.signedTransactionInfo.price` × 1000 (Apple's `price` field is in **milliunits** = 1/1000 of a currency unit; multiply by 1000 to convert to micros) | `purchases.subscriptionsv2.get` → `lineItems[*].autoRenewingPlan.recurringPrice` — `units * 1_000_000 + Math.round(nanos / 1000)` (Money type combines whole units + nanos = 10⁻⁹ units) | | `rawSignedPayload` | The complete `signedPayload` JWS string from the ASN body | The base64-decoded Pub/Sub message `data` (JSON) | ## Validation requirements (kit Phase 1, PR #2) Both stores require signature verification before any event is emitted: - **Apple ASN v2**: verify the JWS using Apple's public root certificates (refresh via the App Store Connect API). The receiver must reject unverified payloads with HTTP 401. - **Google RTDN**: validate the Pub/Sub push request against the configured service account audience (OIDC token verification). Reject missing or invalid tokens with HTTP 401. Idempotency: - Use `(source, sourceNotificationId)` as the dedup key, where `sourceNotificationId` is `notificationUUID` for ASN v2 or `messageId` for RTDN. Convex idempotency table records the first-seen event and silently acknowledges duplicates with HTTP 200. Replay window: - Events MUST be retained for at least 30 days so `webhookEventsSince` can service reconnecting clients. Older events are pruned by a Convex cron job. --- ## Links & Resources - Documentation: https://openiap.dev/docs - Types Reference: https://openiap.dev/docs/types - APIs Reference: https://openiap.dev/docs/apis - Error Codes: https://openiap.dev/docs/errors - GitHub: https://github.com/hyodotdev/openiap ### Ecosystem Libraries - expo-iap: https://github.com/hyodotdev/openiap/tree/main/libraries/expo-iap - react-native-iap: https://github.com/hyodotdev/openiap/tree/main/libraries/react-native-iap - flutter_inapp_purchase: https://github.com/hyodotdev/openiap/tree/main/libraries/flutter_inapp_purchase - godot-iap: https://github.com/hyodotdev/openiap/tree/main/libraries/godot-iap - kmp-iap: https://github.com/hyodotdev/openiap/tree/main/libraries/kmp-iap - maui-iap: https://github.com/hyodotdev/openiap/tree/main/libraries/maui-iap