# Use Cloud Code to fulfill purchases

> Implement a Cloud Code module as your backend solution to fulfill purchases.

If you use D2C payment providers, you can use a [Cloud Code module](/cloud-code/modules/overview.md) 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:

1. Grant product entitlements and update the player's inventory in a database, such as Cloud Save, based on the Stock Keeping Unit (SKU).
2. Call the orders API to mark the order as fulfilled.

> **Important:**
>
> Marking the order as fulfilled is required, not optional. The player keeps their entitlement either way, but an order that stays in the `paid` state leaves Unity IAP order tracking and analytics incomplete.

For information on the orders API, refer to the [event types](./implement-backend.md#event-types) documentation.

## Deploy the module

For information on how to deploy the module, refer to the Cloud Code [Get started](/cloud-code/modules/getting-started.md#deploy-the-module) 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:

1. In the [Unity Dashboard](https://cloud.unity.com), select **IAP** > **Payment Providers**.
2. Under **Entitlement Delivery Method**, select **Edit**.
3. Select the **Cloud Code module** option and then select your module and endpoint from the dropdowns.
4. Select **Save Configuration**.

## Use logs

When a the Cloud Code module processes a transaction, you can view the logs in the [Cloud Code Dashboard](https://cloud.unity.com/home/organizations/default/projects/default/environments/default/cloud-code/logs). For more information, refer to the Cloud Code [Logging](/cloud-code/logging/overview.md) documentation.

## Cloud Code example module

Refer to the following example module that handles the purchase event webhooks:

```csharp
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());
    }
}
```

[Integrate D2C payment providers](./workflow.md#use-a-cloud-code-module-to-fulfill-purchases): Return to the Integrate D2C payment providers with IAP workflow page.
[Test your integration](./test-integration.md): Proceed to the next step in the workflow to set up your D2C payment provider.
