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
| Part | Does | Never does |
|---|---|---|
| Unity client | Shows prices, starts purchases, sends proof to your server, confirms the order after the server answers | Adds coins by itself or decides a purchase is real |
| Your server | Checks the purchase with Google or Apple, writes one ledger row per purchase, returns the new balance | Trusts a product ID or price sent by the client |
| Google Play / App Store | Takes payment, signs or verifies the purchase, reports refunds | Remembers 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 4 | Unity IAP 5 |
|---|---|
UnityPurchasing.Initialize with an IStoreListener | UnityIAPServices.StoreController(), then await Connect(), with events instead of a listener |
ConfigurationBuilder.AddProduct | A List<ProductDefinition> passed to FetchProducts, a CatalogProvider, or the Remote Catalog |
ProcessPurchase | OnPurchasePending for new purchases, OnPurchasesFetched for restored ones |
InitiatePurchase and ConfirmPendingPurchase | PurchaseProduct and ConfirmPurchase(PendingOrder) |
product.receipt | order.Info.Receipt, plus order.Info.Google and order.Info.Apple |
| StoreKit 1 and the App Store app receipt | StoreKit 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
- 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. - 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.
- Create the products in Google Play Console. Under Monetize with Play, Products, One-time products, create
coins_500andremove_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 asConsumable, and only acknowledges other types. - Create the products in App Store Connect with the same product IDs:
coins_500as a consumable andremove_adsas a non-consumable. The Account Holder must have signed the Paid Applications Agreement before sandbox purchases work. - 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 ProductType | Consumable | NonConsumable |
| Can be bought | Again and again | Once per store account |
After ConfirmPurchase | The store forgets it | The store keeps returning it in FetchPurchases |
| Source of truth | Your server ledger only | The store, mirrored on your server |
| Restore | Not possible from the store: the player signs in to your account system | RestoreTransactions 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) andSetAppAccountToken(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.
ConfirmPurchaseruns 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.
OnPurchaseDeferredcovers Ask to Buy on iOS and slow payment methods. Show a waiting state; the grant happens later throughOnPurchasePending.
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:
| Check | Google Play | App Store |
|---|---|---|
| What the game sends | order.Info.Google.PurchaseToken and the product ID | order.Info.Apple.jwsRepresentation |
| How the server verifies | purchases.products.get in the Play Developer API | verifyAndDecodeTransaction in Apple’s App Store Server Library |
| Paid | purchaseState is 0 (1 is canceled, 2 is pending) | Valid signature, your bundle ID, no revocationDate |
| Right player | obfuscatedExternalAccountId matches | appAccountToken matches |
| Unique key | The purchase token | transactionId |
| Test purchase | purchaseType is 0 | environment 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_testrows 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,
RestoreTransactionscalls StoreKit 2’sAppStore.sync()on Apple platforms and, on success, runsFetchPurchases, so owned products arrive inOnPurchasesFetched. The component above sends a Remove Ads order to the server, which recognizes the transaction ID and returnsadsRemoved: 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:
- Buy coins twice in a row. The balance rises twice, and each purchase has its own
iap_purchasesrow. - Buy coins, then kill the app before the server answers. On the next launch the order arrives again, and the coins are granted once.
- Turn off the network during verification. Nothing is granted; after reconnecting, the purchase completes.
- Buy Remove Ads, delete the app, reinstall, sign in, and tap Restore Purchases on iOS. Ads stay off.
- Send the same purchase token from a second test account. The server answers 400 or 409.
- 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_creditledger rows next to youriaprows. 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.