# 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