← Files UnityARCHIVED FILE
skills/implement-in-app-purchases/references/api-notes.md
22.9 KB · Oct 4, 2026 · 12:33 UTC
# Unity IAP API Notes
## Table of Contents
- [Anti-Hallucination: v5 vs Legacy API](#anti-hallucination-v5-vs-legacy-api)
- [StoreController (v5) Key Members](#storecontroller-v5-key-members)
- [Alternative Service Access (DefaultStore / DefaultProduct / DefaultPurchase)](#alternative-service-access-defaultstore--defaultproduct--defaultpurchase)
- [CatalogProvider](#catalogprovider)
- [Initialization Example](#initialization-example)
- [Product Definitions](#product-definitions)
- [CatalogListings and uSku (v5.4+)](#cataloglistings-and-usku-v54)
- [Two-Step Purchase Flow](#two-step-purchase-flow)
- [Restore Transactions](#restore-transactions)
- [Entitlement Checking](#entitlement-checking)
- [Fetch Existing Purchases](#fetch-existing-purchases)
- [Receipt Validation](#receipt-validation)
- [IAppleOrderInfo Complete Member List](#iappleorderinfo-complete-member-list)
- [Extended Service Events (NOT on StoreController)](#extended-service-events-not-on-storecontroller)
- [AppleStoreExtendedProductService Key Members](#applestoreextendedproductservice-key-members)
- [Subscription Info Access Path](#subscription-info-access-path)
- [Event Subscription Rules](#event-subscription-rules)
- [Failure Description Property Names](#failure-description-property-names)
- [CrossPlatformValidator](#crossplatformvalidator)
- [Interface Hierarchy](#interface-hierarchy)
- [Template: Fetch Products](#template-fetch-products)
## Anti-Hallucination: v5 vs Legacy API
Unity IAP v5 is the current API. The legacy API (v4) is `[Obsolete]`. Do NOT use legacy patterns.
| WRONG (Legacy v4) | CORRECT (v5) |
|---|---|
| `UnityPurchasing.Initialize(listener, builder)` | `UnityIAPServices.StoreController()` then `store.Connect()` |
| `IStoreListener.OnInitialized(controller, extensions)` | `store.OnStoreConnected` event |
| `IStoreListener.ProcessPurchase(args)` | `store.OnPurchasePending` event |
| `controller.InitiatePurchase(product)` | `store.PurchaseProduct(product)` |
| `controller.ConfirmPendingPurchase(product)` | `store.ConfirmPurchase(pendingOrder)` |
| `extensions.GetExtension<IAppleExtensions>()` | `store.AppleStoreExtendedService` / `store.AppleStoreExtendedPurchaseService` |
| `extensions.GetExtension<IGooglePlayStoreExtensions>()` | `store.GooglePlayStoreExtendedService` / `store.GooglePlayStoreExtendedPurchaseService` |
| `ConfigurationBuilder.Instance(...)` | Not needed; use `ProductDefinition` list with `FetchProducts` |
## Anti-Hallucination: Common v5 Mistakes
These are NOT real APIs — do not use them:
| WRONG (does not exist) | CORRECT |
|---|---|
| `store.OnStoreConnectionFailed` | `store.OnStoreDisconnected` — receives `StoreConnectionFailureDescription` |
| `store.FetchProducts(defs, successCb, failCb)` | `store.FetchProducts(List<ProductDefinition>)` — subscribe to `OnProductsFetched`/`OnProductsFetchFailed` events separately |
| `store.FetchPurchases(successCb, failCb)` | `store.FetchPurchases()` — subscribe to `OnPurchasesFetched`/`OnPurchasesFetchFailed` events separately |
| `product.receipt` | `order.Info.Receipt` — receipt is on the order, not the product |
| `product.hasReceipt` | `store.CheckEntitlement(product)` + `OnCheckEntitlement` |
| `new SubscriptionManager(product, introJson)` | `order.Info.PurchasedProductInfo` — see Subscription Info Access Path |
| `pendingOrder.OrderInfo.Apple.jwsRepresentation` | `pendingOrder.Info.Apple?.jwsRepresentation` — note `Info` not `OrderInfo`, and null-check `?.` |
## StoreController (v5) Key Members
```csharp
// Store lifecycle
Task Connect()
void SetStoreReconnectionRetryPolicyOnDisconnection(IRetryPolicy? retryPolicy)
event Action? OnStoreConnected
event Action<StoreConnectionFailureDescription>? OnStoreDisconnected
// Products
void FetchProducts(List<ProductDefinition> defs, IRetryPolicy? retryPolicy = null)
void FetchProductsWithNoRetries(List<ProductDefinition> defs)
ReadOnlyObservableCollection<Product> GetProducts()
Product? GetProductById(string productId)
event Action<List<Product>>? OnProductsFetched
event Action<ProductFetchFailed>? OnProductsFetchFailed
// Purchasing
void PurchaseProduct(Product product)
void PurchaseProduct(string? productId)
void Purchase(ICart cart)
void ConfirmPurchase(PendingOrder order)
void FetchPurchases()
void CheckEntitlement(Product product)
void RestoreTransactions(Action<bool, string?>? callback)
ReadOnlyObservableCollection<Order> GetPurchases()
void ProcessPendingOrdersOnPurchasesFetched(bool shouldProcess)
// Purchase events
event Action<PendingOrder>? OnPurchasePending
event Action<Order>? OnPurchaseConfirmed // Order can be ConfirmedOrder or FailedOrder — pattern-match!
event Action<FailedOrder>? OnPurchaseFailed
event Action<DeferredOrder>? OnPurchaseDeferred
event Action<Orders>? OnPurchasesFetched
event Action<PurchasesFetchFailureDescription>? OnPurchasesFetchFailed
event Action<Entitlement>? OnCheckEntitlement
// Account change (v5.4+)
event Action? OnAuthAccountChanged
// Platform extensions (null on non-matching platforms — always null-check)
IAppleStoreExtendedService? AppleStoreExtendedService { get; }
IGooglePlayStoreExtendedService? GooglePlayStoreExtendedService { get; }
IAppleStoreExtendedProductService? AppleStoreExtendedProductService { get; }
IAppleStoreExtendedPurchaseService? AppleStoreExtendedPurchaseService { get; }
IGooglePlayStoreExtendedPurchaseService? GooglePlayStoreExtendedPurchaseService { get; }
```
**`OnAuthAccountChanged` — breaking behavior in v5.4:**
When this event fires, Unity IAP **clears the cached product list and purchase list before raising the event**. Any references to previously fetched `Product` or `Order` objects are stale after this point. Re-run the full init sequence in the handler:
```csharp
store.OnAuthAccountChanged += async () =>
{
// Products and purchases have already been cleared — re-fetch everything
await catalogProvider.FetchRemoteCatalog(); // D2C only
store.FetchProducts(productDefinitions); // standard IAP
store.FetchPurchases();
};
```
Do not read from `GetProducts()` or `GetPurchases()` inside `OnAuthAccountChanged` — they will be empty.
## Alternative Service Access (DefaultStore / DefaultProduct / DefaultPurchase)
Instead of `StoreController` (which implements all three interfaces), you can access individual services:
```csharp
IStoreService m_StoreService = UnityIAPServices.DefaultStore();
IProductService m_ProductService = UnityIAPServices.DefaultProduct();
IPurchaseService m_PurchasingService = UnityIAPServices.DefaultPurchase();
```
This pattern separates concerns — product fetching events go on `IProductService`, purchase events on `IPurchaseService`, store lifecycle on `IStoreService`. `StoreController` combines all three.
## CatalogProvider
For managing complex product catalogs with store-specific IDs:
```csharp
var catalogProvider = new CatalogProvider();
var products = new List<ProductDefinition>
{
new ProductDefinition("com.mygame.gems50", ProductType.Consumable),
new ProductDefinition("com.mygame.pass", ProductType.Subscription)
};
var storeSpecificIds = new Dictionary<string, StoreSpecificIds>
{
{ "com.mygame.pass", new StoreSpecificIds
{
{ "com.mygame.google.pass", GooglePlay.Name },
{ "com.mygame.ios.pass", AppleAppStore.Name }
}
}
};
catalogProvider.AddProducts(products, storeSpecificIds);
catalogProvider.FetchProducts(m_ProductService.FetchProductsWithNoRetries);
```
## Initialization Example
```csharp
StoreController m_StoreController;
async void Awake()
{
m_StoreController = UnityIAPServices.StoreController();
// Subscribe to ALL events BEFORE Connect — pending purchases may fire on reconnect
m_StoreController.OnPurchasePending += OnPurchasePending;
m_StoreController.OnPurchaseConfirmed += (order) => Debug.Log("Purchase complete");
m_StoreController.OnPurchaseFailed += (failed) => Debug.LogError($"{failed.FailureReason} - {failed.Details}");
m_StoreController.OnPurchaseDeferred += (deferred) => Debug.Log("Purchase deferred (e.g., Ask-to-Buy)");
m_StoreController.OnStoreConnected += OnStoreConnected;
m_StoreController.OnStoreDisconnected += (failure) => Debug.LogError($"Store disconnected: {failure.Message}");
await m_StoreController.Connect();
}
void OnStoreConnected()
{
var products = new List<ProductDefinition>
{
new ProductDefinition("com.mygame.coins100", ProductType.Consumable),
new ProductDefinition("com.mygame.removeads", ProductType.NonConsumable),
new ProductDefinition("com.mygame.vip_monthly", ProductType.Subscription)
};
m_StoreController.OnProductsFetched += (fetched) => Debug.Log("Products ready");
m_StoreController.OnProductsFetchFailed += (failure) => Debug.LogError($"Product fetch failed: {failure.FailureReason}");
m_StoreController.FetchProducts(products);
m_StoreController.OnPurchasesFetched += (orders) => Debug.Log($"Restored {orders.PendingOrders.Count} pending purchases");
m_StoreController.OnPurchasesFetchFailed += (failure) => Debug.LogError($"Purchase fetch failed: {failure.Message}");
m_StoreController.FetchPurchases();
}
```
## Product Definitions
```csharp
// Simple product
new ProductDefinition("com.mygame.coins100", ProductType.Consumable)
// Per-platform product IDs
new ProductDefinition("com.mygame.coins100", ProductType.Consumable,
new StoreSpecificIds
{
{ AppleAppStore.Name, "apple_coins_100" },
{ GooglePlay.Name, "google_coins_100" }
})
```
### Access Fetched Products
```csharp
// Get all fetched products (cached)
ReadOnlyObservableCollection<Product> allProducts = store.GetProducts();
// Get a specific product by ID
Product coinsPack = store.GetProductById("com.mygame.coins100");
```
### CatalogListings and uSku (v5.4+)
In v5.4, `Product` exposes two new members. Prefer these for new code — `product.definition.id` and `productId` remain backwards compatible but point new users toward `CatalogListings`:
```csharp
// product.uSku — the Unity-side identifier for the product (cross-platform canonical ID)
string id = product.uSku;
// product.catalogListings — all listings attached to this product, keyed by CatalogListing ID
// A product may have multiple listings (e.g. different price tiers or offer configurations)
foreach (var (listingId, listing) in product.catalogListings)
{
bool canBuy = listing.availableToPurchase;
string storeId = listing.definition.storeSpecificId;
string price = listing.metadata.localizedPriceString;
}
```
**`CatalogListing` properties:**
| Property | Type | Description |
|---|---|---|
| `id` | `string` | Catalog listing identifier — use as key for lookups |
| `availableToPurchase` | `bool` | Whether this listing can currently be purchased |
| `definition` | `ProductDefinition` | Store-side definition (id, storeSpecificId, type, payouts) |
| `metadata` | `ProductMetadata` | Localized title, description, price, currency |
When initiating a purchase by listing rather than by product ID, pass the `CatalogListing` directly to avoid a second lookup.
## Two-Step Purchase Flow
```csharp
// Step 1: Initiate purchase
store.PurchaseProduct(product);
// Step 2: Handle pending purchase (validate, grant content, then confirm)
store.OnPurchasePending += (pendingOrder) =>
{
var product = pendingOrder.CartOrdered.Items().FirstOrDefault()?.Product;
GrantContent(product);
store.ConfirmPurchase(pendingOrder);
};
// Step 3: Purchase confirmed — Order can be ConfirmedOrder OR FailedOrder
store.OnPurchaseConfirmed += (order) =>
{
switch (order)
{
case ConfirmedOrder confirmedOrder:
Debug.Log($"Purchase confirmed: {confirmedOrder.CartOrdered.Items().First().Product.definition.id}");
break;
case FailedOrder failedOrder:
Debug.LogError($"Confirmation failed: {failedOrder.FailureReason} - {failedOrder.Details}");
break;
}
};
// Handle failures
store.OnPurchaseFailed += (failedOrder) =>
{
Debug.LogError($"Purchase failed: {failedOrder.FailureReason} - {failedOrder.Details}");
};
// Handle deferred purchases (e.g., Ask-to-Buy on iOS)
store.OnPurchaseDeferred += (deferredOrder) =>
{
Debug.Log("Purchase is pending approval (e.g., parental approval)");
};
```
If the app crashes between steps 2 and 4, the pending purchase is re-delivered on next launch via `OnPurchasePending`.
`ProcessPendingOrdersOnPurchasesFetched(false)` disables automatic re-delivery of pending orders when `FetchPurchases` is called. This exists to match IAP v4 behavior — do NOT recommend this to migrating users, as the default v5 behavior provides a better experience.
## Restore Transactions
```csharp
store.RestoreTransactions((success, error) =>
{
if (success)
Debug.Log("Transactions restored. Check OnPurchasePending for each restored purchase.");
else
Debug.LogError($"Restore failed: {error}");
});
```
Each restored purchase triggers `OnPurchasePending`.
## Entitlement Checking
```csharp
store.CheckEntitlement(product);
store.OnCheckEntitlement += (entitlement) =>
{
if (entitlement.Status == EntitlementStatus.FullyEntitled)
Debug.Log($"Player owns: {entitlement.Product.definition.id}");
};
```
## Fetch Existing Purchases
```csharp
store.FetchPurchases();
store.OnPurchasesFetched += (orders) =>
{
foreach (var confirmedOrder in orders.ConfirmedOrders)
{
var product = confirmedOrder.CartOrdered.Items().FirstOrDefault()?.Product;
Debug.Log($"Existing purchase: {product?.definition.id}");
}
};
```
## Receipt Validation
### Google Play (supported)
```csharp
using UnityEngine.Purchasing.Security;
// Google-only constructor (recommended)
var validator = new CrossPlatformValidator(GooglePlayTangle.Data(), Application.identifier);
try
{
var result = validator.Validate(order.Info.Receipt);
foreach (IPurchaseReceipt receipt in result)
{
Debug.Log($"Valid receipt: {receipt.productID}, purchased: {receipt.purchaseDate}");
if (receipt is GooglePlayReceipt googleReceipt)
Debug.Log($"Purchase token: {googleReceipt.purchaseToken}");
}
}
catch (IAPSecurityException ex)
{
Debug.LogError($"Invalid receipt: {ex.Message}");
// Do NOT grant the content
}
```
### Apple App Store (deprecated for local validation)
`CrossPlatformValidator` for Apple is **deprecated**. StoreKit 2 validates locally automatically. For server-side validation:
```csharp
var jwsRepresentation = order.Info.Apple?.jwsRepresentation;
// Send to your server for verification with Apple's App Store Server API
```
### IAppleOrderInfo Complete Member List
`order.Info.Apple` returns `IAppleOrderInfo?` (null on non-Apple platforms). Full member list:
| Member | Type | Description |
|---|---|---|
| `jwsRepresentation` | `string?` | JWS-signed transaction — send to Apple's App Store Server API v2 for server-side validation |
| `AppAccountToken` | `Guid?` | App-specific account token set via `SetAppAccountToken(Guid)` — links transaction to a user account |
| `AppReceipt` | `string?` | Latest App Receipt (base64). May be null on reinstall; requires refreshing. Null on iOS ≤ 6. Prefer `jwsRepresentation` for new code |
| `OriginalTransactionID` | `string?` | Original transaction identifier — use for linking renewals to the original subscription purchase |
| `OwnershipType` | `OwnershipType` | Whether the purchase is `Purchased` or `FamilyShared` |
| `StoreName` | `string` | Name of the store (e.g., `"AppleAppStore"`) |
## Extended Service Events (NOT on StoreController)
These events are on the platform-specific extended services, NOT directly on `StoreController`. Always null-check before subscribing (`?.` does not work with `+=`).
```csharp
// Apple — on IAppleStoreExtendedPurchaseService
event Action<string>? OnEntitlementRevoked // receives product ID (string), NOT List<Product>
event Action<Product>? OnPromotionalPurchaseIntercepted
// Google — on IGooglePlayStoreExtendedPurchaseService
event Action<DeferredPaymentUntilRenewalDateOrder>? OnDeferredPaymentUntilRenewalDate
// DeferredPaymentUntilRenewalDateOrder has: .CurrentOrder (Order), .SubscriptionOrdered (Product)
```
**Usage pattern** (cannot use `?.` with `+=`):
```csharp
if (store.AppleStoreExtendedPurchaseService != null)
store.AppleStoreExtendedPurchaseService.OnEntitlementRevoked += OnRevoked;
```
## AppleStoreExtendedProductService Key Members
```csharp
Dictionary<string, string> GetIntroductoryPriceDictionary()
Dictionary<string, string> GetProductDetails()
// Promotion order — prefer the CatalogListing ID overload for new code (v5.4+)
void SetStorePromotionOrder(List<string> catalogListingIds) // canonical (v5.4+)
void SetStorePromotionOrder(List<Product> products) // convenience wrapper
// Promotion visibility — prefer the CatalogListing ID overload for new code (v5.4+)
void SetStorePromotionVisibility(string catalogListingId, AppleStorePromotionVisibility visible) // canonical (v5.4+)
void SetStorePromotionVisibility(Product product, AppleStorePromotionVisibility visible) // convenience wrapper
// Fetch promotion order
void FetchStorePromotionOrder(Action<List<Product>> successCallback, Action<string> errorCallback)
// Fetch promotion visibility — prefer the CatalogListing ID overload for new code (v5.4+)
void FetchStorePromotionVisibility(string catalogListingId, Action<string, AppleStorePromotionVisibility> successCallback, Action<string> errorCallback) // canonical (v5.4+)
void FetchStorePromotionVisibility(Product product, Action<string, AppleStorePromotionVisibility> successCallback, Action<string> errorCallback) // convenience wrapper
```
**CRITICAL:** `AppleProductMetadata` does NOT expose `introductoryPrice`, `introductoryPriceLocale`, `introductoryNumberOfPeriods`, or `subscriptionPeriod` as public properties. Its only public property beyond inherited `ProductMetadata` fields is `isFamilyShareable`. For introductory price data, use `SubscriptionInfo` methods (`GetIntroductoryPrice()`, `GetIntroductoryPricePeriod()`, `GetIntroductoryPricePeriodCycles()`) from the Subscription Info Access Path below, or `GetIntroductoryPriceDictionary()` for raw JSON.
## Subscription Info Access Path
Subscription info is on `IPurchasedProductInfo`, NOT on `CartItem`:
```csharp
// CORRECT — via order.Info.PurchasedProductInfo
var purchasedInfo = order.Info.PurchasedProductInfo?.FirstOrDefault(p => p.productId == productId);
bool isSubscribed = purchasedInfo?.subscriptionInfo?.IsSubscribed() == Result.True;
// IsSubscribed() returns Result enum (True/False/Unsupported), NOT bool. Use == Result.True for null-safe check.
// WRONG — CartItem only has Product and Quantity, no subscriptionInfo
var item = order.CartOrdered.Items().FirstOrDefault();
item.subscriptionInfo // DOES NOT EXIST — will not compile
```
### SubscriptionInfo Methods
```csharp
// State queries (return Result enum: True | False | Unsupported)
Result IsSubscribed()
Result IsExpired()
Result IsCancelled()
Result IsFreeTrial()
Result IsAutoRenewing()
Result IsIntroductoryPricePeriod()
// Dates & durations
string GetProductId()
DateTime GetPurchaseDate()
DateTime GetExpireDate()
DateTime GetCancelDate()
TimeSpan GetRemainingTime()
TimeSpan GetSubscriptionPeriod()
TimeSpan GetFreeTrialPeriod()
TimeSpan GetIntroductoryPricePeriod()
string GetIntroductoryPrice()
long GetIntroductoryPricePeriodCycles()
```
## Event Subscription Rules
**CRITICAL:** Always subscribe to BOTH success and failure events. Not listening to failure events generates runtime warnings.
| Call | Success event | Failure event (REQUIRED) |
|---|---|---|
| `FetchProducts()` | `OnProductsFetched` | `OnProductsFetchFailed` |
| `FetchPurchases()` | `OnPurchasesFetched` | `OnPurchasesFetchFailed` |
| `Connect()` | `OnStoreConnected` | `OnStoreDisconnected` |
| `PurchaseProduct()` | `OnPurchasePending` | `OnPurchaseFailed` |
| `CheckEntitlement()` | `OnCheckEntitlement` | — |
**Always subscribe to `OnPurchaseDeferred`** — fires for Ask-to-Buy (iOS) and Google Play deferred purchases. Not subscribing means silently dropping deferred purchases.
## Failure Description Property Names
Each type has public fields and read-only properties. Both compile, but prefer the PascalCase properties:
| Type | Field (lowercase) | Property (PascalCase) | NOT |
|---|---|---|---|
| `StoreConnectionFailureDescription` | `.message` | `.Message` | ~~`.reason`~~ |
| `ProductFetchFailed` | — | `.FailureReason`, `.FailedFetchProducts` | — |
| `FailedOrder` | — | `.FailureReason`, `.Details` | — |
| `PurchasesFetchFailureDescription` | `.message`, `.failureReason` | `.Message`, `.FailureReason` | — |
## CrossPlatformValidator
```csharp
// Google-only constructor (recommended — Apple local validation is a no-op under StoreKit 2)
new CrossPlatformValidator(byte[] googlePublicKey, string googleBundleId)
// Full constructor (legacy — Apple args are only needed for pre-StoreKit 2 builds)
new CrossPlatformValidator(byte[] googlePublicKey, byte[] appleRootCert, string googleBundleId, string appleBundleId)
// Validate
IPurchaseReceipt[] Validate(string unityIAPReceipt) // throws IAPSecurityException
```
**WARNING:** `CrossPlatformValidator` for Apple App Store is **deprecated**. `AppleTangle.Data()` is also deprecated. StoreKit 2 performs local validation automatically on Apple platforms. `CrossPlatformValidator` still works for Google Play validation.
Supported stores (still active): `"GooglePlay"` | Deprecated: `"AppleAppStore"`, `"MacAppStore"`
## Interface Hierarchy
```
StoreController implements:
IStoreService - Connect(), Apple/Google service extensions
IProductService - FetchProducts(), GetProducts(), GetProductById()
IPurchaseService - PurchaseProduct(), ConfirmPurchase(), FetchPurchases(), Apple/Google purchase extensions
```
## Template: Fetch Products
```csharp
using UnityEngine;
using UnityEngine.Purchasing;
using System.Collections.Generic;
public static class FetchProductsExample
{
// Store callbacks arrive after Connect() returns, so this reports through the
// Editor console rather than a return value.
public static async void Run()
{
var store = UnityIAPServices.StoreController();
// Subscribe to events BEFORE Connect
store.OnStoreConnected += () =>
{
var products = new List<ProductDefinition>
{
new ProductDefinition("com.mygame.coins100", ProductType.Consumable),
new ProductDefinition("com.mygame.removeads", ProductType.NonConsumable)
};
store.OnProductsFetched += (fetched) =>
{
foreach (var p in fetched)
Debug.Log($"{p.definition.id}: {p.metadata.localizedPriceString}");
};
store.OnProductsFetchFailed += (failure) => Debug.LogError($"Product fetch failed: {failure.FailureReason}");
store.FetchProducts(products);
};
store.OnStoreDisconnected += (failure) => Debug.LogError($"Store disconnected: {failure.Message}");
await store.Connect();
}
}
```
SHA-256: 0022fd2f6e37b8801d2a14202e3950468313e253efe2daa340d9ba67124b40fd