Flutter in-app purchase validation with .NET 6
Flutter team has made it very easy to monetize your apps with the help of in-app purchase package. However, on top of Flutter integration itself, which is very well described in this codelab, you’ll also need to set up a backend for validation and storage of your receipts to ensure you will not get punked, be able to provide effective customer service and maintain simple and straightforward bookkeeping.
The codelab provided by Google implies the use of Node.JS, Cloud Functions, and Firestore/Storage to verify and store your transaction records. But what if you are not “hip enough” to use Node.JS? Well, you are in the right place, because in this article we will look at how to use Flutter in-app purchases with .NET 6 Web API – the preferred Web API of large corporations and government organizations.
Here we cover the ABSOLUTE BARE MINIMUM implementation and will concentrate on the validation and storage of receipts, as the entire subject of in-app purchases is fairly massive and cannot be covered in-depth in a single article.
Prerequisites:
- ASP.Net Core 6 Web API (be it Win or Linux – doesn’t matter)
- Storage of your choice (MongoDB, MySQL, or a SQL Server, Cloud).
- Authentication (Firebase oAuth is free and it works well).
- Generous amount of time – one month at least.
1. Adding configuration to your app settings
Here we assume that you have completed, and are in possession of configuration data from step 9 of the codelab. If so, you should have a service-account.json file on your hands for server-to-server oAuth with Google API. Place that file into the root of your solution, right next to the appsettings.json.
Now we need to add whatever key-value pairs we will need in the API. Ideally, this should be stored securely, but we will keep it simple for now.
Let’s open appsettings.json and add the configs to the end of that file into a separate object, called, say, PurchaseValidationSettings and place our AppStore secret and a bundle ID for GooglePlay in there:
{
// ...
// other settings omitted here
// ...
"PurchaseValidationSettings": {
"AppStore": {
"SharedSecret": "e1302a0384098bf0e43de33b3ca50aa4"
},
"GooglePlay": {
"BundleId": "com.example.app"
}
}
}
Now let’s create the necessary models and pull our settings into the app:
PurchaseValidationSettings.cs
public class PurchaseValidationSettings
{
public AppStore? AppStore { get; set; }
public GooglePlay? GooglePlay { get; set; }
public PurchaseValidationSettings() { }
public PurchaseValidationSettings(AppStore appStore, GooglePlay googlePlay)
{
AppStore = appStore;
GooglePlay = googlePlay;
}
}
AppStore.cs:
public class AppStore
{
public String? SharedSecret { get; set; }
public AppStore() { }
public AppStore(string sharedSecret)
{
SharedSecret = sharedSecret;
}
}
GooglePlay.cs:
public class GooglePlay
{
public string? BundleId {get; set;}
public GooglePlay() { }
public GooglePlay(string bundleId)
{
this.BundleId = bundleId;
}
}
And now we should be able to add that configuration to Program.cs:
builder.Services.Configure<PurchaseValidationSettings>(
builder.Configuration.GetSection("PurchaseValidationSettings")
);
All that should allow us to read the config data during run time.
2. Adding endpoint to your API
The next thing we need is to add an actual endpoint, that the Flutter app will use to validate the transaction. All we need from the client for that matter is the Platform
on which the transaction is being made, the product ID
from the store, and the token
. Token is the serverVerificationData
string, returned by the store as a part of the PurchaseDetails
object when the user completes the transaction (more about it will be described in step #3).
So let’s go ahead and add that class and call it PurchaseDetails.cs:
public class PurchaseDetails
{
public string ProductID { get; set; }
public string VerificationData { get; set; }
public string Platform { get; set; }
public PurchaseDetails(string productID, string verificationData, string platform)
{
ProductID = productID;
VerificationData = verificationData;
Platform = platform;
}
public bool IsIOS => Platform == "iOS";
public bool IsAndroid => Platform == "Android";
public bool IsUnsupported => Platform == "Unsupported";
}
As you can see, I expect from the client only three values. The UserID
will be taken from the JWT token, so we don’t need to pass it explicitly. Also, for convenience, I’ve added three getters that will make my life easier down the road.
Now that we have that model, let’s add a controller to our API to establish a validation endpoint, say PurchaseValidationController.cs:
[Route("[controller]")]
[ApiController]
public class PurchaseValidationController : ControllerBase
{
private readonly MyApiService _service;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly string _uid;
public PurchaseValidationController(MyApiService service,
IHttpContextAccessor httpContextAccessor)
{
_service = service;
_httpContextAccessor = httpContextAccessor;
_uid = _httpContextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "";
}
[Authorize]
[HttpPost]
public async Task<ActionResult<bool>> VerifyPurchase([FromBody] PurchaseDetails purchaseDetails)
{
if (purchaseDetails.IsIOS || purchaseDetails.IsAndroid)
{
var res = await _service.ValidatePurchase(purchaseDetails, _uid);
return Ok(res);
}
return BadRequest("Unsupported platform");
}
}
This controller only has a single POST endpoint. That point requires authentication and converts JSON Map provided in the body into a PurchaseDetails
object we will use for validation. Also, we parse JWT token to extract the user ID _uid
and pass it to the service along with purchase details.
In VerifyPurchase
method, we do a mere symbolic validation of the platform, and, if it checks out, – we proceed to the service for the actual validation.
As far as the service goes – you can create a dedicated service for this validation, or reuse an already existing service – that doesn’t really matter. All we need is to add only a handful of methods, in my case – to MyAppService.cs:
public async Task<bool> ValidatePurchase(PurchaseDetails purchaseDetails, string uid)
{
if (purchaseDetails.IsIOS)
{
// TODO: return await ValidateIos(purchaseDetails, uid);
return true;
}
else if (purchaseDetails.IsAndroid)
{
// TODO: return await ValidateAndroid(purchaseDetails, uid);
return true;
}
return false;
}
This is where we stop for now (we’ll return to this later to un-comment the two commented methods).
Let’s proceed to the client to integrate this API point to do what it’s there for. You can go ahead and deploy this API to try and call it from the Flutter app. At this point, we should always get a positive response.
3. Adding a validation call to your Flutter app
If you followed through the codelab step 10, you should have two methods in your class: _verifyPurchase
and _handlePurchase
. While the latter should be modified to reflect the purchases you are planning to conduct, the former should be tweaked to call the API method we added in the previous step. So after removing all the Cloud FN stuff and adding in the new call, you should end up with something like this:
import 'dart:io' as io;
...
Future<bool> _verifyPurchase(PurchaseDetails details) async {
return await _service.execPost(
ApiEndpoints.verifyPurchase, // your API at https://api.example.com/api/v1/ValidatePurchse/ or something like that
{
'verificationData': details.verificationData.serverVerificationData,
'platform': io.Platform.isIOS
? 'iOS'
: io.Platform.isAndroid
? 'Android'
: 'Unsupported',
},
CancelToken());
}
In the example above we assume that you have an established HTTP _service
(i.e. Dio) that can send authenticated POST requests to your API with oAuth token in the header. As a payload, we are passing a Map<String, String> object with only two values that your API is expecting to find as described in step 2 of this article. Those are the Platform and the token, which is a string value of PurchaseDetails.verificationData.serverVerificationData
for both platforms.
Believe it or not – this is all we need for the client side.
Go ahead and try to make a purchase – the endpoint should return true on both platforms.
4. Verifying iOS purchases
Apple has made it really easy to validate your receipts (thanks Apple!). Before we begin, let’s quickly look over this article. As you can see, all we have to do is to make a POST request to the production URL, providing a very basic object consisting of very few things. If the AppStore detects a sandbox environment – you will need to loop back to a sandbox URL, which is really cool and lets you use the same code for both environments. So here’s what your final method could look like:
public async Task<bool> ValidateIos(PurchaseDetails purchaseDetails, string uid)
{
string prodUrl = "https://buy.itunes.apple.com/verifyReceipt";
string sbUrl = "https://sandbox.itunes.apple.com/verifyReceipt";
var requestBody = new Dictionary<string, dynamic>() {
{ "receipt-data", purchaseDetails.VerificationData},
{ "password", _settings.Value.AppStore!.SharedSecret!},
{"exclude-old-transactions", false } };
var _client = new HttpClient();
HttpResponseMessage response = await _client.PostAsJsonAsync(prodUrl, requestBody);
var receiptJson = await response.Content.ReadAsStringAsync();
var receipt = JsonConvert.DeserializeObject<Dictionary<string, dynamic>>(receiptJson);
// if code = 21007 go back and fetch using SB URL
if (receipt != null && receipt["status"] == 21007)
{
response = await _client.PostAsJsonAsync(sbUrl, requestBody);
receiptJson = await response.Content.ReadAsStringAsync();
receipt = JsonConvert.DeserializeObject<Dictionary<string, dynamic>>(receiptJson);
}
if (receipt != null && receipt["status"] == 0)
{
if (receipt["receipt"]["in_app"][0]["in_app_ownership_type"] == "PURCHASED")
{
await SaveReceipt(purchaseDetails, uid, receiptJson);
return true;
}
}
_client.Dispose();
return false;
}
If you’ve done everything right, the AppStore will return the following JSON object:
{
"receipt": {
"receipt_type": "ProductionSandbox",
"adam_id": 0,
"app_item_id": 0,
"bundle_id": "com.example.app",
"application_version": "14",
"download_id": 0,
"version_external_identifier": 0,
"receipt_creation_date": "2022-12-18 19:34:56 Etc/GMT",
"receipt_creation_date_ms": "1671392096000",
"receipt_creation_date_pst": "2022-12-18 11:34:56 America/Los_Angeles",
"request_date": "2022-12-18 19:36:34 Etc/GMT",
"request_date_ms": "1671392194290",
"request_date_pst": "2022-12-18 11:36:34 America/Los_Angeles",
"original_purchase_date": "2013-08-01 07:00:00 Etc/GMT",
"original_purchase_date_ms": "1375340400000",
"original_purchase_date_pst": "2013-08-01 00:00:00 America/Los_Angeles",
"original_application_version": "1.0",
"in_app": [
{
"quantity": "1",
"product_id": "your_product_id",
"transaction_id": "20000001729",
"original_transaction_id": "20000001729",
"purchase_date": "2022-12-18 19:34:56 Etc/GMT",
"purchase_date_ms": "1671392096000",
"purchase_date_pst": "2022-12-18 11:34:56 America/Los_Angeles",
"original_purchase_date": "2022-12-18 19:34:56 Etc/GMT",
"original_purchase_date_ms": "1671392096000",
"original_purchase_date_pst": "2022-12-18 11:34:56 America/Los_Angeles",
"is_trial_period": "false",
"in_app_ownership_type": "PURCHASED"
}
]
},
"environment": "Sandbox",
"latest_receipt_info": [
{
"quantity": "1",
"product_id": "your_product_id",
"transaction_id": "20000001729",
"original_transaction_id": "20000001729",
"purchase_date": "2022-12-18 19:34:56 Etc/GMT",
"purchase_date_ms": "1671392096000",
"purchase_date_pst": "2022-12-18 11:34:56 America/Los_Angeles",
"original_purchase_date": "2022-12-18 19:34:56 Etc/GMT",
"original_purchase_date_ms": "1671392096000",
"original_purchase_date_pst": "2022-12-18 11:34:56 America/Los_Angeles",
"is_trial_period": "false",
"in_app_ownership_type": "PURCHASED"
}
],
"latest_receipt": "MIITwwY...b4iupc4NS0YE=",
"status": 0
}
In this response we are targeting this value: receipt->in_app[0]->in_app_ownership_type
that should have a value of "PURCHASED"
if everything worked out right.
5. Verifying Android purchases
For the Android validation, we will need to install a few nugets to make our life easy:
The latter will provide seamless server-to-server authentication, while the former will fetch the actual data. And here’s the code:
public async Task<bool> ValidateAndroid(PurchaseDetails purchaseDetails, string uid)
{
using var stream = new FileStream("service-account.json", FileMode.Open, FileAccess.Read);
ServiceAccountCredential? credential = GoogleCredential.FromStream(stream)
.UnderlyingCredential as ServiceAccountCredential;
if (credential == null) throw new ApplicationException("Could not get credential");
using (var pubService = new AndroidPublisherService(new BaseClientService.Initializer()
{
HttpClientInitializer = credential
})){
try
{
string packageName = _settings.Value.GooglePlay!.BundleId!;
string productId = purchaseDetails.ProductID;
string token = purchaseDetails.VerificationData;
ProductPurchase? receipt = pubService.Purchases.Products.Get(
packageName, productId, token).Execute();
if (receipt != null)
{
if (receipt.ConsumptionState == 1)
{
await SaveReceipt(purchaseDetails, uid, JsonSerializer.Serialize(receipt));
return true;
}
}
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
return false;
}
Here, when the call is completed, we will have a ProductPurchse object of the following format returned to us by GooglePlay:
{
"AcknowledgementState" : 1,
"ConsumptionState" : 1,
"DeveloperPayload" : "",
"Kind" : "androidpublisher#productPurchase",
"ObfuscatedExternalAccountId" : null,
"ObfuscatedExternalProfileId" : null,
"OrderId" : "GPA.3308-2776-5028-27586",
"ProductId" : null,
"PurchaseState" : 0,
"PurchaseTimeMillis" : NumberLong("1671671439000"),
"PurchaseToken" : null,
"PurchaseType" : 0,
"Quantity" : null,
"RegionCode" : "CA",
"ETag" : null
}
For the Android platform, we will be targeting the “ConsumptionState” field. If consumption state is equal to “1”, then the purchase is a success.
6. Storing the receipt into the database
You may have noticed in the previous examples: we he have a method called SaveReceipt. That’s the guy that takes care of storing the data in the database. In this case, we are using MySQL for the storage of receipts, and Mongo is taking care of the user accounts. If you haven’t created your table yet, here’s how it could look like:
email
and uid
columns in that table are somewhat redundant, but we add them for convenience anyway.
And, finally, here’s the code to do the actual saving:
private async Task SaveReceipt(PurchaseDetails purchaseDetails, string uid, string receipt)
{
string email = _usersCollection.Find(Builders<User>.Filter.Eq("Uid", uid)).Single().Email ?? "unknown";
// save receipt to DB
if(connection.State == System.Data.ConnectionState.Closed) await connection.OpenAsync();
string sql = @"INSERT INTO mydb.receipts (platform, product, email, uid, receipt)
VALUES(@platform, @product, @email, @uid, @receipt)";
using var command = new MySqlCommand(sql, connection);
command.Parameters.AddWithValue("@platform", purchaseDetails.Platform);
command.Parameters.AddWithValue("@product", purchaseDetails.ProductID);
command.Parameters.AddWithValue("@email", email);
command.Parameters.AddWithValue("@uid", uid);
command.Parameters.AddWithValue("@receipt", receipt);
int rowsAffected = command.ExecuteNonQuery();
command.Dispose();
await connection.CloseAsync();
}
7. Going forward
The code above should get you going, however, there are still lots of work left to do. The areas to improve on would be:
- Error handling – think of every possible scenario
- Error logging – gotta know what’s going on
- Security – ideally, all sensitive data should be stored securely, no in a json file.
- Admin console. You may need to be able to refund your customers and add CRUD to that table.
Thanks for reading!