
Intro to In-App Purchases and Updating to IAP5
Tutorial
intermediate
+10XP
15m
Unity Technologies
Whether you are migrating from Unity IAP 4 to IAP 5 or setting up mobile in-app purchases in the Unity game engine for the first time, this step-by-step breakdown covers the complete implementation workflow.
Unity IAP 5 introduces major architectural refinements, structural changes to initialization, and cleaner product fetching workflows. Plus, it paves the way for advanced ecosystem features on the horizon - including direct payment providers (like Stripe and Coda) and webshops that let you target sales better and keep a greater share of your purchase revenue.
Overview Video
1. Setup
The transition from Unity In-App Purchasing (IAP) 4 to version 5 introduces major architectural improvements. IAP5 offers streamlined product fetching, improved error handling, and better initialization workflow. Crucially, version 5 paves the way for advanced web-to-mobile monetization channels, integration with custom payment processors like Stripe or Coda, and standalone webshops.
Upgrading the package inside the Unity Package Manager will immediately prompt compilation errors due to changed type definitions and interface deprecations.
One of the immediate breaking modifications is how store-specific IDs are instantiated. The legacy IDs class has been renamed to better reflect its purpose:
Legacy type: new IDs()Refactored type: new StoreSpecificIds()
If you want to follow along looking at an implementation of IAP5 code, you can get the sample in the Package Manager, in the In-App Purchasing package, and the Minimal Coded IAP Sample.
Open the script PaywallManager for IAP5 code.
namespace Samples.Purchasing.IAP5.Minimal
{
public class PaywallManager : MonoBehaviour
{
public Text inAppConsole;
StoreController m_StoreController;
protected void Awake()
{
InitializeIAP();
}In IAP4, classes handling purchases had to implement the IStoreListener or IDetailedStoreListener interfaces for store initialization and purchase processing.
IAP v5 completely eliminates these interface dependencies. Instead, it consolidates interactions around a concrete UnityIAPServices.StoreController() class that fires targeted events throughout the purchase lifecycle.
2. Initialization
Legacy initialization required packaging product IDs into a ConfigurationBuilder prior to initializing Unity Purchasing. Under IAP5, initialization operates asynchronously and explicitly requests platform connections
Note: before using UnityIAPServices, initialize the Unity Gaming Services with await UnityServices.InitializeAsync(options). We recommend doing this at the start of the game and demonstrate this in a single entry point pattern in the script GameInitializer in our GemHunter Unity Gaming Services project demo.
3. StoreController events
Everything is handled by the StoreController in IAP5 instead of being split into IStoreListener and IStoreController like in IAP4.
private async void InitializStore()
{
m_StoreController = UnityIAPServices.StoreController();
SubscribeIAPEvents();
await m_StoreController.Connect();
List<ProductDefinition> products;
if (m_UseIAPCatalog)
{
products = BuildProductDefinitionsFromIAPCatalog();
}
else
{
products = BuildProductDefinitionsManually();
}
m_StoreController.FetchProducts(products);
InitializeReceiptValidatorsIfNeeded();
}Once we save a reference to the StoreController we subscribe to its events. They are self explanatory. Once we have it, we connect to the stores (Google Play or AppStore) with Connect(), in this event (OnStoreConnected and OnStoreDisconnected) we can enable and disable UI buttons relative to purchases.
private void SubscribeIAPEvents()
{
if (m_StoreController == null) return;
// Initialization and connection
m_StoreController.OnStoreConnected += OnStoreConnected;
m_StoreController.OnStoreDisconnected += OnStoreDisconnected;
// Product/Purchase fetching
m_StoreController.OnProductsFetched += OnProductsFetched;
m_StoreController.OnProductsFetchFailed += OnProductsFetchFailed;
m_StoreController.OnPurchasesFetched += OnPurchasesFetched;
m_StoreController.OnPurchasesFetchFailed += OnPurchasesFetchFailed;
// Purchase lifecycle
m_StoreController.OnPurchasePending += OnPurchasePending;
// A purchase is initiated but can't be completed immediately
// (e.g. parent approval, Strong Customer Authentication (Europe))
m_StoreController.OnPurchaseDeferred += OnPurchaseDeferred;
// Fires when the store acknowledges our ConfirmPurchase call,
// finalizing the transaction
m_StoreController.OnPurchaseConfirmed += OnPurchaseConfirmed;
m_StoreController.OnPurchaseFailed += OnPurchaseFailed;
}4. Product and purchase fetching
Once connected we send our product definitions to the store with m_StoreController.FetchProducts(products); basically, we send AppStore or Google Play a list of products asking for the actual price and purchase description in the user’s device language.
In IAP5 we don’t use configuration builder anymore from IAP4. In IAP5, we pass product definition objects directly to FetchProducts(products) whenever you are ready. They have an ID and type so there’s less platform specific code to write compared to IAP4.
We can create our product definitions with List products = new List(); and add products in two ways:
1. Using the IAP product catalog window directly in the Editor
products = BuildProductDefinitionsFromIAPCatalog();
2. Add the product list programmatically
products.Add(new ProductDefinition("gem_chest_01", ProductType.Consumable));
products.Add(new ProductDefinition("premium_subscription", ProductType.Subscription));We can then fetch the products and use the events listeners to log the information retrieved.
When products are fetched we can then check if any purchase is pending to process with m_StoreController.FetchPurchases()
Non-completed purchases that were interrupted or postponed have a chance to be completed here with our OnPurchaseFetched(Orders orders) event listener.
private void OnPurchasesFetched(Orders orders)
{
// Process purchases, e.g. check for entitlements from completed orders
if (orders.PendingOrders.Count > 0)
{
foreach (var order in orders.PendingOrders)
{
var product = order.CartOrdered.Items().FirstOrDefault()?.Product;
Logger.LogDemo($"[IAP] Pending order found: {product?.definition.id} | Tx: {order.Info?.TransactionID}");
// On some iOS versions, OnPurchasePending doesn't fire automatically
// for recovered purchases — processing here ensures nothing is missed.
// HashSet deduplication in ProcessPurchaseAsync prevents double-granting
// on iOS versions where OnPurchasePending does fire.
ProcessPurchaseAsync(order);
}
}
else
{
Logger.LogDemo("[IAP] No pending orders found.");
}
}
For every pending purchase in this session we will also get a OnPurchasePending event to handle them but there’s a chance that these will slip through depending on iOS version. To make sure nothing is missed we can process them in our orders.PendingOders.
Note: By default, calling FetchPurchases invokes OnPurchasePending for any pending purchases which have not yet been handled in the session.
Even if both (OnPurchasePending and our ProcessPurchaseAsync(order) function) get fired we can avoid duplication which we show later.
We can also retrieve the confirmed purchases. If you have non-consumable or subscriptions. Products that are meant to be purchased only once, for example, and ads removal products can be entitled/enabled right away.
foreach (var confirmedOrder in orders.ConfirmedOrders)
{
var product = confirmedOrder.CartOrdered.Items().FirstOrDefault()?.Product;
if (product?.definition.type != ProductType.Consumable)
{
m_StoreController.CheckEntitlement(product);
}
}As a last step in the initialization process we can initialize receipt validators if needed.
private async void InitializeStore()
{
m_StoreController = UnityIAPServices.StoreController();
SubscribeIAPEvents();
await m_StoreController.Connect();
List<ProductDefinition> products;
if (m_UseIAPCatalog)
{
products = BuildProductDefinitionsFromIAPCatalog();
}
else
{
products = BuildProductDefinitionsManually();
}
m_StoreController.FetchProducts(products);
InitializeReceiptValidatorsIfNeeded();
}Apple stores validates receipts with StoreKit2 so by the time we get the receipt on OnPurchasePending it will be already validated. Google Play handles this differently, requiring an identifier, which is your google license key via the obfuscator tool in the IAP package, and this is where we use our cross platform validation for.
Note: With IAP 5, the intent was to just support StoreKit2 and the new jws transaction representations. Apple handles on-device validation for us with this flow, and these also allow you to validate specific transactions, instead of “all” of the player’s transaction in one big file.
You can still get the StoreKit1 receipt but they’re not especially reliable when using StoreKit2.
Now with StoreKit1 in newer versions of IAP 5.x, you can force the use of StoreKit1 on all devices, or just have devices on iOS versions before 15.0, where StoreKit2 is not supported, run StoreKit1. In these cases, developers can interact with StoreKit1 (or rather they must interact with it), where there is no jws transaction representation, only (more reliable) receipt access.
private void InitializeReceiptValidatorsIfNeeded()
{
// In v5, Apple receipts are handled by StoreKit2. Keep validator for Google only.
if (Application.platform == RuntimePlatform.Android)
{
try
{
m_GoogleValidator = new CrossPlatformValidator(GooglePlayTangle.Data(), Application.identifier);
Debug.Log("[IAP] Google receipt validator initialized.");
}
catch (Exception e)
{
Debug.LogWarning($"[IAP] Validator init skipped/failed: {e.Message}");
}
}
}5. Purchase flow
UI handlers can stay the same when moving from IA4 to IAP5, they simply call to initiate purchase with the product ID.
#region UI Event Handlers
private void SetupUIEventHandlers()
{
m_StoreUIController.ClickPurchaseBundlePack += HandleBundlePackPurchase;
m_StoreUIController.ClickPurchaseMegaPack += HandleMegaPackPurchase;
m_StoreUIController.ClickPurchaseCoinPack += HandleCoinPackPurchase;
m_StoreUIController.ClickPurchaseFreeCoinPack += HandleCoinPackFreePurchase;
}
private void HandleBundlePackPurchase()
{
InitiatePurchase(m_BundlePackProductID);
}
private void HandleMegaPackPurchase()
{
InitiatePurchase(m_MegaPackProductID);
}
private void HandleCoinPackPurchase(int coinAmount)
{
if (m_CoinPackProducts.TryGetValue(coinAmount, out string productId))
{
InitiatePurchase(productId);
}
else
{
Logger.LogError($"No product ID found for coin amount: {coinAmount}");
}
}Initialization
We can use a flag to disable buttons while a current purchase is in process to avoid accidental duplicated requests.
We can retrieve all the products to populate a store listing with m_StoreController.GetProducts(), but in this menu we already know which product we want to offer so we get the product with m_StoreController.GetProductById(productId).
private void InitiatePurchase(string productId)
{
if (m_IsPurchaseInProgress)
{
Logger.LogWarning("Purchase already in progress");
return;
}
if (m_StoreController == null)
{
Logger.LogError("Store not initialized");
return;
}
m_IsPurchaseInProgress = true;
var product = m_StoreController.GetProductById(productId);
if (product != null)
{
m_StoreController.PurchaseProduct(product);
}
else
{
m_IsPurchaseInProgress = false;
Logger.Log($"The product service has no product with the ID {productId}");
}
}
Since we might get this event triggered twice, from OnPurchasePending and our ProcessPurchaseAsync(order) function or to avoid duplicated requests, we want to avoid granting the player the purchases more than once. It’s good practice to check the processed private readonly HashSet<string> m_ProcessedTransactionIds = new(); (from OnPurchasesConfirmed) in the session against the ones pending.
Transaction Ids can be used in our Cloud Code for stronger server side verification and granting of purchases to players via Economy. We cover this topic and more in the Unity Gaming Services series that you can watch in Unity’s Youtube Playlist.
Lastly, we can run Google Play validation, grant the player the items and confirm the purchase to the store with m_StoreController.ConfirmPurchase(pendingOrder).
Confirm ensures that the whole purchase flow was completed however OnPurchaseFailed will be triggered if the order failed. We should handle the different reasons accordingly, if the user canceled the purchase there’s no need to inform the player, but if it failed for other reasons we should inform the player about it.
private void OnPurchaseFailed(FailedOrder failedOrder)
{
m_IsPurchaseInProgress = false;
if (failedOrder.FailureReason == PurchaseFailureReason.UserCancelled)
{
Logger.LogWarning($"[IAP] Purchase cancelled by user: {failedOrder.FailureReason}");
return;
}
Logger.LogError($"[IAP] Purchase failed: {failedOrder.FailureReason}");
PurchaseFailed?.Invoke($"Purchase failed: {failedOrder.FailureReason}");
}Since IAP5 uses events, remember to unsubscribe to them when the manager is removed:
private void OnDestroy()
{
UnsubscribeIAPEvents();
UnsubscribeUIEventHandlers();
}
private void UnsubscribeIAPEvents()
{
if (m_StoreController == null) return;
// Initialization and connection
m_StoreController.OnStoreConnected -= OnStoreConnected;
m_StoreController.OnStoreDisconnected -= OnStoreDisconnected;
// Product fetch lifecycle
m_StoreController.OnProductsFetched -= OnProductsFetched;
m_StoreController.OnProductsFetchFailed -= OnProductsFetchFailed;
m_StoreController.OnPurchasesFetched -= OnPurchasesFetched;
m_StoreController.OnPurchasesFetchFailed -= OnPurchasesFetchFailed;
// Purchase lifecycle
m_StoreController.OnPurchasePending -= OnPurchasePending;
m_StoreController.OnPurchaseDeferred -= OnPurchaseDeferred;
m_StoreController.OnPurchaseConfirmed -= OnPurchaseConfirmed;
m_StoreController.OnPurchaseFailed -= OnPurchaseFailed;
}
private void UnsubscribeUIEventHandlers()
{
m_StoreUIController.ClickPurchaseBundlePack -= HandleBundlePackPurchase;
m_StoreUIController.ClickPurchaseMegaPack -= HandleMegaPackPurchase;
m_StoreUIController.ClickPurchaseCoinPack -= HandleCoinPackPurchase;
m_StoreUIController.ClickPurchaseFreeCoinPack -= HandleCoinPackFreePurchase;
}We experienced first hand the process of upgrading to IAP5 our Gem Hunter Match cloud edition, and while the repository still needs updating we updated the purchase scripts to IAP5. In this project, you can observe a more complex but realistic set up for a project where many validations are made server side with Cloud Code.
In the next tutorials, we will look into:
- Implementing Payment providers
- Creating our webshop