Skip to content

Instantly share code, notes, and snippets.

@antiero
Created September 9, 2025 10:57
Show Gist options
  • Select an option

  • Save antiero/281227a256bb2ac8387500ac51676cd1 to your computer and use it in GitHub Desktop.

Select an option

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/com.unity.purchasing@5.0//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?

@GaziAkyuz
Copy link

There is an issue here. You will see many orders in Pending status with a payment amount of 0.

The problem occurs because players can choose “Pay in Shop” or “Pay by SIM card / carrier billing” as their payment method in Google Play or App Store settings. In these cases, the platform triggers OnPurchasePending immediately, even though the payment has not been completed yet.

As a result, players receive their consumable in-app purchases (coins, gems, etc.) without actually completing the payment. This happens because Google Play and the App Store treat the purchase as initiated and fire the pending callback before the payment is finalized.

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