Use Cloud Code to fulfill purchases
Implement a Cloud Code module as your backend solution to fulfill purchases.
Read time 6 minutesLast updated 11 hours ago
If you use D2C payment providers, you can use a Cloud Code module to fulfill purchases. Cloud Code validates the webhook purchase event and returns a success response. You need to write a module to implement the following:
- Grant product entitlements and update the player's inventory in a database, such as Cloud Save, based on the Stock Keeping Unit (SKU).
- Call the orders API to mark the order as fulfilled.
For information on the orders API, refer to the event types documentation.
Deploy the module
For information on how to deploy the module, refer to the Cloud Code Get started guide.Set up your Cloud Code module in the IAP Dashboard
Configure your project to receive purchase information from your Cloud Code module in the Unity Dashboard:- In the Unity Dashboard, select IAP > Payment Providers.
- Under Entitlement Delivery Method, select Edit.
- Select the Cloud Code module option and then select your module and endpoint from the dropdowns.
- Select Save Configuration.
Use logs
When a the Cloud Code module processes a transaction, you can view the logs in the Cloud Code Dashboard. For more information, refer to the Cloud Code Logging documentation.Cloud Code example module
Refer to the following example module that handles the purchase event webhooks:using System;using System.Collections.Generic;using System.Net.Http;using System.Net.Http.Headers;using System.Text;using System.Text.Json;using System.Text.Json.Serialization;using System.Threading;using System.Threading.Tasks;using Microsoft.Extensions.DependencyInjection;using Microsoft.Extensions.Logging;using Unity.Services.CloudCode.Core;using Unity.Services.CloudCode.Apis;using Unity.Services.CloudSave.Model;namespace Fulfillment;// Handles IAP purchase webhooks: grants product entitlements via Cloud Save and marks orders as fulfilled.public class IapFulfillmentHandler{ private readonly ILogger<IapFulfillmentHandler> m_Logger; private static readonly HttpClient s_HttpClient = new() { Timeout = TimeSpan.FromSeconds(10) }; private const string k_IapTransactionVerifierBaseUrl = "https://iap.services.api.unity.com/v1"; // Accepts and stores the logger used for diagnostics. public IapFulfillmentHandler(ILogger<IapFulfillmentHandler> logger) { m_Logger = logger; } // Webhook entry point: grants entitlements from line items via Cloud Save, then marks the order as fulfilled and returns a response. [CloudCodeFunction("ProcessPurchaseFulfillment")] public async Task<WebhookResponse> ProcessPurchaseFulfillment( IExecutionContext context, IGameApiClient gameApiClient, Guid id, string version, string eventType, DateTime time, string projectId, string environmentId, string dataType, WebhookOrderData data) { try { m_Logger.LogInformation( "Processing purchase fulfillment - EventID: {EventID}, EventType: {EventType}, Time: {Time}, " + "ProjectID: {ProjectID}, EnvironmentId: {EnvironmentId}, PlayerId: {PlayerId}, " + "PaymentProvider: {PaymentProvider}, PaymentProviderResourceId: {PaymentProviderResourceId}, " + "OrderId: {OrderId}, LineItems: {LineItems}, CustomReferenceId: {CustomReferenceId}", id.ToString(), eventType, time.ToString(), projectId, environmentId, data.PlayerId, data.PaymentProvider, data.PaymentProviderResourceId, data.Id, JsonSerializer.Serialize(data.LineItems), data.CustomReferenceId); List<string> entitlements = await GrantProductEntitlements( context, gameApiClient, data.PlayerId, data.LineItems); m_Logger.LogInformation( "Successfully processed purchase fulfillment for order {OrderId} with " + "{Entitlements}", data.Id, entitlements); try { m_Logger.LogInformation("Calling fulfillment API for order {OrderId}", data.Id); bool fulfillmentSuccess = await FulfillOrderAsync( data.Id, projectId, environmentId, context.ServiceToken ?? string.Empty); if (fulfillmentSuccess) { m_Logger.LogInformation("Successfully marked order {OrderId} as fulfilled", data.Id); } else { m_Logger.LogWarning( "Failed to mark order {OrderId} as fulfilled, but entitlements were " + "granted", data.Id); } } catch (Exception ex) { m_Logger.LogError(ex, "Error calling fulfillment API for order {OrderId}: {Error}", data.Id, ex.Message); } return new WebhookResponse { Status = WebhookResponseCodes.WebhookStatusOK }; } catch (Exception ex) { m_Logger.LogError( "Unexpected error during fulfillment for order {OrderId}: {Error}", data.Id, ex.Message); return new WebhookResponse { Status = WebhookResponseCodes.WebhookStatusError, Code = WebhookResponseCodes.WebhookErrorDeclined, Description = ex.Message }; } } // Loads the player inventory, applies each line item by SKU (gems, pass, storage), saves inventory, and returns the list of granted entitlement descriptions. private async Task<List<string>> GrantProductEntitlements( IExecutionContext context, IGameApiClient gameApiClient, string playerId, List<WebhookLineItem> lineItems) { if (lineItems == null || lineItems.Count == 0) { m_Logger.LogWarning("GrantProductEntitlements called with null or empty line items list"); return new List<string>(); } List<string> entitlements = new List<string>(); try { m_Logger.LogInformation("Getting player inventory for player {PlayerId}", playerId); PlayerInventory currentInventory = await GetPlayerInventory( context, gameApiClient, playerId); m_Logger.LogInformation("Retrieved inventory: {Inventory}", JsonSerializer.Serialize(currentInventory)); foreach (WebhookLineItem item in lineItems) { string sku = item.Sku; m_Logger.LogInformation("Processing line item: SKU={SKU}, ProductType={ProductType}", sku, item.ProductType); switch (sku) { case "com.unity.iap.test.adventure.pass.not": int days = 30; currentInventory.AdventurePass = DateTime.UtcNow.AddDays(days); entitlements.Add($"{days} Days Adventure Pass"); break; case "com.unity.iap.test.30.gems": int gems30 = 30; currentInventory.Gems += gems30; entitlements.Add($"{gems30} Gems"); break; case "com.unity.iap.test.premium.storage": currentInventory.PremiumStorage = true; entitlements.Add($"PremiumStorage = true"); break; default: m_Logger.LogWarning("Unknown SKU {SKU} in fulfillment request", sku); break; } } m_Logger.LogInformation("Updated inventory: inventory: {Inventory}", JsonSerializer.Serialize(currentInventory)); m_Logger.LogInformation("Saving player inventory"); await SavePlayerInventory(context, gameApiClient, playerId, currentInventory); m_Logger.LogInformation("Successfully saved player inventory"); } catch (Exception ex) { m_Logger.LogError("Error in GrantProductEntitlements: {Error}", ex.Message); throw; } return entitlements; } // Fetches the "player_inventory" key from Cloud Save for the player and deserializes it; returns a new empty inventory if missing or on error. private async Task<PlayerInventory> GetPlayerInventory( IExecutionContext context, IGameApiClient gameApiClient, string playerId) { try { var result = await gameApiClient.CloudSaveData.GetItemsAsync( context, context.ServiceToken!, context.ProjectId!, playerId, new List<string> { "player_inventory" }); if (result.Data.Results.Count <= 0) { m_Logger.LogInformation("No existing inventory found, creating new inventory"); return new PlayerInventory(); } var resultItem = result.Data.Results[0]; if (resultItem.Value == null) { m_Logger.LogWarning("Inventory value is null, creating new inventory"); return new PlayerInventory(); } string inventoryJson; try { inventoryJson = resultItem.Value.ToString(); if (string.IsNullOrWhiteSpace(inventoryJson)) { m_Logger.LogWarning("Inventory JSON is empty, creating new inventory"); return new PlayerInventory(); } } catch (Exception ex) { m_Logger.LogError("Failed to convert inventory value to string: {Error}", ex.Message); return new PlayerInventory(); } try { PlayerInventory inventory = JsonSerializer.Deserialize<PlayerInventory>( inventoryJson); return inventory ?? new PlayerInventory(); } catch (Exception ex) { m_Logger.LogError("Failed to deserialize inventory JSON: {Error}. JSON: {Json}", ex.Message, inventoryJson); return new PlayerInventory(); } } catch (Exception ex) { m_Logger.LogError("Unexpected error while getting inventory: {Error}", ex.Message); return new PlayerInventory(); } } // Serializes the inventory to JSON and writes it to Cloud Save under the "player_inventory" key for the player. private async Task SavePlayerInventory( IExecutionContext context, IGameApiClient gameApiClient, string playerId, PlayerInventory inventory) { try { if (inventory == null) { m_Logger.LogWarning("Attempted to save null inventory, skipping"); return; } string inventoryJson; try { inventoryJson = JsonSerializer.Serialize(inventory); } catch (Exception ex) { m_Logger.LogError("Failed to serialize inventory: {Error}", ex.Message); throw; } await gameApiClient.CloudSaveData.SetItemAsync( context, context.ServiceToken!, context.ProjectId!, playerId, new SetItemBody("player_inventory", inventoryJson)); m_Logger.LogInformation("Successfully saved inventory for player {PlayerId}", playerId); } catch (Exception ex) { m_Logger.LogError("Failed to save inventory: {Error}", ex.Message); throw; } } // Sends a PATCH request to the IAP orders API to set the order status to fulfilled; returns true on success. private async Task<bool> FulfillOrderAsync( string orderId, string projectId, string environmentId, string serviceToken) { if (string.IsNullOrEmpty(orderId)) { m_Logger.LogWarning("Cannot fulfill order - order ID is null or empty"); return false; } if (string.IsNullOrEmpty(projectId)) { m_Logger.LogWarning("Cannot fulfill order - project ID is null or empty"); return false; } if (string.IsNullOrEmpty(environmentId)) { m_Logger.LogWarning("Cannot fulfill order - environment ID is null or empty"); return false; } if (string.IsNullOrEmpty(serviceToken)) { m_Logger.LogWarning("Cannot fulfill order - service token is null or empty"); return false; } try { string url = $"{k_IapTransactionVerifierBaseUrl}/projects/{projectId}/environments/" + $"{environmentId}/orders/{orderId}"; var requestBody = new { status = "fulfilled" }; string jsonContent = JsonSerializer.Serialize(requestBody); StringContent content = new StringContent( jsonContent, Encoding.UTF8, "application/json"); HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Patch, url) { Content = content }; request.Headers.Authorization = new AuthenticationHeaderValue( "Bearer", serviceToken); m_Logger.LogInformation("Sending fulfillment request to {Url} for order {OrderId}", url, orderId); HttpResponseMessage response = await s_HttpClient.SendAsync(request); if (response.IsSuccessStatusCode) { m_Logger.LogInformation("Fulfillment API call successful for order {OrderId}. Status: {StatusCode}", orderId, response.StatusCode); return true; } string responseBody = await response.Content.ReadAsStringAsync(); m_Logger.LogWarning( "Fulfillment API call failed for order {OrderId}. Status: {StatusCode}, " + "Response: {ResponseBody}", orderId, response.StatusCode, responseBody); return false; } catch (Exception ex) { m_Logger.LogError(ex, "Unexpected error while fulfilling order {OrderId}: {Error}", orderId, ex.Message); return false; } }}// Player inventory stored in Cloud Save (gems, adventure pass, premium storage, and similar).public class PlayerInventory{ public int Gems { get; set; } public int XpBooster { get; set; } public DateTime? AdventurePass { get; set; } public bool PremiumStorage { get; set; }}// Order payload sent by the IAP webhook for a purchase event.public class WebhookOrderData{ [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; [JsonPropertyName("playerId")] public string PlayerId { get; set; } = string.Empty; [JsonPropertyName("paymentProvider")] public string PaymentProvider { get; set; } = string.Empty; [JsonPropertyName("paymentProviderResourceId")] public string? PaymentProviderResourceId { get; set; } [JsonPropertyName("url")] public string Url { get; set; } = string.Empty; [JsonPropertyName("lineItems")] public List<WebhookLineItem> LineItems { get; set; } = new(); [JsonPropertyName("currency")] public string Currency { get; set; } = string.Empty; [JsonPropertyName("amounts")] public WebhookAmounts? Amounts { get; set; } [JsonPropertyName("status")] public string Status { get; set; } = string.Empty; [JsonPropertyName("customReferenceId")] public string? CustomReferenceId { get; set; } [JsonPropertyName("metadata")] public Dictionary<string, string>? Metadata { get; set; } [JsonPropertyName("createdAt")] public DateTime CreatedAt { get; set; } [JsonPropertyName("updatedAt")] public DateTime UpdatedAt { get; set; } [JsonPropertyName("paidAt")] public DateTime? PaidAt { get; set; } [JsonPropertyName("fulfilledAt")] public DateTime? FulfilledAt { get; set; }}// A single purchasable item in an order (SKU, product type, and price).public class WebhookLineItem{ [JsonPropertyName("sku")] public string Sku { get; set; } = string.Empty; [JsonPropertyName("productType")] public string ProductType { get; set; } = string.Empty; [JsonPropertyName("price")] public WebhookMoney Price { get; set; } = null!;}// Total and refunded amounts for an order, in micros.public class WebhookAmounts{ [JsonPropertyName("totalMicros")] public long TotalMicros { get; set; } [JsonPropertyName("refundedMicros")] public long RefundedMicros { get; set; }}// A monetary amount and currency (amount in micros).public class WebhookMoney{ [JsonPropertyName("amountMicros")] public long AmountMicros { get; set; } [JsonPropertyName("currency")] public string Currency { get; set; } = string.Empty;}// Status and error code constants for IAP webhook responses.public static class WebhookResponseCodes{ public const string WebhookStatusOK = "ok"; public const string WebhookStatusError = "error"; public const string WebhookErrorDeclined = "declined";}// Response returned by the IAP fulfillment webhook to the IAP service.public class WebhookResponse{ public string Status { get; set; } = string.Empty; public string? Code { get; set; } public string? Description { get; set; }}// Registers Cloud Code module dependencies (for example, the game API client).public class ModuleConfig : ICloudCodeSetup{ // Registers the game API client as a singleton for re-use by Cloud Code functions. public void Setup(ICloudCodeConfig config) { config.Dependencies.AddSingleton(GameApiClient.Create()); }}