Key takeaways

  • Unity IAP 5 replaced IStoreListener and ProcessPurchase with a StoreController and events: connect, fetch products, buy with PurchaseProduct, and finish each order in OnPurchasePending with ConfirmPurchase.
  • Grant coins on your server first: verify the Google purchase token with purchases.products.get or the Apple signed transaction with verifyAndDecodeTransaction, key the grant on the token or transaction ID, then confirm in the game.
  • Consumables vanish from the store once confirmed, so coins live only in your server ledger, while a non-consumable Remove Ads comes back through FetchPurchases and needs a Restore Purchases button on iOS.

Unity IAP tutorial: how in-app purchases work in Unity IAP 5

To sell coins and a Remove Ads purchase in Unity, install the In-App Purchasing package (com.unity.purchasing), get a StoreController with UnityIAPServices.StoreController(), connect, fetch your products, and call PurchaseProduct. When OnPurchasePending fires, send the purchase token (Google Play) or the signed transaction (App Store) to your server, let the server check it with the store and grant the coins, and only then call ConfirmPurchase. Restored and already-owned purchases arrive through OnPurchasesFetched.

This guide uses Unity IAP 5.4.4, the latest version on Unity’s package registry (released October 2, 2026, checked on October 22, 2026). Unity recommends Unity 6.0 or later and supports Unity 2022.3 as the minimum. We compile-checked the C# against the 5.4.4 API signatures and ran the Node.js server code against PostgreSQL with mocked store responses.

Who does what

PartDoesNever does
Unity clientShows prices, starts purchases, sends proof to your server, confirms the order after the server answersAdds coins by itself or decides a purchase is real
Your serverChecks the purchase with Google or Apple, writes one ledger row per purchase, returns the new balanceTrusts a product ID or price sent by the client
Google Play / App StoreTakes payment, signs or verifies the purchase, reports refundsRemembers consumables after you confirm them

What changed in Unity IAP 5

Many older Unity IAP tutorials show the version 4 API, which version 5 replaced. Unity’s upgrade guide for IAP v5 lists the changes. The ones that matter for this tutorial:

Unity IAP 4Unity IAP 5
UnityPurchasing.Initialize with an IStoreListenerUnityIAPServices.StoreController(), then await Connect(), with events instead of a listener
ConfigurationBuilder.AddProductA List<ProductDefinition> passed to FetchProducts, a CatalogProvider, or the Remote Catalog
ProcessPurchaseOnPurchasePending for new purchases, OnPurchasesFetched for restored ones
InitiatePurchase and ConfirmPendingPurchasePurchaseProduct and ConfirmPurchase(PendingOrder)
product.receiptorder.Info.Receipt, plus order.Info.Google and order.Info.Apple
StoreKit 1 and the App Store app receiptStoreKit 2: validate order.Info.Apple.jwsRepresentation on your server

The same guide says IAP 5.4 uses Google Play Billing Library 9.0.0 and that StoreKit 2 needs iOS 15. Older devices fall back to StoreKit 1, where jwsRepresentation is null, so set your minimum iOS version to 15. And since 5.4.4, the IAP Catalog window and Codeless IAP only open in projects that already have a legacy catalog, according to the package changelog.

Step 1: install the package and set up both stores

  1. Install the package. In the Unity Editor, open Window, Package Management, Package Manager, choose the Unity Registry tab, search for In-App Purchasing (com.unity.purchasing) and install it.
  2. Decide whether you need Unity Gaming Services. Unity’s setup page says you must link a cloud project (Services, General Settings) and enable In-App Purchases (Services, In-App Purchasing) if you use the Remote Catalog, direct-to-consumer payment providers, Unity Authentication or IAP analytics. A game that sells only through Google Play and the App Store, with products defined in the build, can skip it.
  3. Create the products in Google Play Console. Under Monetize with Play, Products, One-time products, create coins_500 and remove_ads, each with a purchase option and prices, and activate them, as Google’s one-time products help page describes. Whether a product is consumable is decided in your code: on Google Play, Unity IAP consumes a purchase when you confirm a product defined as Consumable, and only acknowledges other types.
  4. Create the products in App Store Connect with the same product IDs: coins_500 as a consumable and remove_ads as a non-consumable. The Account Holder must have signed the Paid Applications Agreement before sandbox purchases work.
  5. Give your server store access. For Google, enable the Google Play Developer API in a Google Cloud project, create a service account, and invite its email address in Play Console, Users and permissions, with “View financial data” and “Manage orders and subscriptions”, as Google’s Play Developer API getting started page lists. For Apple, download Apple Root CA G3 from the Apple PKI page and note your app’s Apple ID in App Store Connect.

Step 2: define coins (consumable) and Remove Ads (non-consumable)

The two products behave very differently after purchase, and that decides where you store them:

500 coins (coins_500)Remove Ads (remove_ads)
Unity ProductTypeConsumableNonConsumable
Can be boughtAgain and againOnce per store account
After ConfirmPurchaseThe store forgets itThe store keeps returning it in FetchPurchases
Source of truthYour server ledger onlyThe store, mirrored on your server
RestoreNot possible from the store: the player signs in to your account systemRestoreTransactions on iOS, automatic on Google Play

Unity’s Purchases page is direct about the consumable row: once you acknowledge a consumable, the store does not return it again, so always persist consumable rewards remotely. Coins saved only on the device are lost on reinstall, with no way to restore them. Unity’s setup page adds that Unity IAP will not let a player buy the same consumable again until you confirm the previous order.

Name the second product honestly. If it removes forced ads such as interstitials but keeps opt-in features like rewarded videos and an offerwall, say exactly that in the store description. How the two kinds of ads fit together is covered in offerwall vs rewarded video.

Step 3: the purchase flow in C#

One component owns the store connection for the whole session. Put it on a GameObject in your first scene. Unity’s setup page says the object that handles purchases must live for as long as your game sells anything, so the component calls DontDestroyOnLoad.

using System;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.Purchasing;

public class IapStore : MonoBehaviour
{
    public const string Coins500 = "coins_500";
    public const string RemoveAds = "remove_ads";

    [SerializeField] private string verifyUrl = "https://api.yourgame.com/iap/verify";

    public bool AdsRemoved { get; private set; }
    public event Action StoreChanged;

    private StoreController store;
    private readonly HashSet<string> verifying = new HashSet<string>();

    [Serializable]
    private class VerifyRequest
    {
        public string store;
        public string productId;
        public string purchaseToken;
        public string jws;
    }

    [Serializable]
    private class VerifyResponse
    {
        public long coins;
        public bool adsRemoved;
    }

    async void Start()
    {
        DontDestroyOnLoad(gameObject);
        store = UnityIAPServices.StoreController();

        // Subscribe before Connect, or you miss events that fire during connection.
        store.OnStoreDisconnected += d => Debug.LogWarning("Store disconnected: " + d.Message);
        store.OnProductsFetched += OnProductsFetched;
        store.OnProductsFetchFailed += f => Debug.LogWarning("Products not fetched: " + f.FailureReason);
        store.OnPurchasePending += OnPurchasePending;
        store.OnPurchaseFailed += OnPurchaseFailed;
        store.OnPurchaseDeferred += o => Debug.Log("Waiting for approval or payment.");
        store.OnPurchasesFetched += OnPurchasesFetched;
        store.OnPurchasesFetchFailed += f => Debug.LogWarning("Purchases not fetched: " + f.Message);

        try
        {
            await store.Connect();
        }
        catch (Exception e)
        {
            Debug.LogWarning("Store connection failed: " + e.Message);
            return;
        }
        if (store.GetConnectionState() != ConnectionState.Connected) return;

        // Tie every purchase to the signed-in player. Both values come from your server.
        store.GooglePlayStoreExtendedService?.SetObfuscatedAccountId(PlayerSession.PlayAccountId);
        store.AppleStoreExtendedService?.SetAppAccountToken(PlayerSession.AppleAccountToken);

        store.FetchProducts(new List<ProductDefinition>
        {
            new ProductDefinition(Coins500, ProductType.Consumable),
            new ProductDefinition(RemoveAds, ProductType.NonConsumable),
        });
    }

    void OnProductsFetched(List<Product> products)
    {
        StoreChanged?.Invoke();
        store.FetchPurchases();
    }

    // When the player comes back to the game, pick up anything still pending.
    void OnApplicationPause(bool paused)
    {
        if (!paused && store != null && store.GetConnectionState() == ConnectionState.Connected)
        {
            store.FetchPurchases();
        }
    }

    public string PriceOf(string productId)
    {
        Product product = store?.GetProductById(productId);
        return product != null ? product.metadata.localizedPriceString : "";
    }

    public void Buy(string productId)
    {
        Product product = store?.GetProductById(productId);
        if (product == null || !product.availableToPurchase) return;
        if (productId == RemoveAds && AdsRemoved) return;
        store.PurchaseProduct(product);
    }

    // The Restore Purchases button. Show it on iOS.
    public void RestorePurchases()
    {
        store?.RestoreTransactions((ok, error) =>
        {
            if (!ok) Debug.LogWarning("Restore failed: " + error);
        });
    }

    void OnPurchasePending(PendingOrder order)
    {
        if (verifying.Add(order.Info.TransactionID))
        {
            StartCoroutine(Verify(order, order));
        }
    }

    void OnPurchasesFetched(Orders orders)
    {
        foreach (ConfirmedOrder order in orders.ConfirmedOrders)
        {
            if (ProductIdOf(order) == RemoveAds && verifying.Add(order.Info.TransactionID))
            {
                StartCoroutine(Verify(order, null));
            }
        }
    }

    void OnPurchaseFailed(FailedOrder order)
    {
        if (order.FailureReason != PurchaseFailureReason.UserCancelled)
        {
            Debug.LogWarning("Purchase failed: " + order.FailureReason + " " + order.Details);
        }
        StoreChanged?.Invoke();
    }

    static string ProductIdOf(Order order)
    {
        return order.CartOrdered.Items()[0].Product.definition.storeSpecificId;
    }

    IEnumerator Verify(Order order, PendingOrder toConfirm)
    {
        var body = new VerifyRequest
        {
            store = order.Info.Apple != null ? "apple" : "google",
            productId = ProductIdOf(order),
            purchaseToken = order.Info.Google?.PurchaseToken,
            jws = order.Info.Apple?.jwsRepresentation,
        };

        using (var req = new UnityWebRequest(verifyUrl, "POST"))
        {
            req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(JsonUtility.ToJson(body)));
            req.downloadHandler = new DownloadHandlerBuffer();
            req.SetRequestHeader("Content-Type", "application/json");
            req.SetRequestHeader("Authorization", "Bearer " + PlayerSession.Token);
            req.timeout = 20;
            yield return req.SendWebRequest();

            if (req.result == UnityWebRequest.Result.Success)
            {
                // The server granted it now or already had: show its numbers, then close the order.
                VerifyResponse res = JsonUtility.FromJson<VerifyResponse>(req.downloadHandler.text);
                AdsRemoved = res.adsRemoved;
                GameEconomy.Wallet.Overwrite("coins", res.coins);
                if (toConfirm != null) store.ConfirmPurchase(toConfirm);
            }
            else if (req.responseCode >= 400 && req.responseCode < 500)
            {
                // Rejected: grant nothing and do not confirm.
                Debug.LogWarning("Purchase rejected: " + req.downloadHandler.text);
            }
            // Network or 5xx error: the order stays pending and comes back on the next fetch.
        }

        verifying.Remove(order.Info.TransactionID);
        StoreChanged?.Invoke();
    }
}

What each part does, and why:

  • Events before Connect. Unity’s setup page says handlers attached later miss the events that fire during connection.
  • Account IDs on every purchase. SetObfuscatedAccountId (Google) and SetAppAccountToken (Apple) stamp each purchase with your player, so the server can refuse a token copied from another account. Google limits the ID to 64 characters and blocks purchases that put personal data such as an email in it, so send a one-way hash. For Apple, store one UUID per player on your server.
  • Grant, then confirm. ConfirmPurchase runs only after a 2xx answer from the server. After a crash or a network drop, the order stays pending and Unity delivers it again on the next fetch or launch; the server’s unique key makes that retry harmless.
  • Rejected means not confirmed. A 4xx answer grants nothing. Unity’s Purchases page notes that Google Play refunds an unacknowledged purchase after three days, the right outcome for a purchase your server could not verify.
  • Deferred is not paid. OnPurchaseDeferred covers Ask to Buy on iOS and slow payment methods. Show a waiting state; the grant happens later through OnPurchasePending.

GameEconomy.Wallet.Overwrite comes from our Unity currency system guide, where coins bought with money are a server-authoritative currency: the game only displays the balance your server returns. Gate your interstitials with if (!iapStore.AdsRemoved), and load both values from your server at sign-in too.

Unity recommends its Remote Catalog for new projects, so you can change products without a new build. Only the FetchProducts call changes.

Step 4: validate receipts and grant coins on the server

Unity’s receipt validation page calls server-side validation “essential for server-delivered content such as granting virtual currency” and says Unity IAP has no built-in remote validation service, so this part is yours. Each store has one official check:

CheckGoogle PlayApp Store
What the game sendsorder.Info.Google.PurchaseToken and the product IDorder.Info.Apple.jwsRepresentation
How the server verifiespurchases.products.get in the Play Developer APIverifyAndDecodeTransaction in Apple’s App Store Server Library
PaidpurchaseState is 0 (1 is canceled, 2 is pending)Valid signature, your bundle ID, no revocationDate
Right playerobfuscatedExternalAccountId matchesappAccountToken matches
Unique keyThe purchase tokentransactionId
Test purchasepurchaseType is 0environment is Sandbox

Google’s purchase verification guide names purchases.products.get for one-time products and says the purchase token is globally unique, so it can be your primary key (not orderId, which promo code purchases lack). The API reference gives the request as GET /androidpublisher/v3/applications/{packageName}/purchases/products/{productId}/tokens/{token}. For Apple, the developer documentation on jwsRepresentation suggests verifying it with the App Store Server Library function verifyAndDecodeTransaction, which checks the certificate chain against Apple’s root certificates.

The server reuses the wallet_ledger and wallet_balance tables from the currency system guide and adds one table that remembers every store purchase, which is also where Remove Ads lives:

CREATE TABLE iap_purchases (
    source_ref  TEXT        PRIMARY KEY,
    player_id   TEXT        NOT NULL,
    product_id  TEXT        NOT NULL,
    test        BOOLEAN     NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

The endpoint, in Node.js with Express, pg, @googleapis/androidpublisher and @apple/app-store-server-library. req.user is set by your own sign-in middleware:

const crypto = require('crypto');
const fs = require('fs');
const express = require('express');
const { Pool } = require('pg');
const { androidpublisher, auth } = require('@googleapis/androidpublisher');
const { SignedDataVerifier, Environment, VerificationStatus } = require('@apple/app-store-server-library');

const PRODUCTS = {
  coins_500: { coins: 500 },
  remove_ads: { coins: 0 },
};

const db = new Pool();
const play = androidpublisher({
  version: 'v3',
  auth: new auth.GoogleAuth({ scopes: ['https://www.googleapis.com/auth/androidpublisher'] }),
});
const appleRoots = [fs.readFileSync('AppleRootCA-G3.cer')];
const bundleId = process.env.IOS_BUNDLE_ID;
const appleVerifiers = [
  new SignedDataVerifier(appleRoots, true, Environment.PRODUCTION, bundleId, Number(process.env.APP_APPLE_ID)),
  new SignedDataVerifier(appleRoots, true, Environment.SANDBOX, bundleId),
];

// The value the game passes to SetObfuscatedAccountId: a hash, never an email.
const playAccountId = (playerId) => crypto.createHash('sha256').update(String(playerId)).digest('hex');

async function checkGoogle(player, productId, token) {
  try {
    const { data } = await play.purchases.products.get({
      packageName: process.env.ANDROID_PACKAGE_NAME, productId, token,
    });
    if (data.purchaseState !== 0) return null; // 1 = canceled, 2 = pending
    if (data.obfuscatedExternalAccountId !== playAccountId(player.id)) return null;
    return { ref: 'google:' + token, productId, test: data.purchaseType === 0 };
  } catch (e) {
    if (e.status && e.status < 500) return null; // unknown or malformed token
    throw e;
  }
}

async function checkApple(player, jws) {
  for (const verifier of appleVerifiers) {
    try {
      const tx = await verifier.verifyAndDecodeTransaction(jws);
      if (tx.revocationDate) return null; // refunded or revoked
      if ((tx.appAccountToken || '').toLowerCase() !== player.appleAccountToken) return null;
      return { ref: 'apple:' + tx.transactionId, productId: tx.productId, test: tx.environment !== Environment.PRODUCTION };
    } catch (e) {
      if (e.status === VerificationStatus.RETRYABLE_VERIFICATION_FAILURE) throw e;
      if (e.status !== VerificationStatus.INVALID_ENVIRONMENT) return null;
    }
  }
  return null;
}

async function grant(player, purchase) {
  const product = PRODUCTS[purchase.productId];
  const client = await db.connect();
  try {
    await client.query('BEGIN');
    const inserted = await client.query(
      `INSERT INTO iap_purchases (source_ref, player_id, product_id, test)
       VALUES ($1, $2, $3, $4) ON CONFLICT (source_ref) DO NOTHING RETURNING source_ref`,
      [purchase.ref, player.id, purchase.productId, purchase.test]);
    if (inserted.rowCount === 0) {
      const owner = await client.query('SELECT player_id FROM iap_purchases WHERE source_ref = $1', [purchase.ref]);
      if (owner.rows[0].player_id !== String(player.id)) {
        await client.query('ROLLBACK');
        return { status: 409 };
      }
    } else if (product.coins > 0) {
      await client.query(
        `INSERT INTO wallet_ledger (player_id, currency, delta, source, source_ref)
         VALUES ($1, 'coins', $2, $3, $4)`,
        [player.id, product.coins, purchase.test ? 'iap_test' : 'iap', purchase.ref]);
      await client.query(
        `INSERT INTO wallet_balance (player_id, currency, amount) VALUES ($1, 'coins', $2)
         ON CONFLICT (player_id, currency) DO UPDATE SET amount = wallet_balance.amount + EXCLUDED.amount`,
        [player.id, product.coins]);
    }
    await client.query('COMMIT');
    return { status: 200 };
  } catch (e) {
    await client.query('ROLLBACK');
    throw e;
  } finally {
    client.release();
  }
}

async function state(playerId) {
  const coins = await db.query(
    `SELECT amount FROM wallet_balance WHERE player_id = $1 AND currency = 'coins'`, [playerId]);
  const ads = await db.query(
    `SELECT 1 FROM iap_purchases WHERE player_id = $1 AND product_id = 'remove_ads'`, [playerId]);
  return { coins: Number(coins.rows[0]?.amount || 0), adsRemoved: ads.rowCount > 0 };
}

const router = express.Router();

router.post('/iap/verify', express.json(), async (req, res) => {
  const player = req.user; // set by your own auth middleware: { id, appleAccountToken }
  const { store, productId, purchaseToken, jws } = req.body || {};
  try {
    let purchase = null;
    if (store === 'google' && purchaseToken && PRODUCTS[productId]) {
      purchase = await checkGoogle(player, productId, purchaseToken);
    } else if (store === 'apple' && jws) {
      purchase = await checkApple(player, jws);
    }
    if (!purchase || !PRODUCTS[purchase.productId]) {
      return res.status(400).json({ error: 'invalid_purchase' });
    }
    const result = await grant(player, purchase);
    if (result.status !== 200) return res.status(result.status).json({ error: 'owned_by_another_account' });
    res.json(await state(player.id));
  } catch (e) {
    console.error('iap verify failed', e);
    res.status(503).json({ error: 'try_again' });
  }
});

module.exports = { router, playAccountId };
  • The product comes from the store. Apple’s signed transaction carries the product ID, Google checks the token against the product ID you ask about, and the coin amount comes from the server’s own table.
  • Retries are safe. A second call for the same token inserts nothing and returns the same balance. A token claimed by another account gets 409.
  • Store outages are not rejections. A 5xx from Google or a retryable Apple failure answers 503, so the order stays pending and comes back later.
  • Test purchases are labeled. Sandbox and license tester purchases are granted, so testing works end to end, but as iap_test rows that you exclude from revenue reports.

Refunds and chargebacks

A refund after the coins were spent is the same problem as an offer reversal. Google’s Voided Purchases API lists refunded, canceled and charged-back purchases, and Real-time developer notifications report them as they happen. Apple sends a REFUND notification through App Store Server Notifications V2. For each one, write a negative iap_refund ledger row (or delete the Remove Ads row) and apply your terms for balances below zero. The duplicate credit guide covers idempotency.

Step 5: restore purchases on iOS

Apple’s App Review Guidelines (section 3.1.1, checked on October 22, 2026) say in-game currencies bought with in-app purchase may not expire and that “you should make sure you have a restore mechanism for any restorable in-app purchases”. Remove Ads is restorable; coins are not. In practice:

  • Show a Restore Purchases button on iOS, for example in Settings, wired to RestorePurchases().
  • What it does. In 5.4.4, RestoreTransactions calls StoreKit 2’s AppStore.sync() on Apple platforms and, on success, runs FetchPurchases, so owned products arrive in OnPurchasesFetched. The component above sends a Remove Ads order to the server, which recognizes the transaction ID and returns adsRemoved: true.
  • Google Play needs no button. Unity’s upgrade guide says confirmed purchases are restored automatically when you call FetchPurchases, which the component does after every product fetch.
  • Coins come back through your account system. Sign-in reloads the server balance, which is why Unity’s setup page warns against anonymous sign-in in production: an anonymous player loses their purchase history on reinstall.

Step 6: test with license testers and sandbox accounts

In the Editor, Unity IAP uses a fake store, so you can click through the buttons. Its orders carry no Google or Apple data, so the server above rejects them, which is what you want: test grants on devices.

On Android, Google’s testing guide says license testers can sideload debug builds, as long as the package name matches the app in Play Console, and pay with test instruments instead of real money. Purchases from license testers are refunded after 3 minutes if your app does not acknowledge them, which makes a missing ConfirmPurchase easy to spot. Use the slow test cards to test pending purchases.

On iOS, Apple’s sandbox testing page says to create Sandbox Apple Accounts in App Store Connect and sign in under Settings, Developer after your first purchase attempt. Xcode and TestFlight builds both use the sandbox.

Run each of these at least once before release:

  1. Buy coins twice in a row. The balance rises twice, and each purchase has its own iap_purchases row.
  2. Buy coins, then kill the app before the server answers. On the next launch the order arrives again, and the coins are granted once.
  3. Turn off the network during verification. Nothing is granted; after reconnecting, the purchase completes.
  4. Buy Remove Ads, delete the app, reinstall, sign in, and tap Restore Purchases on iOS. Ads stay off.
  5. Send the same purchase token from a second test account. The server answers 400 or 409.
  6. Refund a test order and check that your refund handler writes the negative ledger row.

Sell coins and pay them through an offerwall too

Most players never pay, so many games add a second way to earn the same coins: an offerwall, where players complete offers such as reaching a level in another game and you earn per completed offer. The economics are in hybrid monetization for games. In code it costs little, because your server already owns the balance:

  • Offer rewards arrive at your server as signed postbacks and become offer_credit ledger rows next to your iap rows. The game reloads the same balance when the player closes the wall.
  • Keep one price per coin in your head. Illustrative example: if 500 coins sell for $4.99, a player values a coin at about one cent, so an offer that credits 1,000 coins should feel like a $10 effort. Set the offerwall conversion rate with that in mind; how to set your virtual currency exchange rate and the virtual currency calculator walk through it.
  • Remove Ads should not remove the offerwall, which is opt-in.

The client side, a token from your server and one call to open the wall, is in how to add an offerwall to a Unity game, and the plugin reference is in the Sharklio Unity SDK docs.

Pay your Unity coins through Sharklio as well

Sharklio is a pay-per-result affiliate network. Its Unity plugin (com.sharklio.offerwall, for Unity 2022.3 LTS and Unity 6) opens the offerwall on Android and iOS with a single-use session token your server creates, so no key ships in your game. Each app sets its own currency name, icon, conversion rate, user split and rounding, and your server receives a signed postback for every credit and every reversal, the same ledger pattern as your IAP purchases (see Postbacks). Offers depend on the player’s country and device, and players must be at least 16. Publisher applications are reviewed by hand: read how to get approved as a publisher or see how the wall works.

Frequently asked questions

What is the latest version of Unity IAP?

On October 22, 2026, the latest version of com.unity.purchasing on Unity’s package registry is 5.4.4, released October 2, 2026. It supports Unity 2022.3 and later, and Unity recommends Unity 6.0 or later.

How do I make a consumable purchase in Unity IAP?

Define the product as ProductType.Consumable, buy it with PurchaseProduct, grant the item in OnPurchasePending, and call ConfirmPurchase. Store the result on your server, because the store does not return a consumable after you confirm it.

How do I make a Remove Ads purchase in Unity?

Create a non-consumable product, buy it like any other, and save the entitlement on your server. At launch, FetchPurchases returns it in OnPurchasesFetched, and on iOS a Restore Purchases button calls RestoreTransactions.

Does Unity IAP validate receipts on a server for me?

No. Unity’s documentation says Unity IAP has no built-in remote validation service. Your server checks Google purchases with the Play Developer API and Apple purchases by verifying the StoreKit 2 signed transaction.

What replaced ProcessPurchase in Unity IAP 5?

The OnPurchasePending event on StoreController handles new purchases, and OnPurchasesFetched handles restored ones. You finish a purchase with ConfirmPurchase(PendingOrder) instead of returning a result.

Is a Restore Purchases button required on iOS?

Apple’s App Review Guidelines say you should have a restore mechanism for any restorable in-app purchases, such as a non-consumable Remove Ads. Consumable coins are restored by signing in to your own account system instead.