Monetizing a mobile app with in-app purchases used to be one of those jobs that made you question your career choices. Two completely different client implementations, two completely different server validation flows, and the constant fun of keeping both in sync as each store tweaked its policies. Honestly, it was a slog.
The story in .NET MAUI 10 is finally starting to improve. Microsoft shipped an official BillingService sample in 2026 that gives you a clean IBillingService abstraction spanning Android, iOS, Mac Catalyst, and Windows — one interface, four platforms, sensible MVVM. It's a real step forward.
But here's the catch: the sample stops at the client. Ship it as-is, and a determined user with a rooted device (or even just a freely available receipt-replay tool) can hand your app a fake purchase and watch it merrily unlock premium features for free. Not great.
So, this guide picks up where the official sample leaves off. We'll wire up the client the recommended way, then build the server-side receipt validation for both Google Play and the App Store that any production app actually needs.
Why In-App Purchases Are Still Hard in .NET MAUI 10
Let's be upfront about something: .NET MAUI 10 still ships without first-party IAP APIs. There's an open proposal to add a unified Microsoft.Maui.Billing surface backed by StoreKit 2 and Google Play Billing Library v7, but it hasn't landed yet. Until it does, you've got three realistic options:
- Microsoft BillingService sample — the new official pattern. Clean MVVM, dependency-injected, targets .NET 10, covers four platforms. Best starting point in 2026.
- Plugin.InAppBilling — James Montemagno's community plugin. Battle-tested, but he has publicly signaled it's winding down now that Microsoft has an official sample.
- IAPHUB or RevenueCat-style SDKs — hosted billing that hides the platform differences and handles validation for you. Fastest path to production, but you pay a percentage of revenue (which adds up fast once you're at scale).
This article goes with option one, because that's where the ecosystem is moving and, frankly, because it teaches you what the abstractions are actually doing under the hood.
The BillingService Architecture
Before writing any code, it helps to see the shape of what you're building. The sample's architecture is deliberately small — and that's a good thing:
IBillingService // shared contract, lives in /Services
├── BillingService.Android.cs // Google Play Billing Client
├── BillingService.iOS.cs // StoreKit (iOS + Mac Catalyst)
└── BillingService.Windows.cs // Microsoft Store
One interface, one partial class per platform, and conditional compilation via the MAUI multi-targeting model. Here's the contract:
public interface IBillingService
{
Task<bool> InitializeAsync();
Task<IEnumerable<Product>> GetProductsAsync(IEnumerable<string> productIds);
Task<PurchaseResult> PurchaseAsync(string productId);
Task<bool> RestorePurchasesAsync();
bool IsProductOwned(string productId);
event EventHandler<PurchaseResult> PurchaseCompleted;
}
public record Product(string Id, string Title, string Description, string FormattedPrice, ProductType Type);
public enum ProductType { Consumable, NonConsumable, Subscription }
public record PurchaseResult(bool Success, string? ProductId, string? TransactionId, string? ReceiptPayload, string? Error);
Two things to notice. First, PurchaseResult carries a ReceiptPayload — that's the opaque blob you'll ship to your server for validation. Second, PurchaseCompleted is an event, not a return value. StoreKit and Google Play can both deliver purchases asynchronously (pending payments, parental approvals, the occasional sandbox race condition), so the interface has to support out-of-band delivery. Trying to model this as a simple await-and-return gets you in trouble fast.
Step 1: Configure Products in Every Console
You can't test anything until the stores know your SKUs exist. Do this first — seriously. It's the step that blocks developers for days when skipped, and I've watched it happen more than once.
Google Play Console
- Upload at least one signed AAB to an internal testing track. Google Play will flat-out refuse to list products until it has a build.
- Open Monetize → In-app products for one-time items, or Monetize → Subscriptions for recurring.
- Set the product ID to a reverse-DNS string like
com.yourcompany.app.pro_monthly. You cannot rename or reuse IDs, so pick well (you'll live with this choice for years). - Add license testers under Setup → License testing — their purchases are sandboxed and refunded automatically.
App Store Connect
- Open your app, go to Features → In-App Purchases or Subscriptions.
- For subscriptions, create a subscription group first — StoreKit uses groups to enforce "one active subscription per group" semantics.
- Fill in localizations and screenshots. Apple won't let you submit products for review without them, and "just a placeholder" won't get past review either.
- Create sandbox testers under Users and Access → Sandbox. Sign out of your real Apple ID on the test device and sign in with the sandbox account only when prompted mid-purchase.
Microsoft Partner Center
For Windows, head to Monetize → Add-ons, associate your MAUI app with a Store listing, and define durable or consumable add-ons. One thing to keep in mind: Windows doesn't yet support subscriptions through the Microsoft Store IAP API, so plan accordingly.
Step 2: Wire Up MauiProgram and the ViewModel
Registration is one line per platform because the partial class takes care of multi-targeting:
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>();
builder.Services.AddSingleton<IBillingService, BillingService>();
builder.Services.AddSingleton<IReceiptValidator, ReceiptValidator>();
builder.Services.AddTransient<StoreViewModel>();
builder.Services.AddTransient<StorePage>();
return builder.Build();
}
The IReceiptValidator is the piece the official sample is missing; we'll add it in Step 5.
A minimal ViewModel, using CommunityToolkit.Mvvm source generators:
public partial class StoreViewModel : ObservableObject
{
private readonly IBillingService _billing;
private readonly IReceiptValidator _validator;
[ObservableProperty] private ObservableCollection<Product> _products = new();
[ObservableProperty] private bool _isBusy;
private static readonly string[] ProductIds =
{
"com.yourcompany.app.pro_monthly",
"com.yourcompany.app.pro_yearly",
"com.yourcompany.app.remove_ads"
};
public StoreViewModel(IBillingService billing, IReceiptValidator validator)
{
_billing = billing;
_validator = validator;
_billing.PurchaseCompleted += OnPurchaseCompleted;
}
[RelayCommand]
private async Task LoadAsync()
{
IsBusy = true;
try
{
await _billing.InitializeAsync();
var items = await _billing.GetProductsAsync(ProductIds);
Products = new ObservableCollection<Product>(items);
}
finally { IsBusy = false; }
}
[RelayCommand]
private async Task BuyAsync(Product product)
{
var result = await _billing.PurchaseAsync(product.Id);
if (!result.Success) return;
await OnPurchaseCompletedAsync(result);
}
private async void OnPurchaseCompleted(object? sender, PurchaseResult e)
=> await OnPurchaseCompletedAsync(e);
private async Task OnPurchaseCompletedAsync(PurchaseResult result)
{
var verified = await _validator.ValidateAsync(result);
if (verified) Entitlements.Grant(result.ProductId!);
}
}
Step 3: The Android Implementation (Google Play Billing v7)
Google Play Billing Client v7 expects you to launch a BillingFlow, listen for PurchasesUpdated callbacks, and — this is the critical part — acknowledge every purchase within three days, or Google will auto-refund the user. That last part trips up beginners all the time, and it's the kind of bug you discover when someone in finance asks why your revenue graph looks weird.
#if ANDROID
using Android.BillingClient.Api;
public partial class BillingService : IBillingService, IBillingClientStateListener, IPurchasesUpdatedListener
{
private BillingClient? _client;
private readonly Dictionary<string, ProductDetails> _cachedDetails = new();
private TaskCompletionSource<bool>? _connectTcs;
public event EventHandler<PurchaseResult>? PurchaseCompleted;
public Task<bool> InitializeAsync()
{
_connectTcs = new TaskCompletionSource<bool>();
_client = BillingClient.NewBuilder(Platform.AppContext)
.SetListener(this)
.EnablePendingPurchases()
.Build();
_client.StartConnection(this);
return _connectTcs.Task;
}
public void OnBillingSetupFinished(BillingResult result)
=> _connectTcs?.TrySetResult(result.ResponseCode == BillingResponseCode.Ok);
public void OnBillingServiceDisconnected() { /* reconnect on next call */ }
public async Task<IEnumerable<Product>> GetProductsAsync(IEnumerable<string> productIds)
{
var queryProducts = productIds.Select(id =>
QueryProductDetailsParams.Product.NewBuilder()
.SetProductId(id)
.SetProductType(BillingClient.ProductType.Subs) // or .Inapp
.Build()).ToList();
var queryParams = QueryProductDetailsParams.NewBuilder()
.SetProductList(queryProducts).Build();
var result = await _client!.QueryProductDetailsAsync(queryParams);
foreach (var d in result.ProductDetails)
_cachedDetails[d.ProductId] = d;
return result.ProductDetails.Select(d => new Product(
d.ProductId, d.Name, d.Description,
d.OneTimePurchaseOfferDetails?.FormattedPrice
?? d.SubscriptionOfferDetails?[0].PricingPhases.PricingPhaseList[0].FormattedPrice
?? "",
d.ProductType == BillingClient.ProductType.Subs ? ProductType.Subscription : ProductType.NonConsumable));
}
public async Task<PurchaseResult> PurchaseAsync(string productId)
{
if (!_cachedDetails.TryGetValue(productId, out var details))
return new(false, productId, null, null, "Product not loaded");
var offerToken = details.SubscriptionOfferDetails?[0].OfferToken;
var productParamsBuilder = BillingFlowParams.ProductDetailsParams.NewBuilder()
.SetProductDetails(details);
if (offerToken != null) productParamsBuilder.SetOfferToken(offerToken);
var flowParams = BillingFlowParams.NewBuilder()
.SetProductDetailsParamsList(new[] { productParamsBuilder.Build() })
.Build();
var activity = Platform.CurrentActivity!;
_client!.LaunchBillingFlow(activity, flowParams);
return new(true, productId, null, null, null); // final result comes via OnPurchasesUpdated
}
public async void OnPurchasesUpdated(BillingResult result, IList<Purchase>? purchases)
{
if (result.ResponseCode != BillingResponseCode.Ok || purchases == null) return;
foreach (var p in purchases)
{
if (p.PurchaseState != Purchase.PurchaseStateEnum.Purchased) continue;
if (!p.IsAcknowledged)
{
var ack = AcknowledgePurchaseParams.NewBuilder()
.SetPurchaseToken(p.PurchaseToken).Build();
await _client!.AcknowledgePurchaseAsync(ack);
}
PurchaseCompleted?.Invoke(this, new PurchaseResult(
true,
p.Products.First(),
p.OrderId,
p.PurchaseToken, // send to server alongside productId
null));
}
}
}
#endif
The PurchaseToken is what your server will hand to Google's purchases.products.get or purchases.subscriptionsv2.get endpoint to verify the transaction.
Step 4: The iOS Implementation (StoreKit)
Microsoft's sample still targets StoreKit 1, because full Swift interop from .NET hasn't shipped yet. The team has said the sample will get a StoreKit 2 update once interop lands. Until then, use SKPaymentQueue:
#if IOS || MACCATALYST
using StoreKit;
public partial class BillingService : SKPaymentTransactionObserver, IBillingService
{
private TaskCompletionSource<SKProductsResponse>? _productsTcs;
private readonly Dictionary<string, SKProduct> _products = new();
public event EventHandler<PurchaseResult>? PurchaseCompleted;
public Task<bool> InitializeAsync()
{
SKPaymentQueue.DefaultQueue.AddTransactionObserver(this);
return Task.FromResult(SKPaymentQueue.CanMakePayments);
}
public Task<IEnumerable<Product>> GetProductsAsync(IEnumerable<string> productIds)
{
_productsTcs = new TaskCompletionSource<SKProductsResponse>();
var request = new SKProductsRequest(new NSSet(productIds.ToArray()));
request.ReceivedResponse += (_, e) => _productsTcs.TrySetResult(e.Response);
request.Start();
return _productsTcs.Task.ContinueWith(t =>
{
foreach (var p in t.Result.Products) _products[p.ProductIdentifier] = p;
return t.Result.Products.Select(Map);
});
}
private static Product Map(SKProduct p)
{
var fmt = new NSNumberFormatter { FormatterBehavior = NSNumberFormatterBehavior.Version_10_4, NumberStyle = NSNumberFormatterStyle.Currency, Locale = p.PriceLocale };
return new Product(p.ProductIdentifier, p.LocalizedTitle, p.LocalizedDescription,
fmt.StringFromNumber(p.Price), ProductType.NonConsumable);
}
public Task<PurchaseResult> PurchaseAsync(string productId)
{
if (!_products.TryGetValue(productId, out var product))
return Task.FromResult(new PurchaseResult(false, productId, null, null, "Unknown product"));
SKPaymentQueue.DefaultQueue.AddPayment(SKPayment.PaymentWithProduct(product));
return Task.FromResult(new PurchaseResult(true, productId, null, null, null));
}
public override void UpdatedTransactions(SKPaymentQueue queue, SKPaymentTransaction[] transactions)
{
foreach (var tx in transactions)
{
switch (tx.TransactionState)
{
case SKPaymentTransactionState.Purchased:
case SKPaymentTransactionState.Restored:
var receipt = NSData.FromUrl(NSBundle.MainBundle.AppStoreReceiptUrl!)
?.GetBase64EncodedString(NSDataBase64EncodingOptions.None);
PurchaseCompleted?.Invoke(this, new PurchaseResult(
true, tx.Payment.ProductIdentifier, tx.TransactionIdentifier, receipt, null));
queue.FinishTransaction(tx);
break;
case SKPaymentTransactionState.Failed:
PurchaseCompleted?.Invoke(this, new PurchaseResult(
false, tx.Payment.ProductIdentifier, null, null, tx.Error?.LocalizedDescription));
queue.FinishTransaction(tx);
break;
}
}
}
}
#endif
The base64-encoded AppStoreReceipt is what you'll POST to Apple for validation.
Step 5: Server-Side Receipt Validation (the Part Microsoft Left Out)
This is the single piece that separates a demo from a production IAP implementation. Without it, a tampered client can call Entitlements.Grant() with any product ID it wants. With it, you verify the transaction against the store before handing out entitlements — which is, you know, the whole point.
Contract
public interface IReceiptValidator
{
Task<bool> ValidateAsync(PurchaseResult result);
}
public class ReceiptValidator : IReceiptValidator
{
private readonly HttpClient _http;
public ReceiptValidator(IHttpClientFactory factory) => _http = factory.CreateClient("api");
public async Task<bool> ValidateAsync(PurchaseResult result)
{
var payload = new
{
platform = DeviceInfo.Platform.ToString(),
productId = result.ProductId,
transactionId = result.TransactionId,
receipt = result.ReceiptPayload
};
var resp = await _http.PostAsJsonAsync("/iap/validate", payload);
return resp.IsSuccessStatusCode;
}
}
The client ships the receipt to your server. Your server then talks to the store. Never put store-side credentials in the app — a decompiled APK leaks them in seconds.
Server: Validate an App Store Receipt
Apple's classic endpoint takes your shared secret (set in App Store Connect) and the base64 receipt, and returns the decoded purchase list. Production URL is https://buy.itunes.apple.com/verifyReceipt; if it returns status 21007, retry against the sandbox URL https://sandbox.itunes.apple.com/verifyReceipt. That's the canonical Apple guidance for handling both environments from one code path (and yes, you do want both in one path — running separate binaries for sandbox vs production is a special kind of pain).
// ASP.NET Core minimal API
app.MapPost("/iap/validate", async (ValidateRequest req, HttpClient http, IConfiguration cfg) =>
{
if (req.Platform is "iOS" or "MacCatalyst")
{
var body = new { receipt_data = req.Receipt, password = cfg["Apple:SharedSecret"] };
var prod = await http.PostAsJsonAsync("https://buy.itunes.apple.com/verifyReceipt", body);
var json = await prod.Content.ReadFromJsonAsync<JsonElement>();
var status = json.GetProperty("status").GetInt32();
if (status == 21007) // sandbox receipt hit production endpoint
{
var sand = await http.PostAsJsonAsync("https://sandbox.itunes.apple.com/verifyReceipt", body);
json = await sand.Content.ReadFromJsonAsync<JsonElement>();
status = json.GetProperty("status").GetInt32();
}
if (status != 0) return Results.Unauthorized();
var receipts = json.GetProperty("receipt").GetProperty("in_app");
var matches = receipts.EnumerateArray()
.Any(r => r.GetProperty("product_id").GetString() == req.ProductId
&& r.GetProperty("transaction_id").GetString() == req.TransactionId);
return matches ? Results.Ok() : Results.Unauthorized();
}
// Android branch below...
});
Server: Validate a Google Play Purchase
On Android you authenticate to Google with a service account (created in Google Cloud, then linked in Play Console → API access) and call purchases.products.get for one-offs or purchases.subscriptionsv2.get for subscriptions. purchaseState must be 0 (purchased) and, for subscriptions, acknowledgementState must be 1.
using Google.Apis.AndroidPublisher.v3;
using Google.Apis.Auth.OAuth2;
using Google.Apis.Services;
async Task<bool> ValidateAndroidAsync(string packageName, string productId, string purchaseToken)
{
var credential = GoogleCredential.FromFile("service-account.json")
.CreateScoped(AndroidPublisherService.ScopeConstants.Androidpublisher);
var publisher = new AndroidPublisherService(new BaseClientService.Initializer
{
HttpClientInitializer = credential,
ApplicationName = "MauiIapValidator"
});
var purchase = await publisher.Purchases.Products
.Get(packageName, productId, purchaseToken)
.ExecuteAsync();
return purchase.PurchaseState == 0 && purchase.ConsumptionState == 0;
}
Store the resulting entitlement against the user ID in your database. That's your source of truth — never trust a client-cached boolean. Ever.
Handling Subscriptions Properly
Subscriptions are where most apps quietly lose revenue to bugs. A few rules that save real pain:
- Poll on app launch. Re-validate the latest subscription receipt every cold start. Users cancel, upgrade, or get billed-out without your app knowing, and StoreKit/Play won't push you a notification while the app is closed.
- Use server-to-server notifications. Apple's App Store Server Notifications V2 and Google's Real-Time Developer Notifications (via Pub/Sub) push renewal, cancellation, refund, and grace-period events to your server. Wire them up or you'll have ghost subscribers lingering in your database.
- Handle grace periods. If a payment fails, both stores give the user 16–30 days to update their card. During that window the subscription is technically active; revoke access too early and you'll refund a lot of churn.
- Never expose the raw entitlement to the client. The client asks your server "is this user pro?" — it does not check a local flag the user could toggle.
Testing Strategy
IAP testing is honestly its own discipline. Here's a minimum viable matrix:
- Android license testers — purchases are sandboxed, auto-refunded after 14 days, and let you test renewal compression (1 week becomes 5 minutes, which is wonderfully convenient).
- iOS sandbox testers — subscriptions renew much faster (a monthly sub renews every 5 minutes, up to 6 times). Sign in only via Settings → App Store → Sandbox Account, never your real Apple ID.
- Receipt replay — capture a sandbox receipt and replay it against your validator to confirm idempotency. Your server should grant the entitlement once and reject duplicates.
- Tampered receipt — flip a byte in the base64 payload. Your validator must return
Unauthorized. If it returnsOk, stop and fix it before doing anything else. - Offline purchase flow — kill network mid-purchase. The transaction should queue and complete when connectivity returns;
SKPaymentTransactionObserverandBillingClientboth support this, but only if you callInitializeAsyncon every app launch.
Common Pitfalls
- Forgetting to acknowledge on Android. Google refunds unacknowledged purchases after 72 hours. Your CFO will notice.
- Finishing an iOS transaction before validation. If you call
FinishTransactionbefore the server confirms the receipt, a network failure loses the purchase forever. Finish only after validation succeeds. - Hard-coding product IDs in two places. Keep them in a single constants file or — better — fetch them from a server config endpoint so you can add SKUs without shipping a build.
- Trusting the client for entitlements. Assume every client is compromised. The server owns the truth.
FAQ
Does .NET MAUI have built-in in-app purchase support in 2026?
No. .NET MAUI 10 still relies on platform APIs and either a community or Microsoft sample abstraction. A proposal to add first-party Microsoft.Maui.Billing APIs using StoreKit 2 and Google Play Billing v7 is on the roadmap, but it hasn't shipped. For now, the Microsoft BillingService sample is the recommended starting point.
Should I use Plugin.InAppBilling or the new Microsoft BillingService sample?
For new projects, go with the Microsoft BillingService sample. It targets .NET 10, uses modern MVVM patterns, and is where future official support will land. Plugin.InAppBilling is still stable for existing apps, but the author has indicated maintenance is winding down.
Do I really need server-side receipt validation for a small app?
Yes — especially if any unlocked feature has meaningful cost to you (premium content, AI inference, paid API calls). Receipt-replay tools and tweaked app stores make client-only validation trivial to bypass. The hour it takes to stand up a validator pays for itself the first time someone tries.
How do I test subscription renewals without waiting a month?
Both stores compress timelines in sandbox. Apple sandbox renews a monthly subscription every 5 minutes, up to 6 times, then expires. Google Play license testers renew weekly subscriptions every 5 minutes. Use these to verify your grace-period and entitlement-revocation logic end to end.
Can I share one codebase between MAUI and Avalonia for IAP?
Not easily with the Microsoft sample — it depends on MAUI-specific primitives like Platform.CurrentActivity. If you need a shared abstraction across UI frameworks, a hosted SDK such as IAPHUB (or an abstraction layer around Xamarin.Essentials-style APIs) is a better fit than forking the sample.
Wrapping Up
The .NET MAUI 10 IAP story is the best it has ever been, but let's be real: you're still gluing together platform APIs behind a shared interface, and you're still on the hook for server-side validation. Start from Microsoft's BillingService sample, add the IReceiptValidator piece it leaves out, wire up the server-to-server notifications from each store, and treat the server as the single source of truth for entitlements.
That's the shortest path I know of from "works in sandbox" to "holds up in production." Good luck — and go acknowledge those Android purchases.