# 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-07-16T23:03:55.942Z ## Table of Contents 1. Installation 2. Core APIs (Connection, Products, Purchase, Subscription) 3. Platform-Specific APIs (iOS, Android) 4. Store Targets (Play, Horizon, Fire OS, Vega OS) 5. Types Reference 6. Error Codes & Handling 7. 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: "2.4.1") // CocoaPods pod 'openiap', '~> 2.4.1' ``` ### Kotlin (Android) ```kotlin // Gradle (build.gradle.kts) implementation("io.github.hyochan.openiap:openiap-google:2.4.1") // For Meta Horizon OS implementation("io.github.hyochan.openiap:openiap-google-horizon:2.4.1") // For Fire OS (Amazon Appstore) implementation("io.github.hyochan.openiap:openiap-google-amazon:2.4.1") ``` ### Flutter ```bash flutter pub add flutter_inapp_purchase ``` ### Godot Download `godot-iap-2.5.1.zip` from GitHub Releases, extract it to `addons/godot-iap/`, then enable the plugin in Project Settings. ### Kotlin Multiplatform ```kotlin dependencies { implementation("io.github.hyochan:kmp-iap:2.5.1") } ``` Use the latest version from Maven Central: https://central.sonatype.com/artifact/io.github.hyochan/kmp-iap ### .NET MAUI ```bash dotnet add package OpenIap.Maui ``` Current NuGet package version: 1.3.1 Requires .NET 9 or .NET 10, 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. - Android builds select Play, Horizon, or Fire OS with Gradle properties (`horizonEnabled`, `fireOsEnabled`). Vega OS uses a separate React Native for Vega target that resolves the `kepler` JavaScript adapter before creating the Nitro HybridObject. - Onside is not supported in `react-native-iap`; use `expo-iap` for Onside. - 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. - Config plugins can select Horizon, Fire OS, Vega OS, and Onside: `modules.horizon` + `android.horizon.appId`, `modules.amazon.fireOS`, `modules.amazon.vegaOS`, optional `android.amazon.vegaOS` metadata, and `modules.onside`. - 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. - Android builds can select Play, Horizon, or Fire OS flavors. ### 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:kmp-iap`. - 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 `OpenIapClient.Instance` facade, legacy `Iap` shim, `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. Google Billing, Play Services, Gson, AndroidX, and Kotlin Android libraries stay as NuGet `PackageReference` dependencies so consuming apps can deduplicate them. - Public surface: `QueryResolver`, `MutationResolver`, and `IOpenIap` implemented by `OpenIapIOS`, `OpenIapAndroid`, and `OpenIapMacCatalyst`; IAPKit helpers mirror the TypeScript SDKs via `OpenIapClient.KitApi(...)`, `OpenIapClient.ConnectWebhookStream(...)`, `OpenIapClient.ParseWebhookEventData(...)`, and `OpenIapClient.WebhookEventTypes`. - Example app: `libraries/maui-iap/example/OpenIap.Maui.Example`, mirroring the `expo-iap` example flows. --- ## Store Setup Canonical setup docs live under `/docs/setup/store`: `/docs/setup/store/horizon`, `/docs/setup/store/amazon`, and `/docs/setup/store/onside`. Deprecated feature URLs redirect there. - Google Play: default Android artifact, `openiap-google`. - Meta Horizon: Android `horizon` flavor, `openiap-google-horizon`. Expo uses `modules.horizon=true` and `android.horizon.appId`. React Native and Flutter use `horizonEnabled=true` plus app-owned manifest metadata. KMP exposes `horizonRelease`. MAUI uses `OpenIapAndroidStore=horizon`. Godot has no dedicated Horizon selector. Required values: Horizon app id from Meta Horizon Developer Hub (Expo: `android.horizon.appId`; bare RN/Flutter examples commonly pass a Gradle property named `horizonAppId` into manifest meta-data), product SKUs, and verification values such as `horizon.sku`, `horizon.userId`, and `horizon.accessToken` when validating Horizon purchases. - Fire OS: Android `amazon` flavor, `openiap-google-amazon`; use `modules.amazon.fireOS=true` in the Expo config plugin, or `missingDimensionStrategy("platform", "amazon")` in bare Android / React Native / Flutter app Gradle config. Runtime adapters are wired for native Android, `react-native-iap`, `expo-iap`, `flutter_inapp_purchase`, KMP `amazonRelease`, and MAUI `OpenIapAndroidStore=amazon`. Godot has shared Amazon types and verification payloads but no dedicated Fire OS flavor switch. Required values: Android `applicationId` matching the Amazon Developer Console app, Amazon Appstore product ids / App Tester catalog entries, Amazon public key for Fire OS Android builds, and IAPKit Amazon receipt verification values (`amazon.receiptId`, optional `amazon.userId`, `amazon.sandbox`). - Vega OS: not an Android flavor. Target React Native for Vega and compatible Expo Vega targets only, using Amazon's JavaScript IAP API through the runtime-selected `kepler` adapter at the same runtime integration layer as Onside. In Expo config plugin options, use `modules.amazon.vegaOS=true`. Bare React Native Vega targets provide their own `manifest.toml`, Kepler package metadata, and runtime dependencies. `modules.amazon.fireOS` and `modules.amazon.vegaOS` can both be enabled when an app produces separate Fire OS and Vega OS artifacts. Required values: Vega `manifest.toml` package id, title, interactive component id, Kepler runtime/module declarations, Amazon product ids, and Vega runtime dependencies. In Expo, optional `android.amazon.vegaOS` overrides (`packageId`, `title`, `appName`, `icon`) default from the normal Expo app config unless Vega metadata must differ. - Onside: currently `expo-iap` only. Enable `modules.onside=true` and run Expo prebuild so the iOS module autolinking and Podfile environment are regenerated. Required values: stable `ios.bundleIdentifier`, Onside app registration at developer.onside.io, and the `modules.onside` config flag. ### Fire OS Fire OS is an Android target for Amazon Appstore distribution. It uses the `amazon` Gradle flavor and Amazon Appstore SDK. Fire OS maps OpenIAP calls to the Amazon Appstore SDK: | OpenIAP API | Amazon Appstore SDK mapping | |-------------|--------------------------| | `initConnection()` | Register `PurchasingListener`, request user data | | `fetchProducts()` | `PurchasingService.getProductData` | | `requestPurchase()` | `PurchasingService.purchase` | | `getAvailablePurchases()` | `PurchasingService.getPurchaseUpdates(reset=true)` | | `finishTransaction()` | `PurchasingService.notifyFulfillment(..., FULFILLED)` | ### Vega OS Runtime Vega OS is not Fire OS and is not selected with `fireOsEnabled=true`; that flag is only for Android Fire OS builds. Use `modules.amazon.vegaOS=true` for the Vega runtime target in Expo, and `modules.amazon.fireOS=true` for separate Fire OS Android artifacts in the Expo config plugin. Bare React Native uses direct Gradle flavor selection for Fire OS and a separate Kepler target for Vega. Install `@amazon-devices/keplerscript-appstore-iap-lib` and let `react-native-iap` / `expo-iap` select the `kepler` adapter at runtime, similar to how Onside is selected at the runtime integration layer. --- ## 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: { apple: { sku: 'premium' }, google: { 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.purchaseUpdatedListener.listen((purchase) async { await iap.finishTransaction(purchase: purchase, isConsumable: true); }); ``` ### Godot ```gdscript GodotIapPlugin.purchase_updated.connect(_on_purchase_updated) GodotIapPlugin.init_connection() await GodotIapPlugin.fetch_products(request) GodotIapPlugin.request_purchase(props) ``` ### Kotlin Multiplatform ```kotlin val iap = KmpIAP() iap.initConnection() val products = iap.fetchProducts { skus = listOf("premium") type = ProductQueryType.InApp } iap.purchaseUpdatedListener.collect { purchase -> iap.finishTransaction(purchase = purchase, isConsumable = true) } ``` ### .NET MAUI ```csharp using OpenIap; using OpenIap.Maui; var iap = OpenIapClient.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 ); }); ``` --- # Amazon Appstore SDK IAP Reference > Reference for the Fire OS `amazon` flavor in `packages/google`. > Source: [Amazon Appstore SDK release notes](https://developer.amazon.com/docs/appstore-sdk/release-notes.html) ## Version Compatibility | Component | Version | Notes | | --- | --- | --- | | Amazon Appstore SDK | **3.0.9** | Current official release (May 20, 2026) | | OpenIAP Android flavor | `amazon` | Uses the native Appstore SDK, not Google Billing Compatibility | Appstore SDK 3.0.9 adds `EXISTING_PURCHASE` and `NOT_ELIGIBLE` fulfillment results and add-on subscription support. Add-on subscriptions are available only to selected partners and require activation in Amazon Developer Console. ## OpenIAP Mapping | OpenIAP API | Amazon Appstore SDK | | --- | --- | | `initConnection()` | Register `PurchasingListener`, then request user data | | `fetchProducts()` | `PurchasingService.getProductData()` | | `requestPurchase()` | `PurchasingService.purchase()` | | `getAvailablePurchases()` / restore | `PurchasingService.getPurchaseUpdates()` | | `finishTransaction()` | `PurchasingService.notifyFulfillment(..., FULFILLED)` | The Amazon flavor is isolated under `packages/google/openiap/src/amazon/`. Google Play Billing APIs such as Billing Programs, Billing Choice, suspended subscriptions, and in-app messages are not available on this flavor. ## Pending Purchases Amazon Kids can leave consumable or entitlement purchases waiting for parent approval. Call `PurchasingService.enablePendingPurchases()` before initiating a purchase; otherwise the app doesn't receive `PurchaseResponse.RequestStatus.PENDING`. Do not grant an entitlement for a pending response. Poll purchase updates or use Amazon Real-Time Notifications to learn when the parent approves it. Pending purchases do not apply to subscriptions. Reference: [Implement Pending Purchases](https://developer.amazon.com/docs/in-app-purchasing/implement-pending-purchases.html) ## Fulfillment Always report the result after deciding whether the customer can access the content: | Result | Use | | --- | --- | | `FULFILLED` | The purchase was granted successfully | | `EXISTING_PURCHASE` | The customer already has the relevant account/subscription | | `NOT_ELIGIBLE` | The customer can't use the purchased service | | `UNAVAILABLE` | The content couldn't be delivered | Amazon immediately cancels and refunds the purchase when fulfillment is reported as `EXISTING_PURCHASE`, `NOT_ELIGIBLE`, or `UNAVAILABLE`; callers must not use these results as informational statuses. OpenIAP currently maps successful `finishTransaction()` calls to `FULFILLED`. The other 3.0.9 results need a deliberate cross-platform API contract before they can be selected by callers. Reference: [Implement Appstore SDK IAP](https://developer.amazon.com/docs/in-app-purchasing/iap-implement-iap.html) ## Add-On Subscriptions Add-on subscriptions use the existing `getProductData`, `purchase`, purchase updates, and fulfillment calls, but require Appstore SDK 3.0.9+, partner activation, compatible base-subscription configuration, and server verification of the RVS `baseReceipts` relationship. Treat them as unavailable unless Amazon has enabled the feature for the app. In-app add-on purchases are currently supported only on Fire TV; Fire tablets and Amazon's retail website do not offer this purchase flow. An add-on purchase requires an active base subscription. Amazon reports `PurchaseResponse.RequestStatus.INACTIVE_BASE_SUBSCRIPTION` when that condition is not met; OpenIAP surfaces it as `item-unavailable` and does not grant the add-on. Reference: [Set Up Add-On Subscriptions](https://developer.amazon.com/docs/in-app-purchasing/set-up-add-on-subscriptions.html) --- # Google Play Billing Library API Reference > Reference documentation for Google Play Billing Library 9.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 | | 9.0 | 2026-05-19 | Removes older deprecated APIs, reclassifies blocked Play Store activity errors, adds richer sub-response handling, target SDK 35 | | 9.1 | 2026-06-18 | Billing Choice APIs: `getBillingChoiceInfoAsync()`, `showBillingProgramInformationDialog()`, choice-screen details | **Current Version**: 9.1.0 (as of July 2026) > **OpenIAP audit note**: `packages/google` is pinned to Play Billing 9.1.0. > Billing Choice APIs are implemented only in the Play flavor; Horizon and > Amazon variants keep unsupported/default behavior for APIs that do not exist > in their store SDKs. ### External Offer integration rule (8.2.1+) Although the Billing Programs APIs were introduced in 8.2.0, Google requires 8.2.1 or later for External Offer integrations because 8.2.1 fixes the availability and reporting-details APIs. The in-app sequence is: 1. Enable only `BillingProgram.EXTERNAL_OFFER` while building the client. 2. Check `isBillingProgramAvailableAsync`. 3. Call `createBillingProgramReportingDetailsAsync` immediately before each redirect session. Do not cache or reuse its external transaction token for a later redirect. Google permits the same token to report multiple purchases made during that one external-offer session. 4. Call `launchExternalLink` and proceed only when it succeeds. 5. If payment completes, report the transaction and token from the backend. Do not also enable the deprecated `enableExternalOffer` or `enableAlternativeBillingOnly` modes. Those legacy flows use different APIs and must remain available only through explicit legacy configuration. Official references: [External Offer in-app integration](https://developer.android.com/google/play/billing/external/integration), [Play Billing release notes](https://developer.android.com/google/play/billing/release-notes). ## Core Classes ### BillingClient The main interface for communicating with Google Play Billing. ```kotlin val billingClient = BillingClient.newBuilder(context) .setListener(purchasesUpdatedListener) .enablePendingPurchases( PendingPurchasesParams.newBuilder() .enableOneTimeProducts() .build() ) // 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 enabled internally when the Play > Billing version exposes the API. No OpenIAP app-level configuration is 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, queryResult -> if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { queryResult.productDetailsList.forEach { productDetails -> // Handle fetched product details } queryResult.unfetchedProductList.forEach { unfetchedProduct -> // Inspect unfetchedProduct.statusCode for per-product failures } } } ``` ### 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 | | `oneTimePurchaseOfferDetailsList` | List | All INAPP purchase options and discount offers (8.0+) | | `oneTimePurchaseOfferDetails` | Object | Legacy single-offer compatibility accessor | | `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 | Billing service is currently unavailable | | 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 | | 12 | NETWORK_ERROR | Network connection problem | ## 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, queryResult -> if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { return@queryProductDetailsAsync } queryResult.productDetailsList.forEach { productDetails -> // Product fetched successfully } queryResult.unfetchedProductList.forEach { unfetchedProduct -> when (unfetchedProduct.statusCode) { UnfetchedProduct.StatusCode.PRODUCT_NOT_FOUND -> { // SKU doesn't exist in Play Console } UnfetchedProduct.StatusCode.NO_ELIGIBLE_OFFER -> { // User not eligible for any offers } UnfetchedProduct.StatusCode.INVALID_PRODUCT_ID_FORMAT, UnfetchedProduct.StatusCode.UNKNOWN -> { // Invalid request or an unspecified per-product failure } } } } ``` | Status | Description | | --------------------------- | --------------------------------- | | `PRODUCT_NOT_FOUND` | SKU doesn't exist in Play Console | | `NO_ELIGIBLE_OFFER` | User not eligible for any offers | | `INVALID_PRODUCT_ID_FORMAT` | Product ID format is invalid | | `UNKNOWN` | Unspecified per-product failure | ## 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 override fun onPurchasesUpdated(result: BillingResult, purchases: List?) { when (result.onPurchasesUpdatedSubResponseCode) { BillingClient.OnPurchasesUpdatedSubResponseCode.PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS -> { // User's payment method has insufficient funds } BillingClient.OnPurchasesUpdatedSubResponseCode.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 | PBL 9 makes sub-response-code handling part of the migration checklist. It also changes blocked Play Store app cases from generic `ERROR` to `BILLING_UNAVAILABLE`, with a debug message explaining that Play Store is blocked. > **OpenIAP Note**: Purchase failures delivered by > `purchaseErrorListener` preserve this value as > `PurchaseError.subResponseCodeAndroid` when Play supplies it. Available in > OpenIAP Spec 2.3.0 / openiap-google 2.3.1 (requires Play Billing 8.0+). ## 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+) | ## User Choice Billing Details (9.1+ fields) `UserChoiceBillingListener` receives `UserChoiceDetails` when the user selects developer billing from Google's user-choice screen. Read product identifiers from `UserChoiceDetails.Product.getId()`; `Product.toString()` is diagnostic text and is not the product ID contract. ```kotlin val listener = UserChoiceBillingListener { details -> val externalTransactionToken = details.externalTransactionToken val originalExternalTransactionId = details.originalExternalTransactionId val products = details.products.map { product -> Triple(product.id, product.type, product.offerToken) } } ``` OpenIAP keeps the compatibility `products` ID list and also exposes `productDetailsAndroid` with each product's ID, type, and optional offer token. For a developer-billed subscription replacement, forward `originalExternalTransactionId` together with the external transaction token to the backend reporting flow. These two fields are available in OpenIAP Spec 2.3.0 / openiap-google 2.3.1 (requires Play Billing 9.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( PendingPurchasesParams.newBuilder() .enableOneTimeProducts() .build() ) .enableAutoServiceReconnection() .enableBillingProgram( EnableBillingProgramParams.newBuilder() .setBillingProgram(BillingClient.BillingProgram.EXTERNAL_PAYMENTS) .setDeveloperProvidedBillingListener(developerBillingListener) .build() ) .build() ``` ### DeveloperProvidedBillingListener ```kotlin val developerBillingListener = DeveloperProvidedBillingListener { details -> // All nullable fields depend on the selected program and flow. val token: String? = details.externalTransactionToken val linkUri: String? = details.linkUri val originalTransactionId: String? = details.originalExternalTransactionId val products: List = details.products } ``` ### Launch Purchase with External Payments Option ```kotlin val params = BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf(productDetailsParams)) .enableDeveloperBillingOption( DeveloperBillingOptionParams.newBuilder() .setBillingProgram(BillingClient.BillingProgram.EXTERNAL_PAYMENTS) .setLinkUri(Uri.parse("https://example.com/checkout")) .setLaunchMode( DeveloperBillingOptionParams.LaunchMode.LAUNCH_IN_EXTERNAL_BROWSER_OR_APP ) .build() ) .build() billingClient.launchBillingFlow(activity, params) ``` ### Key Types (8.3+) | Type | Purpose | | ------------------------------------------------ | ------------------------------------------------------------- | | `DeveloperBillingOptionParams` | Configures developer billing on `BillingFlowParams` | | `DeveloperProvidedBillingListener` | Callback when user picks developer-provided billing | | `DeveloperProvidedBillingDetails` | Nullable token/link/original-ID fields plus selected products | | `BillingClient.BillingProgram.EXTERNAL_PAYMENTS` | External Payments program constant | > **OpenIAP Note**: Exposed through `enableBillingProgramAndroid`, > `developerBillingOption`, and the developer-provided billing listener. > Enrolment with Google Play's External Payments program is required; > availability is currently restricted to Japan. Horizon and Amazon do not > implement this Google Play program. ## Billing Choice (9.1+) Billing Library 9.1 adds APIs for markets and programs where either Google Play or the app renders a billing choice screen. ### Integration Scenarios | Scenario | Choice renderer | Developer payment | BillingClient setup | Required flow | | -------- | --------------- | ----------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | 1A | Google | In app | `EnableBillingProgramParams` with `DeveloperProvidedBillingListener` | Pass a minimal `DeveloperBillingOptionParams`; Play returns the token through the listener | | 1B | Developer | In app | `EnableBillingProgramParams` without the listener | Fetch choice info, create an `IN_APP` token, show the information dialog, then render the choice UI | | 2A | Google | External link | `EnableBillingProgramParams` with `DeveloperProvidedBillingListener` | Create an `EXTERNAL_LINK` token and pass it with the URI through `DeveloperBillingOptionParams` | | 2B | Developer | External link | `EnableBillingProgramParams` without the listener | Fetch choice info, create an `EXTERNAL_LINK` token, render the choice UI, then pass the token to `launchExternalLink` | The setup must match `choiceScreenType` from Play Console. Registering the listener in a developer-rendered integration is not equivalent to omitting it. | API / Type | Purpose | | -------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | `BillingClient.getBillingChoiceInfoAsync()` | Fetches billing choices available to the current user | | `BillingChoiceInfo` | Contains choice-screen data, including image URLs and loyalty details | | `GetBillingChoiceInfoParams` | Configures the billing-choice info request | | `BillingClient.showBillingProgramInformationDialog()` | Shows an information dialog for a billing program | | `BillingProgramInformationDialogParams` | Configures the information dialog | | `LaunchExternalLinkParams.setExternalTransactionToken()` | Supplies the pre-generated token for a developer-rendered external-link flow | | `BillingProgramAvailabilityDetails.BillingChoiceAvailabilityDetails` | Returns choice-screen type and external-link availability | | `DeveloperBillingOptionParams` | Selects in-app or external-link developer billing during purchase | | `BillingProgramReportingDetailsParams.DeveloperBillingType` | Distinguishes `IN_APP` and `EXTERNAL_LINK` reporting | ### Developer Billing Purchase Options Only `billingProgram` is required for an in-app Billing Choice flow: ```kotlin val inAppChoice = DeveloperBillingOptionParams.newBuilder() .setBillingProgram(BillingClient.BillingProgram.BILLING_CHOICE) .build() ``` For a Google-rendered external-link flow, also set the URI, launch mode, and the pre-generated `EXTERNAL_LINK` transaction token: ```kotlin val externalLinkChoice = DeveloperBillingOptionParams.newBuilder() .setBillingProgram(BillingClient.BillingProgram.BILLING_CHOICE) .setLinkUri(Uri.parse("https://example.com/checkout")) .setLaunchMode(DeveloperBillingOptionParams.LaunchMode.CALLER_WILL_LAUNCH_LINK) .setExternalTransactionToken(preGeneratedToken) .build() ``` ### Developer-Rendered Choice Information ```kotlin val params = GetBillingChoiceInfoParams.newBuilder() .setBillingProgram(BillingClient.BillingProgram.BILLING_CHOICE) .setPlayBillingChoiceImageLayout( GetBillingChoiceInfoParams.ImageLayout.RECTANGULAR_FOUR_BY_ONE ) .setUserLocale(Locale.forLanguageTag("en-US")) .build() billingClient.getBillingChoiceInfoAsync(params) { result, info -> if (result.responseCode == BillingClient.BillingResponseCode.OK) { val imageUrl = info.playBillingChoiceImageUrl val loyaltyText = info.playBillingLoyaltyInfo } } ``` Supported image layouts are `RECTANGULAR_FOUR_BY_ONE`, `RECTANGULAR_THREE_BY_ONE`, and `RECTANGULAR_TWO_BY_TWO`. ### Availability Details For `BILLING_CHOICE`, `BillingProgramAvailabilityDetails` can include: | Field | Meaning | | ------------------------- | --------------------------------------------------------- | | `choiceScreenType` | `UNSPECIFIED`, `DEVELOPER_RENDERED`, or `GOOGLE_RENDERED` | | `isExternalLinkAvailable` | Whether the user is eligible for an external-link option | ### Information Dialog For developer-rendered in-app choice (scenario 1B), call `showBillingProgramInformationDialog()` before showing the app's choice UI. It is a UI-thread API and returns through its listener; it does not return a synchronous `BillingResult`: ```kotlin val params = BillingProgramInformationDialogParams.newBuilder() .setBillingProgram(BillingClient.BillingProgram.BILLING_CHOICE) .setExternalTransactionToken(externalTransactionToken) .build() billingClient.showBillingProgramInformationDialog(activity, params) { result -> // Continue according to result.responseCode. } ``` ### Developer-Billed Subscription Replacement Use the original external transaction ID instead of an old Play purchase token when replacing a subscription bought through developer billing: ```kotlin val updateParams = BillingFlowParams.SubscriptionUpdateParams.newBuilder() .setOriginalExternalTransactionId(originalExternalTransactionId) .build() ``` > **OpenIAP Note**: OpenIAP exposes these through `BILLING_CHOICE`, > `getBillingChoiceInfoAndroid`, `showBillingProgramInformationDialogAndroid`, > `launchExternalLinkAndroid`, `developerBillingOption`, > `originalExternalTransactionId`, and the expanded developer-provided billing > callback. Set `InitConnectionConfig.billingChoiceScreenTypeAndroid` to > `GOOGLE_RENDERED` (default) or `DEVELOPER_RENDERED` so OpenIAP includes or > omits the listener correctly. Play-only APIs return unsupported/default > behavior on Horizon and Amazon. ## In-App Billing Messages (4.1+) `showInAppMessages()` must run on the UI thread. It returns a synchronous `BillingResult` for submission errors and reports the user interaction through `InAppMessageResponseListener`. ## PBL 9 Migration Guardrails - Replace removed APIs: `SkuDetails`, `SkuDetailsParams`, `SkuDetailsResponseListener`, `BillingClient.SkuType`, `querySkuDetailsAsync()`, no-argument `enablePendingPurchases()`, and string `queryPurchasesAsync()`. - Use `ProductDetails`, `QueryProductDetailsParams`, `BillingClient.ProductType`, parameterized `enablePendingPurchases(PendingPurchasesParams)`, and `queryPurchasesAsync(QueryPurchasesParams, ...)`. - Handle `DeveloperProvidedBillingDetails.getExternalTransactionToken()`, `getLinkUri()`, and `getOriginalExternalTransactionId()` as nullable. - Preserve every `DeveloperProvidedBillingDetails.Product` (`id`, `type`, and nullable `offerToken`) from the callback. - Keep Horizon shared code on the Billing 7.0-compatible API subset; put PBL 8/9 code in Play-only sources or behind reflection. ## 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 when available (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. **Distinguish in-app and external-link Billing Choice** when configuring and reporting developer billing 8. **Query fresh ProductDetails before purchase**; stale objects can make `launchBillingFlow()` fail --- # 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 | **2.0.0** (latest) | Google Play Billing **7.0** API | | Google Play Billing (upstream latest) | **9.1.0** | N/A | | Google Play Billing (OpenIAP Play flavor) | **9.1.0** | 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 or 9.x. When writing shared code for both Play and Horizon flavors: - Use only APIs that exist in **both** Billing 7.0 and the Play-flavor Billing version - Horizon SDK does NOT support Billing 8.x/9.x features like auto-reconnect, product status codes, `includeSuspended`, or Billing Choice - OpenIAP handles this automatically with flavor-specific implementations ### Latest Horizon Billing Release Meta released Horizon Billing Compatibility Library **2.0.0** on 2026-01-06. The release notes call out a fix for querying subscription purchases with a single billing plan and dependencies on Horizon Platform SDK Kotlin `iap-kotlin` 0.2.0 and `core-kotlin` 0.2.0. OpenIAP uses `horizon-billing-compatibility` 2.0.0 and its transitive Horizon Platform Kotlin SDK modules. The Horizon flavor is compiled and tested separately, including subscription-purchase restore coverage. Play Billing 9.1.0 features remain Play-flavor-only because Horizon compatibility still targets the Billing 7.0 API surface. ### 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+) ### APIs Only in Billing 9.x (DO NOT use in shared code) - Billing Choice information APIs (`getBillingChoiceInfoAsync`, `BillingChoiceInfo`, `ChoiceScreenType`) - Billing-program information dialog APIs (`showBillingProgramInformationDialog`) - PBL 9 migration assumptions around APIs removed from the Billing 7.0 surface ## 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 - Horizon Billing Compatibility 2.x reads the app id from Android manifest meta-data key `com.meta.horizon.platform.HORIZON_APP_ID`. The older `com.meta.horizon.platform.ovr.OCULUS_APP_ID` key is deprecated; OpenIAP also accepts it and other historical keys only for migration compatibility. - 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/) --- # StoreKit 2 API Reference This document provides external API reference for Apple's StoreKit 2 framework. ## Recent StoreKit Features | Feature | iOS Version | Description | | -------------------------------------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------- | | Win-back offers | iOS 18.0 | Re-engage churned subscribers | | `Product.SubscriptionInfo.RenewalInfo.eligibleWinBackOfferIDs` | iOS 18.0 | Query win-back offer eligibility before purchase | | Consumable transaction history | iOS 18.0 | Opt-in via `SKIncludeConsumableInAppPurchaseHistory` Info.plist key | | StoreKit `Message.billingIssue` | iOS / Mac Catalyst 16.4, visionOS 1.0 | Listener for subscription billing issues (`Message` is unavailable on macOS, tvOS, and watchOS) | | 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:)` | | Monthly subscriptions with a 12-month commitment | iOS 26.4+ runtime / Xcode 26.5 SDK | Monthly billing option for annual auto-renewable subscriptions | | Group purchases and volume purchasing | WWDC 2026 | Multi-seat auto-renewable subscriptions through StoreKit 2 and Apple Business / School Manager | | Retention Messaging | WWDC 2026 | Cancellation-flow messaging and offers, including real-time server decisioning | | Retention offer type | WWDC 2026 | Signed transaction / renewal info can report offer type `5` for retention offers | | Offer codes for all IAP types | 2026 | Offer codes expand beyond auto-renewable subscriptions; IAP promo-code creation ends March 26, 2026 | ### StoreKit Message presentation Iterating `Message.messages` transfers presentation control to the app. A listener that only inspects `Message.Reason.billingIssue` must not silently discard other reasons such as price-increase consent or win-back offers. Display each message with `message.display(in:)` unless the app intentionally implements and documents a custom delay or suppression policy. OpenIAP preserves StoreKit's default presentation while additionally emitting its cross-platform billing issue event. ### 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. ### WWDC 2026 Updates - **Monthly subscriptions with a 12-month commitment**: The Xcode 26.5 SDK adds a monthly billing plan for one-year auto-renewable subscriptions. Customers can subscribe on iOS, iPadOS, macOS, tvOS, and visionOS 26.4+. - **Group purchases and volume purchasing**: Auto-renewable subscriptions using StoreKit 2 can be sold to groups and organizations. In-app group purchases pass a requested seat count into the StoreKit purchase flow; Apple Business Manager and Apple School Manager handle volume purchasing. - **Volume pricing**: App Store Connect can configure up to five seat-count price bands for larger subscription purchases. - **Retention Messaging**: App Store Connect can show cancellation-flow retention messages and offers. Real-time Retention Messaging adds a server-to-server decision point and supports a switch-plan view for monthly subscriptions with a 12-month commitment. - **Offer-code expansion**: Offer codes now support consumables, non-consumables, non-renewing subscriptions, and broader auto-renewable subscription scenarios. Starting March 26, 2026, App Store Connect no longer creates new promo codes for In-App Purchases. ### Verified Offer-Code Redemption (WWDC 2026) The new UIKit/AppKit redemption API accepts `RedeemOption` values and returns the redeemed transaction as a `VerificationResult`: ```swift let result = try await AppStore.presentOfferCodeRedeemSheet( from: viewController, options: [] ) ``` SwiftUI exposes the same result through `offerCodeRedemption(options:isPresented:onCompletion:)`. These APIs require the Xcode 27 beta SDK and are currently beta. Xcode 26.x SDKs expose only the legacy redemption sheet API. > **OpenIAP gap**: `presentCodeRedemptionSheetIOS` still wraps the legacy > `SKPaymentQueue.presentCodeRedemptionSheet()` API and returns only a Boolean. > Redeem options and the verified transaction result need a new end-to-end > schema and wrapper contract. ## 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 transaction.appAccountToken != nil { // Token will only be present if a valid UUID was provided during purchase print("App Account Token available") } ``` ### 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.RenewalInfo.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?.winBackOffers ?? []).filter { eligibleIDs.contains($0.id ?? "") } ``` > **OpenIAP gap**: callers can apply a known win-back offer identifier, but the > public product/renewal types do not yet expose `winBackOffers` or > `eligibleWinBackOfferIDs` for discovery. ### 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 } ``` ## Monthly Subscriptions With 12-Month Commitment (iOS 26.4+) This billing plan lets customers pay monthly while committing to an annual auto-renewable subscription. Apps need to compile with the Xcode 26.5 SDK to merchandise the plan, and customers can purchase on Apple platforms running 26.4 or later. ```swift let result = try await product.purchase(options: [ .billingPlanType(.monthly) ]) ``` > **OpenIAP Note**: The schema represents this with > `SubscriptionBillingPlanTypeIOS` and `RequestSubscriptionIosProps.billingPlanType`. ## Group Purchases and Volume Purchasing (WWDC 2026) StoreKit 2 auto-renewable subscriptions can be sold to multiple seats for groups or organizations. Volume purchasing is handled by Apple Business Manager and Apple School Manager. For in-app group purchases, the app starts a StoreKit purchase with the requested seat count, then Apple can manage invitation links and seat assignment unless the app integrates custom group management. > **OpenIAP gap**: No public OpenIAP request field exists yet for group-purchase > seat count or StoreKit group-management identifiers. ## Retention Messaging (WWDC 2026) Retention Messaging lets App Store Connect present messages and optional offers when a subscriber is about to cancel. Real-time Retention Messaging can call a server endpoint so the developer can choose the message, offer, or switch-plan view at cancellation time. Signed transaction and renewal information can include a retention offer as offer type `5`. ## StoreKit Message API (iOS 16.0+; billing issue on iOS 16.4+; win-back on iOS 18+) Listen for App Store–generated messages (billing issues, win-back offers, price increases, generic). ```swift // Somewhere near app launch. This all-cases sample targets iOS 18+. 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 .priceIncreaseConsent: break case .generic: break @unknown default: break } } } ``` | Reason | Availability | Trigger | | ----------------------- | ------------ | -------------------------------------------------------- | | `.billingIssue` | iOS 16.4+ | User has an unresolved billing problem on a subscription | | `.priceIncreaseConsent` | iOS 16.0+ | Price change that requires user consent | | `.winBackOffer` | iOS 18.0+ | User is eligible for a win-back offer | | `.generic` | iOS 16.0+ | All other system-initiated messages | > **OpenIAP Note**: OpenIAP displays every StoreKit message and additionally > surfaces `.billingIssue` through `subscriptionBillingIssue`; other reasons > are not separate OpenIAP 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 SKIncludeConsumableInAppPurchaseHistory ``` With the key set, finished consumable transactions are included in `Transaction.all`, `Transaction.latest(for:)`, and `Product.latestTransaction`. ## External Purchase Support (iOS 17.4+) `ExternalPurchase.presentNoticeSheet()` / `ExternalPurchaseLink.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 .continuedWithExternalPurchaseToken(let token): // Send the token to your backend reporting flow preserveForBackend(token) case .cancelled: break } } ``` ### Present External Purchase Link ```swift try await ExternalPurchaseLink.open(url: externalURL) ``` > **OpenIAP Note**: `presentExternalPurchaseNoticeSheetIOS` is available on > iOS 17.4+ and macOS 14.4+. The current > `presentExternalPurchaseLinkIOS` implementation uses `UIApplication` and is > not supported on macOS. --- # 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. --- # IAPKit > Receipt-validation SaaS managed by OpenIAP. Hosted at https://kit.openiap.dev. > One Bearer-authed endpoint for Apple / Google / Horizon / Amazon; > harmonized response shape with `{ store, isValid, state, productId? }` so your backend has a single code path for > entitlement + refund detection. IAPKit lives in the OpenIAP monorepo as a Bun + Hono server, Convex backend, and React SPA deployed behind one origin. ## API quick reference Base URL: https://kit.openiap.dev Purchase verification and MCP auth: `Authorization: Bearer openiap-kit_` Product, subscription, and webhook helper routes carry the same project key in their `{apiKey}` path segment. - [POST /v1/purchase/verify](https://kit.openiap.dev/docs/api) — verify an in-app purchase; body is a tagged union on `store` - [GET /v1/products/{apiKey}?platform=IOS&includeClientPayload=true&limit=25](https://kit.openiap.dev/docs/products) — list public product payloads in bounded cursor pages (platform required, limit 1-50, follow `nextCursor` while `hasMore`) - [GET /v1/products/{apiKey}/{productId}/client-payload?platform=IOS](https://kit.openiap.dev/docs/products) — fetch `{ clientPayload }`; returns 404 when the product is missing/Removed or no payload exists - [POST /v1/webhooks/{apiKey}](https://openiap.dev/docs/webhooks#setup) — lifecycle webhook receiver. Paste this URL into App Store Connect (Production + Sandbox) AND Google Cloud Pub/Sub push subscription. Auto-detects ASN v2 vs Pub/Sub by payload shape. **POST-only**; opening in a browser returns 404 — that's expected. - [GET /v1/webhooks/stream/{apiKey}](https://openiap.dev/docs/webhooks#consume-stream) — long-lived SSE stream of normalized `WebhookEvent`s. Connect with `EventSource` (or the per-SDK helper); reconnects honor `Last-Event-ID`. Opening in a browser shows a blank page (text/event-stream that never closes) — use the SDK helpers or `curl -N`. - [GET /v1/openapi](https://kit.openiap.dev/v1/openapi) — machine-readable OpenAPI spec - [GET /v1](https://kit.openiap.dev/v1) — Redoc UI for the OpenAPI spec - [GET /health](https://kit.openiap.dev/health) — liveness probe (no Convex round-trip) - [POST /mcp](https://kit.openiap.dev/docs/ai-assistants/codex-plugin) — MCP Streamable HTTP endpoint for Codex and other MCP clients. Uses an IAPKit project API key, not an OpenAI or ChatGPT API key. Also mounted at `/api/v1/*` for backwards compatibility. `/v1/verify-purchase` is an alias of `/v1/purchase/verify`. Pick `/v1/purchase/verify` for new code. ## Request shapes (discriminated on `store`) - Apple — `{ store: "apple", jws, expectedProductId?, includeClientPayload? }` where `jws` is a StoreKit 2 JWS (≤ 16 KB) - Google — `{ store: "google", purchaseToken, expectedProductId?, includeClientPayload? }` (token ≤ 2 KB) - Horizon — `{ store: "horizon", userId, sku }` (≤ 256 chars each). IAPKit holds the App ID + App Secret server-side and composes the `OC|APP_ID|APP_SECRET` access token per-request. - Amazon — `{ store: "amazon", userId, receiptId, sandbox? }` where `userId` and `receiptId` come from Amazon Appstore RVS. ## Success response ```json { "store": "amazon", "isValid": true, "state": "ENTITLED" } ``` For Apple/Google only, `includeClientPayload: true` may add a top-level `clientPayload` when verification is valid, the store supplies a verified productId, and that exact platform/product has a payload: ```json { "store": "apple", "isValid": true, "state": "ENTITLED", "productId": "premium_monthly", "clientPayload": { "format": "toml", "body": "[access]\nmax_items = 10", "version": 3, "updatedAt": 1784160000000 } } ``` Default requests, invalid receipts, missing payloads, Horizon, and Amazon omit `clientPayload`. Entitlement decisions must use `isValid`, `state`, and the store-verified `productId`. Require that ID to match the product the app expected; never substitute a client-supplied ID when the verified value is missing, and never use payload contents alone. Harmonized `state` values (truthy `isValid`): `ENTITLED`, `PENDING_ACKNOWLEDGMENT`, `READY_TO_CONSUME`. Falsey `isValid`: `PENDING`, `CONSUMED`, `CANCELED`, `EXPIRED`, `INAUTHENTIC`, `UNKNOWN`. ## Product client payloads - Shape: `{ format: "toml" | "json" | "text", body, version, updatedAt }` - Body is required, nonblank, and ≤ 16 KiB measured as UTF-8 bytes. - JSON must parse to a non-null, non-array object; TOML syntax is validated. - Payloads are public app data. Never store secrets, credentials, or private server rules. - Store Sync never pushes or overwrites them. Resetting the local catalog retains them. - They are returned by app requests, not sent through APNs/FCM, and do not display an OS notification. - The separate 64 KB product-management cap applies to the whole HTTP request before parsing; it is not per-product payload storage. ## Status codes - `200` — verification ran; require `isValid`, an operation-appropriate `state`, and an exact store-verified `productId` match - `400 INVALID_INPUT` — malformed body / unknown store / oversized field - `413 PAYLOAD_TOO_LARGE` — request body exceeds the 32 KB edge cap - `401 MISSING_API_KEY` — no `Authorization` header - `403 INVALID_API_KEY` — wrong scheme, malformed, or unrecognized key - `429 RATE_LIMITED` — per-key bucket empty; honor `Retry-After` seconds - `500 UNKNOWN_ERROR` — quote the `X-Correlation-Id` header in a support ticket ## Response headers (on 2xx / 4xx validation / 429) - `X-Correlation-Id` — UUIDv4, matches the stdout log line - `X-RateLimit-Limit` — bucket capacity (default 600 per key) - `X-RateLimit-Remaining` — tokens left in the bucket - `Retry-After` (429 only) — seconds to wait ## Docs - [/docs](https://kit.openiap.dev/docs) — full in-dashboard documentation - [/docs/quickstart](https://kit.openiap.dev/docs/quickstart) — signup → project → API key → first verify - [/docs/products](https://kit.openiap.dev/docs/products) — catalog sync, client payload editing, retrieval, caching, and limits - [/docs/verification/apple](https://kit.openiap.dev/docs/verification/apple) — bundle ID, Issuer ID, Key ID, .p8 - [/docs/verification/google](https://kit.openiap.dev/docs/verification/google) — package name, service account JSON - [/docs/verification/horizon](https://kit.openiap.dev/docs/verification/horizon) — App ID + App Secret (write-only) - [/docs/api](https://kit.openiap.dev/docs/api) — request shapes, responses, errors, headers, and Amazon RVS payloads - [/docs/operations](https://kit.openiap.dev/docs/operations) — rate limits, logs, `/health`, graceful shutdown - [openiap.dev/docs/webhooks](https://openiap.dev/docs/webhooks) — operator setup steps for the lifecycle webhook URL (Apple ASN v2 + Google RTDN) and SDK code for consuming the SSE stream - [/docs/ai-assistants](https://kit.openiap.dev/docs/ai-assistants) — how to point Codex / Claude / Cursor / etc. at this file - [/docs/ai-assistants/codex-plugin](https://kit.openiap.dev/docs/ai-assistants/codex-plugin) — Codex plugin setup and self-hosted IAPKit MCP server option - [/docs/release-notes](https://kit.openiap.dev/docs/release-notes) — changelog --- ## 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