Skip to content

Instantly share code, notes, and snippets.

@antiero
Created September 9, 2025 10:57
Show Gist options
  • Save antiero/281227a256bb2ac8387500ac51676cd1 to your computer and use it in GitHub Desktop.
Save antiero/281227a256bb2ac8387500ac51676cd1 to your computer and use it in GitHub Desktop.
Basic Unity IAP v5 example for Apple In-App Purchase
// IAPManager.cs
// Unity IAP v5 Manager with UnityEvents, product metadata access,
// and safe handling of pending/confirmed purchases.
// Tested on iOS Sandbox where "pending" often occurs.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.Purchasing;
[Serializable]
public class ProductsEvent : UnityEvent<List<Product>> { }
[Serializable]
public class PurchaseSuccessEvent : UnityEvent<string> { } // productId
[Serializable]
public class PurchaseFailedEvent : UnityEvent<string, string> { } // productId, reason
public class IAPManager : MonoBehaviour
{
// Change these ids to be your IAP productIds in the App Store Connect In-App Purchase
public const string PRODUCT_ID_SINGLE_PLEDGE = "com.mycompany.singlePledge";
public const string PRODUCT_ID_DOUBLE_PLEDGE = "com.mycompany.singlePledge";
StoreController storeController;
bool initialized = false;
bool _hasSinglePledge = false;
bool _hasDoublePledge = false;
readonly Dictionary<string, Product> fetchedProducts = new();
public bool HasSinglePledge => _hasSinglePledge;
public bool HasDoublePledge => _hasDoublePledge;
// === UnityEvents ===
[Header("Events")]
public ProductsEvent OnProductsReady;
public PurchaseSuccessEvent OnPurchaseSucceeded;
public PurchaseFailedEvent OnPurchaseFailedEvent;
async void Start()
{
await InitializeIAP();
}
async Task InitializeIAP()
{
storeController = UnityIAPServices.StoreController();
// Hook events
storeController.OnProductsFetched += OnProductsFetched;
storeController.OnProductsFetchFailed += OnProductsFetchFailed;
storeController.OnPurchasesFetched += OnPurchasesFetched;
storeController.OnPurchasesFetchFailed += OnPurchasesFetchFailed;
storeController.OnPurchaseConfirmed += OnPurchaseConfirmed;
storeController.OnPurchaseFailed += OnPurchaseFailed;
storeController.OnPurchasePending += OnPurchasePending;
try
{
await storeController.Connect();
initialized = true;
Debug.Log("IAPv5: Connected");
var initialProducts = new List<ProductDefinition>
{
new ProductDefinition(PRODUCT_ID_SINGLE_PLEDGE, ProductType.NonConsumable),
new ProductDefinition(PRODUCT_ID_DOUBLE_PLEDGE, ProductType.NonConsumable)
};
storeController.FetchProducts(initialProducts);
}
catch (Exception ex)
{
Debug.LogError($"IAPv5: Connect failed: {ex}");
}
}
// === Public API ===
public void BuySinglePledge()
{
if (!initialized) { Debug.LogWarning("IAPv5: Not ready"); return; }
storeController.PurchaseProduct(PRODUCT_ID_SINGLE_PLEDGE);
}
public void BuyDoublePledge()
{
if (!initialized) { Debug.LogWarning("IAPv5: Not ready"); return; }
storeController.PurchaseProduct(PRODUCT_ID_DOUBLE_PLEDGE);
}
public void RestorePurchases(Action<bool, string> callback = null)
{
if (!initialized) { callback?.Invoke(false, "not initialized"); return; }
storeController.RestoreTransactions((ok, msg) =>
{
Debug.Log($"IAPv5: Restore result {ok} - {msg}");
callback?.Invoke(ok, msg);
});
}
// Access product metadata
public string GetLocalizedPrice(string productId)
=> fetchedProducts.ContainsKey(productId) ? fetchedProducts[productId].metadata.localizedPriceString : "";
public string GetCurrencyCode(string productId)
=> fetchedProducts.ContainsKey(productId) ? fetchedProducts[productId].metadata.isoCurrencyCode : "";
public string GetTitle(string productId)
=> fetchedProducts.ContainsKey(productId) ? fetchedProducts[productId].metadata.localizedTitle : "";
public string GetDescription(string productId)
=> fetchedProducts.ContainsKey(productId) ? fetchedProducts[productId].metadata.localizedDescription : "";
// === Event handlers ===
void OnProductsFetched(List<Product> products)
{
Debug.Log("IAPv5: Products fetched: " + products.Count);
foreach (var p in products)
{
fetchedProducts[p.definition.id] = p;
Debug.Log($"Fetched: {p.definition.id} | {p.metadata.localizedTitle} | {p.metadata.localizedPriceString}");
}
OnProductsReady?.Invoke(products);
storeController.FetchPurchases();
}
void OnProductsFetchFailed(ProductFetchFailed failure)
{
Debug.LogError($"IAPv5: ProductsFetchFailed: {failure.Reason} {failure.Message}");
}
void OnPurchasesFetched(Orders orders)
{
_hasSinglePledge = CheckOrdersForProduct(orders, PRODUCT_ID_SINGLE_PLEDGE);
_hasDoublePledge = CheckOrdersForProduct(orders, PRODUCT_ID_DOUBLE_PLEDGE);
Debug.Log($"IAPv5: Purchases fetched. Single: {_hasSinglePledge}, Double: {_hasDoublePledge}");
}
void OnPurchasesFetchFailed(PurchasesFetchFailureDescription failure)
{
Debug.LogError($"IAPv5: PurchasesFetchFailed: {failure.FailureReason} - {failure.Message}");
}
void OnPurchasePending(PendingOrder pending)
{
Debug.Log($"IAPv5: Purchase pending for {pending.Info.ProductId}");
// Fire success event immediately so UI/audio/VFX can trigger
OnPurchaseSucceeded?.Invoke(pending.Info.ProductId);
// Confirm with StoreKit so the transaction finalizes
storeController.ConfirmPurchase(pending);
Debug.Log("IAPv5: Pending purchase confirmed with StoreKit");
}
void OnPurchaseConfirmed(Order order)
{
Debug.Log("IAPv5: Purchase confirmed by StoreKit");
foreach (var p in order.Info.PurchasedProductInfo)
OnPurchaseSucceeded?.Invoke(p.productId);
}
void OnPurchaseFailed(FailedOrder failed)
{
Debug.LogWarning($"IAPv5: Purchase failed. Reason: {failed.FailureReason}");
OnPurchaseFailedEvent?.Invoke(failed.Info.ProductId, failed.FailureReason.ToString());
}
// === Utility ===
bool CheckOrdersForProduct(Orders orders, string productId)
{
if (orders == null) return false;
foreach (var co in orders.ConfirmedOrders)
if (OrderContainsProductId(co, productId)) return true;
foreach (var po in orders.PendingOrders)
if (OrderContainsProductId(po, productId)) return true;
foreach (var d in orders.DeferredOrders)
if (OrderContainsProductId(d, productId)) return true;
return false;
}
bool OrderContainsProductId(Order order, string productId)
{
if (order?.Info?.PurchasedProductInfo == null) return false;
foreach (var p in order.Info.PurchasedProductInfo)
if (p.productId == productId) return true;
return false;
}
}
@arcandio
Copy link

PendingOrder doesn't include pending.Info.ProductId.
https://docs.unity3d.com/Packages/[email protected]//api/UnityEngine.Purchasing.IOrderInfo.html
I think the ID is inside the PurchasedProductInfo list, but when I try to retrieve it, the list itself is empty, meaning I have no way to access the product ID. I'm on IAP 5.0.1. Is this for a different version?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment