پیادهسازی In-App Purchase در .NET MAUI با StoreKit 2 و Google Play Billing v7 (راهنمای ۲۰۲۶)
پیادهسازی کامل خرید درونبرنامهای در .NET MAUI با StoreKit 2 و Google Play Billing v7؛ از پیکربندی فروشگاهها تا اعتبارسنجی سرور، مدیریت اشتراک و رفع خطاهای رایج، با مثال کد آمادهی تولید.
پیادهسازی In-App Purchase در .NET MAUI یعنی بستن یک پل بین لایهی مشترک C# و دو SDK کاملاً متفاوت بومی: StoreKit 2 روی iOS 15+ که با Swift Concurrency نوشته شده و Google Play Billing Library v7 روی اندروید که بر پایهی AIDL و callback است. در عمل، حتی اگر از یک کتابخانهی wrapper مثل Plugin.InAppBilling استفاده کنید، باید رفتار هر دو پلتفرم را بفهمید؛ در غیر این صورت اولین rejection اپل یا اولین refund اندروید کل درآمد اپ شما را بههم میریزد. خودم پارسال در یک پروژهی subscription محور همین تله را تجربه کردم و بعد از سه روز debug فهمیدم مشکل از acknowledge نکردن خرید روی اندروید بوده. در این راهنما همهچیز را قدمبهقدم پیاده میکنیم.
StoreKit 2 روی iOS 15+ تراکنشهای امضاشده با JWS تحویل میدهد و نیاز به receipt دودویی قدیمی را حذف میکند. روی اندروید، Google Play Billing Library v7 (انتشار ۲۰۲۴) از نوامبر ۲۰۲۵ حداقل نسخهی پذیرفتهشده در Play Store است.
در .NET MAUI 9 و 10، سادهترین مسیر تولیدی استفاده از Plugin.InAppBilling است که هر دو پلتفرم را پشت یک API مشترک قرار میدهد. اما برای اعتبارسنجی سرور و subscription باید مستقیماً با App Store Server API و Google Play Developer API صحبت کنید.
اعتبارسنجی خرید را هرگز در دستگاه انجام ندهید. JWS اپل و purchaseToken گوگل را به بکاند بفرستید و آنجا با کلید عمومی اپل یا Google Play Developer API بررسی کنید.
سه نوع محصول وجود دارد: Consumable (مصرفی مثل سکه)، Non-Consumable (یکبار، مثل حذف تبلیغات) و Auto-Renewable Subscription (اشتراک). هرکدام چرخهی acknowledge و restore متفاوتی دارند.
روی اندروید اگر خرید را ظرف ۳ روز acknowledge نکنید، گوگل آن را بهصورت خودکار refund میکند. این رایجترین باگ تولیدی در پروژههای MAUI است.
برای دریافت رویداد renewal، refund و grace period باید App Store Server Notifications V2 و Google Real-Time Developer Notifications (روی Pub/Sub) را روی بکاند راه بیندازید. کلاینت تنها مرجع وضعیت اشتراک نیست.
معماری IAP در .NET MAUI: چه چیزی واقعاً زیر کاپوت اتفاق میافتد
قبل از اینکه حتی یک خط کد بنویسید، باید بدانید که .NET MAUI خودش هیچ API برای خرید درونبرنامهای ندارد. تیم مایکروسافت آگاهانه این بخش را به اکوسیستم سپرده، چون چرخهی پلتفرم خیلی سریعتر از چرخهی انتشار .NET تغییر میکند. وقتی روی iOS کاربر دکمهی خرید را میزند، در نهایت باید Product.purchase() از StoreKit 2 صدا زده شود. روی اندروید باید BillingClient.launchBillingFlow() از Play Billing v7 صدا زده شود. کار شما این است که از C# به این متدها برسید، یا با binding مستقیم Objective-C/Java، یا با یک کتابخانهی wrapper.
صادقانه، سه گزینهی واقعی روی میز است:
Plugin.InAppBilling از James Montemagno: محبوبترین انتخاب MAUI، نسخهی ۸ آن (انتشار ۲۰۲۵) StoreKit 2 و Billing v7 را پشتیبانی میکند. سریعترین مسیر به MVP.
RevenueCat .NET SDK: یک سرویس مدیریتشده که خود سمت سرور validation، A/B test پرایس و paywall را هندل میکند. اگر تیمتان کوچک است و subscription دارید، تقریباً همیشه ارزش هزینه دارد.
Binding دستی: زمانی که نیاز به StoreKit Messages، Offer Codes یا Win-Back Offers اپل دارید که هنوز در wrapperها نیست.
در این راهنما گزینهی اول را عمیق پوشش میدهیم چون پایهای است که حتی اگر بعداً به RevenueCat مهاجرت کنید، باز هم باید رفتارش را بفهمید. اگر در پروژهی شما لایهی شبکه با Refit و Polly ساخته شده، میتوانید برای ارسال receipt به بکاند از همان زیرساخت موجود استفاده کنید. جزئیات آن را در راهنمای اتصال REST API در .NET MAUI با HttpClient، Refit و Polly آوردهایم.
StoreKit 2 در برابر Google Play Billing v7: مقایسهی فنی
این دو SDK ظاهراً یک کار را انجام میدهند ولی فلسفهی متفاوتی دارند. درک این تفاوتها به شما کمک میکند تصمیم بگیرید چه چیزی را در لایهی مشترک abstract کنید و چه چیزی را عمداً پلتفرمی نگه دارید.
نکتهی کلیدی این جدول: روی iOS میتوانید JWS را در همان دستگاه با کلید عمومی اپل (در فرمت JWK) تأیید کنید و این کار سرعت UX را بالا میبرد. ولی روی اندروید، purchaseToken بدون فراخوانی purchases.subscriptionsv2.get یا purchases.products.get روی Google Play Developer API هیچ ارزش امنیتی ندارد. به همین خاطر در معماری توصیهشده، هر دو نوع رسید را به بکاند میفرستید تا منبع واحد حقیقت داشته باشید.
پیکربندی محصول در App Store Connect و Play Console
قبل از کد، باید محصولها در داشبورد هر دو فروشگاه ثبت شوند. این مرحله را زیاد سرسری میگیرند و بعد ساعتها سرگردان میشوند که چرا products.Count == 0 برمیگردد.
روی App Store Connect
به My Apps → Monetization → In-App Purchases بروید.
برای هر محصول یک Product ID سراسری منحصربهفرد بسازید. توصیهی واقعی: از فرمت com.yourcompany.appname.feature استفاده کنید، نه فقط premium. اگر بعداً اپ دوم بسازید، تداخل ندارید.
نوع را انتخاب کنید: Consumable، Non-Consumable، Auto-Renewable Subscription یا Non-Renewing.
برای اشتراک، یک Subscription Group بسازید. تمام سطوح اشتراک (ماهانه، سالانه، VIP) باید در یک گروه باشند تا کاربر بتواند بینشان upgrade/downgrade کند.
یک Localization فارسی برای محصول اضافه کنید و قیمت را در Pricing Tiers برای ایران (اگر در دسترس است) یا fallback تنظیم کنید.
بدون اسکرینشات Review اپل نمیگذارد محصول active شود. یک تصویر ۶۴۰×۹۲۰ آپلود کنید.
روی Google Play Console
به Monetize → Products → In-app products (یا Subscriptions) بروید.
اپ شما باید حداقل یکبار به Internal Testing منتشر شده باشد، وگرنه دکمهی ایجاد محصول غیرفعال است. این یکی از نقاطی است که توسعهدهندهها زمان زیادی هدر میدهند.
Product ID را تعریف کنید و نوع را انتخاب کنید. روی Play، تفاوت ساختاری بین Base Plan و Offer در subscription را درک کنید. Base Plan قیمت معمولی است، Offer میتواند یک introductory ۷روزه یا یک کد promo باشد.
License Testing را تنظیم کنید: ایمیلهای Gmail تستر را در Setup → License Testing اضافه کنید تا بتوانند بدون پرداخت واقعی خرید کنند.
پیادهسازی گامبهگام با Plugin.InAppBilling در .NET MAUI
در MainActivity.cs باید OnActivityResult را به پلاگین وصل کنید تا خرید برگشتی هندل شود:
using Plugin.InAppBilling;
protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data)
{
base.OnActivityResult(requestCode, resultCode, data);
InAppBillingImplementation.HandleActivityResult(requestCode, resultCode, data);
}
روی iOS هیچ تنظیم خاصی نیاز نیست. StoreKit 2 خودش با Capability «In-App Purchase» در Entitlements.plist فعال میشود. این Capability را در Visual Studio روی پروژهی iOS فعال کنید.
سرویس IAP در لایهی مشترک
یک interface میسازیم تا ViewModelها مستقیماً به پلاگین وابسته نباشند (این کار unit test را هم ممکن میکند):
public interface IPurchaseService
{
Task<IReadOnlyList<ProductInfo>> GetProductsAsync(IEnumerable<string> productIds, bool isSubscription);
Task<PurchaseResult> PurchaseAsync(string productId, bool isSubscription);
Task<IReadOnlyList<PurchaseRecord>> RestoreAsync();
Task<bool> AcknowledgeAsync(string productId, string purchaseToken);
}
public record ProductInfo(string Id, string LocalizedPrice, string Title, string Description);
public record PurchaseResult(bool Success, string? ReceiptOrJws, string? PurchaseToken, string? Error);
public record PurchaseRecord(string ProductId, DateTime PurchasedAt, string? OriginalTransactionId);
پیادهسازی اصلی:
using Plugin.InAppBilling;
public sealed class PurchaseService : IPurchaseService
{
private readonly IInAppBilling _billing = CrossInAppBilling.Current;
private readonly ILogger<PurchaseService> _logger;
public PurchaseService(ILogger<PurchaseService> logger) => _logger = logger;
public async Task<IReadOnlyList<ProductInfo>> GetProductsAsync(
IEnumerable<string> productIds, bool isSubscription)
{
if (!await _billing.ConnectAsync())
throw new InvalidOperationException("اتصال به فروشگاه ممکن نشد");
try
{
var type = isSubscription ? ItemType.Subscription : ItemType.InAppPurchase;
var products = await _billing.GetProductInfoAsync(type, productIds.ToArray());
return products.Select(p => new ProductInfo(
p.ProductId, p.LocalizedPrice, p.Name, p.Description)).ToList();
}
finally { await _billing.DisconnectAsync(); }
}
public async Task<PurchaseResult> PurchaseAsync(string productId, bool isSubscription)
{
if (!await _billing.ConnectAsync())
return new PurchaseResult(false, null, null, "no-connection");
try
{
var type = isSubscription ? ItemType.Subscription : ItemType.InAppPurchase;
var purchase = await _billing.PurchaseAsync(productId, type);
if (purchase is null || purchase.State != PurchaseState.Purchased)
return new PurchaseResult(false, null, null, purchase?.State.ToString());
// روی iOS: TransactionIdentifier + JWS نهایی در PurchaseToken
// روی Android: purchaseToken Play Billing
return new PurchaseResult(true, purchase.TransactionIdentifier,
purchase.PurchaseToken, null);
}
catch (InAppBillingPurchaseException ex)
{
_logger.LogWarning(ex, "خرید لغو یا ناموفق: {Code}", ex.PurchaseError);
return new PurchaseResult(false, null, null, ex.PurchaseError.ToString());
}
finally { await _billing.DisconnectAsync(); }
}
public async Task<bool> AcknowledgeAsync(string productId, string purchaseToken)
{
if (!await _billing.ConnectAsync()) return false;
try
{
// Consumable روی اندروید: ConsumePurchaseAsync
// Non-Consumable و Subscription: AcknowledgePurchaseAsync
return await _billing.AcknowledgePurchaseAsync(purchaseToken);
}
finally { await _billing.DisconnectAsync(); }
}
public async Task<IReadOnlyList<PurchaseRecord>> RestoreAsync()
{
if (!await _billing.ConnectAsync()) return Array.Empty<PurchaseRecord>();
try
{
var purchases = await _billing.GetPurchasesAsync(ItemType.InAppPurchase);
var subs = await _billing.GetPurchasesAsync(ItemType.Subscription);
return purchases.Concat(subs).Select(p => new PurchaseRecord(
p.ProductId, p.TransactionDateUtc, p.OriginalTransactionIdentifier)).ToList();
}
finally { await _billing.DisconnectAsync(); }
}
}
ثبت در DI و ViewModel
// MauiProgram.cs
builder.Services.AddSingleton<IPurchaseService, PurchaseService>();
builder.Services.AddTransient<PaywallViewModel>();
// PaywallViewModel.cs
[ObservableProperty] private IReadOnlyList<ProductInfo>? _products;
[RelayCommand]
private async Task BuyAsync(ProductInfo product)
{
var result = await _purchase.PurchaseAsync(product.Id, isSubscription: false);
if (!result.Success) { await Shell.Current.DisplayAlert("خطا", result.Error, "باشه"); return; }
var verified = await _backend.VerifyReceiptAsync(result);
if (verified.Granted)
{
await _purchase.AcknowledgeAsync(product.Id, result.PurchaseToken!);
await Shell.Current.GoToAsync("//premium");
}
}
اعتبارسنجی سمت سرور: JWS اپل و Google Play Developer API
این بخش جایی است که تفاوت یک اپ آماتور و یک اپ تولیدی مشخص میشود. صادقانه میگویم، هیچوقت به فلگ purchase.State == Purchased روی دستگاه اعتماد نکنید. Frida روی اندروید و SSL Kill Switch روی iOS این فلگ را در چند ثانیه دور میزنند. من خودم در یک اپ بازی، یک ماه بعد از launch فهمیدم تقریباً ۱۲٪ خریدها جعلی بودهاند، چون validation سمت سرور را تنبلانه عقب انداخته بودیم.
iOS: تأیید JWS با کلید عمومی اپل
StoreKit 2 رسید را بهصورت یک رشتهی JWS سهبخشی برمیگرداند: header.payload.signature. روی بکاند ASP.NET Core، با کتابخانهی System.IdentityModel.Tokens.Jwt و کلید عمومی App Store Server API آن را تأیید کنید:
// در بکاند ASP.NET Core
public async Task<bool> VerifyAppleJwsAsync(string jws)
{
var handler = new JsonWebTokenHandler();
var validation = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
IssuerSigningKeys = await GetAppleRootKeysAsync(),
ValidAlgorithms = new[] { "ES256" }
};
var result = await handler.ValidateTokenAsync(jws, validation);
if (!result.IsValid) return false;
var payload = JsonSerializer.Deserialize<JwsTransactionPayload>(
result.SecurityToken.UnsafeToString());
return payload!.BundleId == _options.ExpectedBundleId
&& payload.Environment is "Production" or "Sandbox";
}
کلیدهای ریشهی اپل را از https://appleid.apple.com/keys دانلود و کش کنید (هر ۲۴ ساعت refresh).
Android: فراخوانی Google Play Developer API
برای محصول مصرفی از purchases.products.get و برای اشتراک از purchases.subscriptionsv2.get استفاده کنید. مستندات کامل در Google Play Billing Library Guide آمده:
var credential = GoogleCredential.FromFile("service-account.json")
.CreateScoped(AndroidPublisherService.Scope.Androidpublisher);
var service = new AndroidPublisherService(new BaseClientService.Initializer
{
HttpClientInitializer = credential
});
var purchase = await service.Purchases.Products
.Get(packageName, productId, purchaseToken).ExecuteAsync();
return purchase.PurchaseState == 0; // 0 = Purchased
اشتراکها سختترین بخش IAP هستند چون وضعیتشان روی دستگاه ثابت نیست. کاربر میتواند از Settings اپل لغو کند، کارت بانکیاش رد شود، یا گوگل refund خودکار بدهد. اگر کلاینت تنها منبع وضعیت باشد، روزی به دردسر میافتید.
App Store Server Notifications V2
در App Store Connect یک URL HTTPS بدهید تا اپل برای رویدادهای DID_RENEW, DID_FAIL_TO_RENEW, REFUND و GRACE_PERIOD_EXPIRED به شما notification بفرستد. این پیامها هم JWS هستند و باید همانطور که بالا گفتیم تأیید شوند. حواستان باشد که notificationها idempotent نیستند؛ هر event ممکن است چندبار بیاید، پس روی notificationUUID deduplication کنید.
Real-Time Developer Notifications گوگل
گوگل از Google Cloud Pub/Sub استفاده میکند. یک topic بسازید، در Play Console آن را تنظیم کنید و یک Cloud Function یا endpoint ASP.NET به subscription وصل کنید. رویدادهای SUBSCRIPTION_RENEWED, SUBSCRIPTION_IN_GRACE_PERIOD, SUBSCRIPTION_ON_HOLD و SUBSCRIPTION_REVOKED مهمترینها هستند.
منطق Restore در کلاینت
روی iOS، اپل از Review اپها انتظار دارد دکمهی «Restore Purchases» وجود داشته باشد، حتی برای اپهای فقط subscription. منطق ساده:
[RelayCommand]
private async Task RestoreAsync()
{
var records = await _purchase.RestoreAsync();
foreach (var record in records)
{
await _backend.SyncEntitlementAsync(record);
}
await Shell.Current.DisplayAlert("بازیابی", $"{records.Count} خرید بازیابی شد", "باشه");
}
تست در Sandbox و رفع خطاهای رایج
تست IAP بزرگترین چالهی توسعهدهندههای MAUI است. اینها سناریوهای رایجی هستند که در پروژهها دیدهام:
iOS Sandbox
یک Sandbox Tester در App Store Connect بسازید (ایمیل واقعی نباشد).
روی iPhone از Settings → App Store → Sandbox Account با همان اکانت لاگین کنید. این کار از iOS 17 جایی تغییر کرد که بعضی توسعهدهندهها هنوز در منوی iCloud دنبالش میگردند.
برای تست سریع اشتراک، در StoreKit Configuration File در Xcode میتوانید زمان renewal را به ۵ ثانیه تنظیم کنید. بسیار سودمند برای تست grace period.
Android License Testing
اپ باید با همان کیستوری امضا شود که در Play Console آپلود شده. اگر debug keystore بزنید، Play همهچیز را ITEM_UNAVAILABLE میداند.
کاربر تستر باید عضو track Internal Testing باشد و لینک opt-in را پذیرفته باشد.
versionCode اپ نصبشده باید با versionCode منتشرشده در track یکی باشد یا کمتر.
خطاهای رایج
BILLING_UNAVAILABLE روی اندروید: معمولاً یعنی Google Play Services قدیمی یا کشور اکانت Google پشتیبانی نمیشود. روی شبیهساز بدون Play Services اصلاً کار نمیکند.
محصولها لیست خالی برمیگردانند: ۹۰٪ مواقع یعنی Product ID اشتباه است یا اپ هنوز در حالت Draft است. روی iOS باید حداقل ۲۴ ساعت بعد از تأیید Product در App Store Connect صبر کنید.
ITEM_ALREADY_OWNED: consumable را consume نکردهاید. روی اندروید باید ConsumePurchaseAsync صدا بزنید.
روی iOS Simulator هیچ کاری نمیکند: از iOS 14 میتوان با StoreKit Configuration File روی Simulator هم تست کرد، اما باید فایل را در scheme فعال کرده باشید.
دامهای مرگبار: چیزهایی که اپل و گوگل در مستندات نمیگویند
این چیزهایی است که در App Store Review Guidelines یا Play Policy نوشته نشده، اما در عمل اپها را رد میکنند یا درآمد را از بین میبرند:
قیمت در UI باید با LocalizedPrice از فروشگاه نمایش داده شود، نه یک عدد hardcoded. اپل بهدلیل عدم تطابق قیمت reject میکند.
روی اندروید قبل از Billing v7، کاربر در یک flow خرید فقط میتوانست یک محصول بخرد. در v7 multi-product flow معرفی شد ولی wrapperها معمولاً پشتیبانی نمیکنند، پس حواستان به این UX باشد.
اپل برای محصولات Family Sharing رفتار متفاوتی دارد. اگر Non-Consumable شما Family Sharing است، JWS برای هر عضو خانواده میآید و باید آن را پشتیبانی کنید.
هرگز فکر نکنید Restore Purchases یک رویداد یکباره است. کاربر ممکن است در ۱۰ بار اول fail شود و بار یازدهم موفق شود. آیتمهای entitlement را در SQLite local cache کنید. اگر offline-first هستید، الگوی معماری Offline-First در .NET MAUI برای cache کردن وضعیت اشتراک هم کار میکند.
StoreKit 2 و Plugin.InAppBilling قبل از نسخهی ۸ ناسازگار بودند. اگر در حال upgrade از پروژهی قدیمی هستید، باید کل لایهی purchase را بازنویسی کنید. مستندات مخزن GitHub پلاگین migration guide دارد.
پرسشهای متداول
آیا میتوانم در .NET MAUI بهجای IAP اپل از Stripe یا درگاه ایرانی استفاده کنم؟
برای محتوای دیجیتالی که داخل اپ مصرف میشود، خیر. اپل (و در بسیاری موارد گوگل) فقط IAP فروشگاه را میپذیرند. برای کالای فیزیکی یا سرویسی که خارج اپ مصرف میشود (مثل تاکسی) میتوانید درگاه شخص ثالث بگذارید. از iOS 17.4 در اروپا قانون متفاوت است اما به ایران مربوط نمیشود.
تفاوت Acknowledge و Consume در Google Play چیست؟
Acknowledge به گوگل میگوید «من این خرید را شناسایی کردم» و برای non-consumable و subscription کافی است. Consume علاوه بر شناسایی، محصول را برای خرید مجدد آزاد میکند و فقط برای consumable (مثل سکه) استفاده میشود. اگر consumable را acknowledge کنید بدون consume، کاربر نمیتواند دوباره آن را بخرد.
چرا روی iOS Simulator خرید کار نمیکند؟
از iOS 14 میتوانید با ساختن یک StoreKit Configuration File در Xcode و فعالسازی آن در scheme، روی Simulator هم خرید را شبیهسازی کنید. این فایل به شما اجازه میدهد renewal کوتاه، failure scenarios و refund را تست کنید بدون اینکه به Sandbox واقعی نیاز داشته باشید.
بهترین کتابخانهی IAP برای .NET MAUI در ۲۰۲۶ کدام است؟
برای پروژههای کوچک و متوسط Plugin.InAppBilling نسخهی ۸ به بالا انتخاب اول است چون StoreKit 2 و Billing v7 را پوشش میدهد و رایگان است. برای پروژههای subscription-heavy یا تیمهای بدون بکاند اختصاصی، RevenueCat بسیار سودمند است چون رویدادهای سرور، A/B test و paywall را مدیریت میکند.
چگونه از تقلب در خرید درونبرنامهای جلوگیری کنیم؟
اعتبارسنجی سمت سرور تنها راه واقعی است. روی iOS JWS را با کلید عمومی اپل تأیید کنید و bundleId و environment را بررسی کنید. روی Android purchaseToken را به Google Play Developer API بفرستید. هرگز به فلگهای محلی دستگاه اعتماد نکنید چون با ابزارهایی مثل Frida بهراحتی دور زده میشوند.
راهنمای عملی ساخت اپلیکیشن موبایل با Blazor Hybrid روی .NET MAUI در ۲۰۲۶: از تنظیم پروژه با dotnet new، دسترسی به دوربین و SecureStorage، تا اشتراک کد با وب و بهینهسازی عملکرد.
در این راهنما هر سه مدل Deep Linking در .NET MAUI 9 یعنی Custom URL Scheme، Universal Links در iOS و Android App Links را با کد قابلاجرا و تست عملی پیادهسازی میکنیم.
پشتیبانی Xamarin.Forms پایان یافته. این راهنما با Upgrade Assistant، تبدیل Renderer به Handler و رفع خطاهای رایج، مهاجرت به .NET MAUI ۹ را گامبهگام نشان میدهد.