In this tutorial we will show how to use consumable purchases with the steam store using Authentication, Economy, Cloud Code, Cloud Save and IAP. By the end of this guide you should have 2 buttons in your project that go through the purchasing process and authorizations through the steam overlay.
Requirements
This guide was created using the following versions,
- Unity 6000.3.11f1
- Unity Authentication 3.6.0
- Unity Cloud Code 2.10.2
- Unity Deployment 1.7.2
- Unity Cloud Save 3.4.0
- Unity Economy 3.5.3
- Unity IAP version 5.2.0
- Steamworks.NET Unity package (https://steamworks.github.io/installation/)
Unity Dashboard and Project Files
Secrets Manager (Dashboard)
The Secret Manager provides a unified secret management solution, enabling secure access to secrets across multiple solutions. It allows you to store, manage, and access sensitive information such as API keys, passwords, certificates, and other sensitive data.You can find more information on this here.
We need to add our appID and API to the secrets manager on the dashboard to do this, go through the following steps
- On the Dashboard go to Project->Your Project-> Secrets -> add project secret
- Create a secret called STEAM_APP_ID_SECRET (steam app id)
- To get your steam app id, go to your steam developer account, click on apps and packages, click on the project, it is next to the application name at the top or in the URL
- Create a secret called STEAM_API_KEY_SECRET (steam application API key)
- On the Main SteamWorks page select Users & Permissions > Manage Groups.
- Select the Group to be modified; by default, Everyone should be visible.
- Create a WebAPI key on the right hand side of the page under edit group:
- Copy the Web API key and put it into STEAM_API_KEY_SECRET
Steam App ID (Project Folder)
In your main project folder (in file explorer/ finder) create a txt file called steam_appid.txt and enter your App ID into it.
Setting the scene
To create our shop window UI we will need the following;
- Canvas
- Buttons for the purchases
- In this guide we will use a button for 500 Coins and another for 1000 Coins
- Coin currency value
- PlayerID Text Box
- Steam Name Text Box
- Buttons for the purchases
- Game Manager Object
It should resemble the following
Scripts
UI
Create a script called UI, this script will allow us to access the different elements of the UI panel from our Game Manager script
using UnityEngine;
using TMPro;
using UnityEngine.UI;
public class UI : MonoBehaviour
{
[SerializeField]
public TMP_Text BuildNumber;
[SerializeField]
public TMP_Text SteamName;
[SerializeField]
public TMP_Text PlayerId;
[SerializeField]
public TMP_Text Coins;
[SerializeField]
public Button Buy500GoldButton;
[SerializeField]
public Button Buy1000GoldButton;
public void SetPurchaseButtonVisibility(bool isVisible)
{
Buy500GoldButton.interactable = isVisible;
Buy1000GoldButton.interactable = isVisible;
}
}
Authentication
Create a script called UGS_Authentication, this script is used to sign into UGS authentication using an anonymous ID in our Game Manager script. Learn more about Unity Authentication here
using System.Threading.Tasks;
using UnityEngine;
using Unity.Services.Authentication;
public class UGS_Authentication
{
public string PlayerId { get { return AuthenticationService.Instance.PlayerId; } }
public async Task Init()
{
// Initialize the authentication service and Sign-In Anonymously
await Unity.Services.Authentication.AuthenticationService.Instance.SignInAnonymouslyAsync();
Debug.Log($"UGS Authentication PlayerId :: <color=green>{AuthenticationService.Instance.PlayerId}</color>");
}
}
Economy
Next we need a script called UGS_Economy, this script will be used to access the Economy service and pull down configuration/coin balance. For this we want to set up a currency in the UGS dashboard-> Economy->Configuration->Add Resource-> Currency -> Coins -> initial Balance-> 100. Create and publish this currency so we can access it from the client. To find more information on Economy click here to learn more.
using System.Threading.Tasks;
using UnityEngine;
using Unity.Services.Economy;
using Unity.Services.Economy.Model;
using System;
public class UGS_Economy
{
const string COINS = "COINS";
public async Task Init()
{
try
{
await EconomyService.Instance.Configuration.SyncConfigurationAsync();
}
catch (EconomyException e)
{
Debug.Log($"UGS_Economy:Init - Exception {e.Message}");
}
catch (Exception e)
{
Debug.Log($"UGS_Economy:Init - Exception {e.Message}");
}
}
public async Task<long> GetCoinBalance()
{
try
{
GetBalancesResult result = await EconomyService.Instance.PlayerBalances.GetBalancesAsync();
PlayerBalance playerBalance = result.Balances.Find(c => c.CurrencyId == COINS);
return playerBalance != null ? playerBalance.Balance : 0;
}
catch (EconomyRateLimitedException e)
{
Debug.Log($"UGS_Economy:GetCoinBalance - Exception {e.Message}");
return 0;
}
catch (EconomyException e)
{
Debug.Log($"UGS_Economy:GetCoinBalance - Exception {e.Message}");
return 0;
}
}
}
Steam
SteamManager
This script, SteamManager.cs, is available to all users using the Steamworks package, you should already have access to it but here is a reference of what it should look like if you don’t have it.
// The SteamManager is designed to work with Steamworks.NET
// This file is released into the public domain.
// Where that dedication is not recognized you are granted a perpetual,
// irrevocable license to copy and modify this file as you see fit.
//
// Version: 1.0.13
#if !(UNITY_STANDALONE_WIN || UNITY_STANDALONE_LINUX || UNITY_STANDALONE_OSX || STEAMWORKS_WIN || STEAMWORKS_LIN_OSX)
#define DISABLESTEAMWORKS
#endif
using UnityEngine;
#if !DISABLESTEAMWORKS
using System.Collections;
using Steamworks;
#endif
//
// The SteamManager provides a base implementation of Steamworks.NET on which you can build upon.
// It handles the basics of starting up and shutting down the SteamAPI for use.
//
[DisallowMultipleComponent]
public class SteamManager : MonoBehaviour {
#if !DISABLESTEAMWORKS
protected static bool s_EverInitialized = false;
protected static SteamManager s_instance;
protected static SteamManager Instance {
get {
if (s_instance == null) {
return new GameObject("SteamManager").AddComponent<SteamManager>();
}
else {
return s_instance;
}
}
}
protected bool m_bInitialized = false;
public static bool Initialized {
get {
return Instance.m_bInitialized;
}
}
protected SteamAPIWarningMessageHook_t m_SteamAPIWarningMessageHook;
[AOT.MonoPInvokeCallback(typeof(SteamAPIWarningMessageHook_t))]
protected static void SteamAPIDebugTextHook(int nSeverity, System.Text.StringBuilder pchDebugText) {
Debug.LogWarning(pchDebugText);
}
#if UNITY_2019_3_OR_NEWER
// In case of disabled Domain Reload, reset static members before entering Play Mode.
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void InitOnPlayMode()
{
s_EverInitialized = false;
s_instance = null;
}
#endif
protected virtual void Awake() {
// Only one instance of SteamManager at a time!
if (s_instance != null) {
Destroy(gameObject);
return;
}
s_instance = this;
if(s_EverInitialized) {
// This is almost always an error.
// The most common case where this happens is when SteamManager gets destroyed because of Application.Quit(),
// and then some Steamworks code in some other OnDestroy gets called afterwards, creating a new SteamManager.
// You should never call Steamworks functions in OnDestroy, always prefer OnDisable if possible.
throw new System.Exception("Tried to Initialize the SteamAPI twice in one session!");
}
// We want our SteamManager Instance to persist across scenes.
DontDestroyOnLoad(gameObject);
if (!Packsize.Test()) {
Debug.LogError("[Steamworks.NET] Packsize Test returned false, the wrong version of Steamworks.NET is being run in this platform.", this);
}
if (!DllCheck.Test()) {
Debug.LogError("[Steamworks.NET] DllCheck Test returned false, One or more of the Steamworks binaries seems to be the wrong version.", this);
}
try {
// If Steam is not running or the game wasn't started through Steam, SteamAPI_RestartAppIfNecessary starts the
// Steam client and also launches this game again if the User owns it. This can act as a rudimentary form of DRM.
// Note that this will run which ever version you have installed in steam. Which may not be the precise executable
// we were currently running.
// Once you get a Steam AppID assigned by Valve, you need to replace AppId_t.Invalid with it and
// remove steam_appid.txt from the game depot. eg: "(AppId_t)480" or "new AppId_t(480)".
// See the Valve documentation for more information: https://partner.steamgames.com/doc/sdk/api#initialization_and_shutdown
if (SteamAPI.RestartAppIfNecessary(AppId_t.Invalid)) {
Debug.Log("[Steamworks.NET] Shutting down because RestartAppIfNecessary returned true. Steam will restart the application.");
Application.Quit();
return;
}
}
catch (System.DllNotFoundException e) { // We catch this exception here, as it will be the first occurrence of it.
Debug.LogError("[Steamworks.NET] Could not load [lib]steam_api.dll/so/dylib. It's likely not in the correct location. Refer to the README for more details.\n" + e, this);
Application.Quit();
return;
}
// Initializes the Steamworks API.
// If this returns false then this indicates one of the following conditions:
// [*] The Steam client isn't running. A running Steam client is required to provide implementations of the various Steamworks interfaces.
// [*] The Steam client couldn't determine the App ID of game. If you're running your application from the executable or debugger directly then you must have a [code-inline]steam_appid.txt[/code-inline] in your game directory next to the executable, with your app ID in it and nothing else. Steam will look for this file in the current working directory. If you are running your executable from a different directory you may need to relocate the [code-inline]steam_appid.txt[/code-inline] file.
// [*] Your application is not running under the same OS user context as the Steam client, such as a different user or administration access level.
// [*] Ensure that you own a license for the App ID on the currently active Steam account. Your game must show up in your Steam library.
// [*] Your App ID is not completely set up, i.e. in Release State: Unavailable, or it's missing default packages.
// Valve's documentation for this is located here:
// https://partner.steamgames.com/doc/sdk/api#initialization_and_shutdown
m_bInitialized = SteamAPI.Init();
if (!m_bInitialized) {
Debug.LogError("[Steamworks.NET] SteamAPI_Init() failed. Refer to Valve's documentation or the comment above this line for more information.", this);
return;
}
s_EverInitialized = true;
}
// This should only ever get called on first load and after an Assembly reload, You should never Disable the Steamworks Manager yourself.
protected virtual void OnEnable() {
if (s_instance == null) {
s_instance = this;
}
if (!m_bInitialized) {
return;
}
if (m_SteamAPIWarningMessageHook == null) {
// Set up our callback to receive warning messages from Steam.
// You must launch with "-debug_steamapi" in the launch args to receive warnings.
m_SteamAPIWarningMessageHook = new SteamAPIWarningMessageHook_t(SteamAPIDebugTextHook);
SteamClient.SetWarningMessageHook(m_SteamAPIWarningMessageHook);
}
}
// OnApplicationQuit gets called too early to shutdown the SteamAPI.
// Because the SteamManager should be persistent and never disabled or destroyed we can shutdown the SteamAPI here.
// Thus it is not recommended to perform any Steamworks work in other OnDestroy functions as the order of execution can not be garenteed upon Shutdown. Prefer OnDisable().
protected virtual void OnDestroy() {
if (s_instance != this) {
return;
}
s_instance = null;
if (!m_bInitialized) {
return;
}
SteamAPI.Shutdown();
}
protected virtual void Update() {
if (!m_bInitialized) {
return;
}
// Run Steam client callbacks
SteamAPI.RunCallbacks();
}
#else
public static bool Initialized {
get {
return false;
}
}
#endif // !DISABLESTEAMWORKS
}Steam Overlay and Purchase Completion
Create a script called Steam.cs, this script will be used to allow us run the steam API callbacks to access information about the steam user and their purchase as well as activate the game overlay. Once the purchase is confirmed we pass the orderID to our cloud function to finalize the transaction.
using UnityEngine;
using System.Collections;
using Steamworks;
using System.Threading.Tasks;
public class Steam : MonoBehaviour
{
protected Callback<MicroTxnAuthorizationResponse_t> m_MicroTxnAuthorizationResponse;
protected Callback<GameOverlayActivated_t> m_GameOverlayActivated;
void Update()
{
if (!SteamManager.Initialized) return;
SteamAPI.RunCallbacks();
}
public void Init()
{
if (SteamManager.Initialized)
{
// Create InitTxn and Overlay Callbacks
m_MicroTxnAuthorizationResponse = Callback<MicroTxnAuthorizationResponse_t>.Create(OnMicroTxnAuthorizationResponse);
m_GameOverlayActivated = Callback<GameOverlayActivated_t>.Create(OnGameOverlayActivated);
string name = SteamFriends.GetPersonaName();
Debug.Log($"Steam Name :: <color=yellow>{name}</color>");
}
}
// The player has Authorised a Transaction in the Steam Purchase Overlay
private async void OnMicroTxnAuthorizationResponse(MicroTxnAuthorizationResponse_t pCallback)
{
Debug.Log("<color=yellow>Steam InitTxn CallBack Received</color>");
Debug.Log($"Response From Steam InitTxn : Authorized - {pCallback.m_bAuthorized}");
Debug.Log($"Response From Steam InitTxn : OrderId - {pCallback.m_ulOrderID}");
Debug.Log($"Response From Steam InitTxn : AppId - {pCallback.m_unAppID}");
// InitTxn Response from Steam Received, Now tell Steam Server to finalize the transaction
await GameManager.Instance.ugsCloudCode.SteamFinalizePurchase(pCallback.m_ulOrderID.ToString());
}
// Pause the application when the overlay is activated
private void OnGameOverlayActivated(GameOverlayActivated_t pCallback)
{
if (pCallback.m_bActive != 0)
{
Debug.Log("Steam Overlay has been activated");
Time.timeScale = 0;
}
else
{
Debug.Log("Steam Overlay has been closed");
Time.timeScale = 1;
}
}
}Note: Gamemanager will not exist at this point and will throw an error but we will address this later.
Cloud Code
Cloud Code Module
We will now create a Unity Cloud Code C# module that provides server-side Steam payment processing. It acts as the backend counterpart to the SteamStore.cs script we will create later. This module will contain two scripts, one called SteamPurchasing.cs and another called SteamOrders.cs.
Module Summary
The SteamPurchasing script handles the complete Steam purchase flow:
Cloud Code Functions (exposed to game clients):
- FetchProducts() - Returns hardcoded product catalog (500 coins for $0.99, 1000 coins for $1.49)
- SteamInitTxn() - Initiates a Steam transaction via Steam's Web API
- SteamFinalizeTxn() - Finalizes a transaction after player authorization
Steam Web API Integration:
- GetUserInfo() - Retrieves Steam user info including currency
- InitTxn() - Calls Steam's InitTxn API to start the purchase flow
- FinalizeTxn() - Calls Steam's FinalizeTxn API to complete the purchase
Transaction Redemption:
- RedeemTransaction() - Server-authoritative method that updates player's Unity Economy currency balance after successful Steam payment
- Sends push notification to client when purchase succeeds
The SteamOrders script manages the order lifecycle and persistence
- Generates unique order IDs (timestamp-based for demo)
- Stores order history in Unity Cloud Save (per-player)
- Tracks order metadata: orderId, transId, productId, state, timestamps, error info
Creating the Module
Follow the steps below to set up the cloud code module.
Configure Environment
- Open the Deployment window by going to Services > Deployment .
- Set environment in Deployment Settings (e.g., Production).
Create Cloud Code Module
- In Project window, right-click > Create > Services > Cloud Code C# Module Reference.
- Rename it IAPCustomStoreModule
- Select the newly created module reference file in the Project window.
- In Inspector, click Generate Solution.
- In Inspector, click Generate Bindings.
- Click Open solution
Deploy Module
- Open Deployment window.
- Select module reference file in Project window.
- In the Deployment window, click Deploy Selected.
Script Implementation
- In the Cloud Code project, rename example.cs to SteamPurchasing.cs.
- Create a second script called SteamOrders.cs in the same project
- Copy contents from code snippets below for their respective scripts.
- In Unity, select the IAPCustomStoreModule reference file.
- Click Generate Bindings.
- In the Deployment window, click Deploy Selected.
SteamPurchasing.cs
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
using Unity.Services.CloudCode.Apis;
using Unity.Services.CloudCode.Core;
using Unity.Services.CloudCode.Shared;
using Unity.Services.Economy.Model;
using System;
using System.Collections.Generic;
using System.Web;
using System.Threading.Tasks;
using System.Net.Http;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Net;
namespace SteamPurchasing;
public class SteamPurchasing
{
private const string STEAM_API_KEY_SECRET_KEY = "STEAM_API_KEY_SECRET";
private const string STEAM_APP_ID_SECRET_KEY = "STEAM_APP_ID_SECRET";
private Secret? _STEAM_API_KEY;
private Secret? _STEAM_APP_ID;
private const string DEFAULT_CURRENCY = "USD";
private const string DEFAULT_ECONOMY_CURRENCY_ID = "COINS";
private static HttpClient httpClient = new HttpClient();
private readonly ILogger<SteamPurchasing> _logger;
private readonly SteamOrders _steamOrders;
private readonly IPushClient _pushClient;
/// <summary>
/// Cloud Code Module Setup
/// </summary>
public class ModuleConfig : ICloudCodeSetup
{
public void Setup(ICloudCodeConfig config)
{
config.Dependencies.AddSingleton(GameApiClient.Create());
config.Dependencies.AddSingleton<IPushClient, PushClient>(_ => PushClient.Create());
config.Dependencies.AddSingleton<SteamOrders>();
config.Dependencies.AddSingleton<SteamPurchasing>();
}
}
/// <summary>
/// Steam Purchasing Constructor with dependency injection of Logger and Steam Orders singletons
/// </summary>
/// <param name="logger">Interface for loggign to CloudCode Logs in the Unity Dashboard</param>
/// <param name="steamOrders"><Class for handling Steam Orders</param>
public SteamPurchasing(ILogger<SteamPurchasing> logger, IPushClient pushClient, SteamOrders steamOrders)
{
_logger = logger;
_steamOrders = steamOrders;
_pushClient = pushClient;
}
#region CLOUD_CODE_METHODS
// -------------------------
// PUBLIC CLOUD CODE METHODS
// Cloud Code Methods exposed to the game client, must be public
// FetchProducts
/// <summary>
/// FetchProducts Returns a hardwired list of products, that could be fetched from Remote Config, or Economy
/// </summary>
/// <returns>A List of product dictionaries</returns>
[CloudCodeFunction("FetchProducts")]
public async Task<List<Dictionary<string, string>>> FetchProducts()
{
// TODO it would be better if this was a struct so it could be strongly typed
// And we need to consider type CONSUMABLE vs NON_CONSUMABLE and SUBSCRIPTIONS
// And we need to return Receipts and transactionID for purchased non consumable and subscripotions
try
{
// Two hardwired products are used for this experiment, ultimately these will be fetched from Remote Config
var productList = new List<Dictionary<string, string>>
{
new Dictionary<string, string>
{
{ "productId", "COINS_500" },
{ "itemId", "1" }, // must be an integer
{ "name", "500 Gold Coins" },
{ "description", "500 Gold Coins" },
{ "amount", "99" }, // the cost in USD cents
{ "quantity", "1"},
{ "unityCurrencyId", DEFAULT_ECONOMY_CURRENCY_ID}, // the Unity Economy Currency ID
{ "unityCurrencyAmount","500"}
},
new Dictionary<string, string>
{
{ "productId", "COINS_1000" },
{ "itemId", "2" }, // must be an integer
{ "name", "1000 Gold Coins" },
{ "description", "1000 Gold Coins" },
{ "amount", "149" },// the cost in USD cents
{ "quantity", "1" },
{ "unityCurrencyId", DEFAULT_ECONOMY_CURRENCY_ID}, // the Unity Economy Currency ID
{ "unityCurrencyAmount","1000"}
}
};
_logger.LogInformation($"{productList.Count} Products Fetched");
await Task.Delay(100); // Simulate a delay for when we fetch the products from Remote Config
return productList;
}
catch (Exception ex)
{
_logger.LogError($"Error Fetching Products: {ex.Message}");
return new List<Dictionary<string, string>>();
}
}
/// Initiate Steam Transaction
/// <summary>
/// Initiate Steam Transaction
/// </summary>
/// <param name="steamUserData">Steam User Data passed to CloudCode from game client</param>
/// <param name="context">IExecutionContext interface containing extra information about the current call.</param>
/// <param name="gameApiClient">IGameApiClient interface provides a way to access the Cloud Code C# Client SDKs.</param>
/// <returns>A string containing the transaction response from Steam</returns>
/// <exception cref="InvalidOperationException"></exception>
[CloudCodeFunction("SteamInitTxn")]
public async Task<string?> SteamInitTxn(IExecutionContext context, IGameApiClient gameApiClient, Dictionary<string, string> steamUserData)
{
await GetSteamSecrets(context, gameApiClient);
if (_STEAM_API_KEY == null || _STEAM_APP_ID == null)
{
_logger.LogError("Unable to retrieve Steam Secrets.");
throw new InvalidOperationException("Unable to retrieve Steam Secrets.");
}
// Check the user data providedis not null or empty
string? userDataResponse = await GetUserInfo(steamUserData);
if (string.IsNullOrEmpty(userDataResponse))
{
_logger.LogError("User data response is null or empty.");
throw new InvalidOperationException("User data response is null or empty.");
}
// Get User Currency from the user data
JObject userInfoJson = JObject.Parse(userDataResponse);
string currency = userInfoJson["response"]?["params"]?["currency"]?.ToString() ?? DEFAULT_CURRENCY;
// Get the Product Info based on the productId in the user data passed from the client
Dictionary<string, string> productData = await GetProductFromSteamUserData(steamUserData);
if (productData.Count == 0)
{
_logger.LogError("Product data is empty.");
throw new InvalidOperationException("Product data is empty.");
}
// Generate a new OrderId for this order
string orderID = _steamOrders.GenereateOrderId();
// Initialize Transaction, by calling the InitTxn method in this class
string? txnResponse = await InitTxn(orderID, currency, steamUserData, productData);
// Check that the Transaction Response is not null or empty
if (string.IsNullOrEmpty(txnResponse))
{
_logger.LogError("Transaction response is null or empty in Steam InitTxn.");
throw new InvalidOperationException("Transaction response is null or empty.");
}
// Parse the Transaction Response
JObject txnResponseJson = JObject.Parse(txnResponse);
string? result = txnResponseJson["response"]?["result"]?.ToString() ?? "Failure";
_logger.LogInformation($"InitTransaction result == {result} /n {txnResponse} ");
// Load the player's order history
SteamOrders.OrderHistory orderHistory = await _steamOrders.LoadOrderHistory(context, gameApiClient) ?? new SteamOrders.OrderHistory();
// Create a new Order object
SteamOrders.Order newOrder = new SteamOrders.Order() {
orderId = orderID,
transId = txnResponseJson["response"]?["params"]?["transid"]?.ToString() ?? null,
productId = steamUserData["productId"],
dateCreated = DateTime.UtcNow,
dateUpdated = DateTime.UtcNow
};
// Check if the result is OK, set its state to INITIALIZED, or FAILED order
if (result == "OK")
{
newOrder.state = SteamOrders.OrderState.INITIALIZED.ToString();
}
else
{
newOrder.state = SteamOrders.OrderState.FAILED.ToString();
newOrder.errorCode = txnResponseJson["response"]?["error"]?["errorcode"] != null ? (int)(txnResponseJson["response"]?["error"]?["errorcode"] ?? 0) : 0;
newOrder.errorDescription = txnResponseJson["response"]?["error"]?["errordesc"]?.ToString() ?? null;
}
// Append new Order to the player's order history
orderHistory.orders.Add(newOrder);
_logger.LogInformation($"Generated new Order {JsonConvert.SerializeObject(newOrder)}");
// Save the player's order history
await _steamOrders.SaveOrderHistory(context, gameApiClient, orderHistory);
return $"Purchase initialization response, {txnResponse}!";
}
// Finalize Steam Transaction
/// <summary>
/// Finalize Steam Transaction, called from the game client, when it hears from Steam
/// that the player Authorized the transaction in their Steam Wallet
/// </summary>
/// <param name="context">IExecutionContext interface containing extra information about the current call.</param>
/// <param name="gameApiClient">IGameApiClient interface provides a way to access the Cloud Code C# Client SDKs.</param>
/// <param name="pushClient">IPushClient interface provides a way send messages back to the Cloud Code Client</param>
/// <param name="orderId">The Order Id for the transaction to finalize</param>
/// <returns>A string containing the transaction response from Steam</returns>
/// <exception cref="InvalidOperationException"></exception>
[CloudCodeFunction("SteamFinalizeTxn")]
public async Task<string?> SteamFinalizeTxn(IExecutionContext context, IGameApiClient gameApiClient, IPushClient pushClient, string orderId)
{
await GetSteamSecrets(context, gameApiClient);
if (_STEAM_API_KEY == null || _STEAM_APP_ID == null)
{
_logger.LogError("Unable to retrieve Steam Secrets.");
throw new InvalidOperationException("Unable to retrieve Steam Secrets.");
}
_logger.LogInformation($"Finalizing Transaction for OrderId: {orderId}");
// Get the Order based on the orderId, which comes from the Steam callback on the client
SteamOrders.Order? order = await _steamOrders.GetOrder(context, gameApiClient, orderId);
if (order == null)
{
_logger.LogError("Order is null in SteamFinalizeTxn.");
throw new InvalidOperationException("Order is null.");
}
// Update Order State to PLAYER_AUTHORISED,
// incase something goes wrong between here and getting a response from Steam on the Finalize Transaction
order.state = SteamOrders.OrderState.PLAYER_AUTHORISED.ToString();
await _steamOrders.SaveOrder(context, gameApiClient, order);
// Finalize Transaction
string? txnResponse = await FinalizeTxn(orderId);
_logger.LogInformation($"Finalized Transaction {txnResponse}");
// Check that the Transaction Response is not null or empty
if (string.IsNullOrEmpty(txnResponse))
{
_logger.LogError("Transaction response is null or empty in SteamFinalizeTxn.");
throw new InvalidOperationException("Transaction response is null or empty.");
}
// Handle the FinaizeTxn Response
JObject txnResponseJson = JObject.Parse(txnResponse);
string? result = txnResponseJson["response"]?["result"]?.ToString();
_logger.LogInformation($"Finalize Transaction result == {result}");
// Update Order State, setting the state to STEAM_FINALIZED_SUCCESS, or FAILED
// And if the result is OK, redeem the transaction
if (result != null && result == "OK")
{
order.state = SteamOrders.OrderState.STEAM_FINALIZED_SUCCESS.ToString();
await RedeemTransaction(context, gameApiClient, pushClient, order);
}
else
{
order.state = SteamOrders.OrderState.FAILED.ToString();
order.errorCode = txnResponseJson["response"]?["error"]?["errorcode"] != null ? (int)(txnResponseJson["response"]?["error"]?["errorcode"] ?? 0) : 0;
order.errorDescription = txnResponseJson["response"]?["error"]?["errordesc"]?.ToString() ?? null;
}
// Save the Order and the player's OrderHistory back to Cloud Save
await _steamOrders.SaveOrder(context, gameApiClient, order);
return $"Purchase Finalization response, {txnResponse}!";
}
#endregion
#region PRIVATE METHODS
/// <summary>
/// Get Steam Secrets - Retrieves Steam API Key and App ID from Unity Secrets Manager.
/// </summary>
/// <param name="context">IExecutionContext interface containing extra information about the current call.</param>
/// <param name="gameApiClient">IGameApiClient interface provides a way to access the Cloud Code C# Client SDKs.</param>
/// <returns></returns>
private async Task GetSteamSecrets(IExecutionContext context, IGameApiClient gameApiClient)
{
_STEAM_APP_ID = await gameApiClient.SecretManager.GetSecret(context, STEAM_APP_ID_SECRET_KEY);
_STEAM_API_KEY = await gameApiClient.SecretManager.GetSecret(context, STEAM_API_KEY_SECRET_KEY);
}
/// <summary>
/// Redeem Steam Transaction by updating the player's currency balance in Unity Econonmy
/// NOTE: This is a Server Authoritative method, it can only be called from Cloud Code Server on a Steam approved transaction
/// </summary>
/// <param name="context">IExecutionContext interface containing extra information about the current call.</param>
/// <param name="gameApiClient">IGameApiClient interface provides a way to access the Cloud Code C# Client SDKs.</param>
/// <param name="order">The Order to that has successfully been Finalized</param>
/// <returns></returns>
private async Task RedeemTransaction(IExecutionContext context, IGameApiClient gameApiClient, IPushClient pushClient, SteamOrders.Order order)
{
// Check that the PlayerId is not null or empty
if (string.IsNullOrEmpty(context.PlayerId))
{
_logger.LogError("PlayerId is null or empty. Cannot increment player currency balance.");
throw new InvalidOperationException("PlayerId is null or empty.");
}
// Get the list of products and find the one that matches the productId in the Order that was passed in
List<Dictionary<string, string>> products = await FetchProducts();
Dictionary<string, string>? product = products.Find(x => x["productId"] == order.productId);
if (product == null)
{
_logger.LogInformation($"Unable to redeem transaction, Couldn't fetch Product {order.productId}");
throw new InvalidOperationException($"Unable to redeem transaction, Couldn't fetch Product {order.productId}");
}
// How much to incrment the player's currency by, from the product definition
CurrencyModifyBalanceRequest cmb = new CurrencyModifyBalanceRequest
{
Amount = Convert.ToInt32(product["unityCurrencyAmount"])
};
_logger.LogInformation($"Redeeming Order - {JsonConvert.SerializeObject(order)} For Currency {product["unityCurrencyId"]}. Amount {Convert.ToInt32(product["amount"])}");
// Update the player's currency balance
var economyResponse = await gameApiClient.EconomyCurrencies.IncrementPlayerCurrencyBalanceAsync(
context,
context.AccessToken,
context.ProjectId,
context.PlayerId,
product["unityCurrencyId"],
cmb);
// Check result and update Order State to REDEEMED if successful
// but, leave the state as STEAM_FINALIZED_SUCCESS if it failed so we can try to redeem again later
if (economyResponse.StatusCode == HttpStatusCode.OK)
{
order.state = SteamOrders.OrderState.REDEEMED.ToString();
_logger.LogInformation($"Currency Balance Updated : {economyResponse.Data?.CurrencyId}, new Balance = {economyResponse.Data?.Balance}");
}
else
{
_logger.LogInformation($"Currency Balance Update Failed : {economyResponse.StatusCode.ToString()} {economyResponse.ErrorText}");
}
// Save the Order and the player's OrderHistory back to Cloud Save
await _steamOrders.SaveOrder(context, gameApiClient, order);
// Notify the client to update it's Currency Balance UI
var response = await pushClient.SendPlayerMessageAsync(context, "PURCHASE_SUCCESSFUL", "STEAM_PURCHASING", context.PlayerId);
}
#endregion
#region PRIVATE_STEAM_WEB_API_METHODS
/// Steam Get UserInfo Web API Request
/// <summary>
/// Wrapper for Steam GetUserInfo Web API
/// https://partner.steamgames.com/doc/webapi/ISteamMicroTxn#GetUserInfo
/// </summary>
/// <param name="steamUserData">Steam User Data from the client</param>
/// <returns>Standard Steam WEB API response with OK or Failure</returns>
private async Task<string?> GetUserInfo(Dictionary<string, string> steamUserData)
{
string getUrl = "https://partner.steam-api.com/ISteamMicroTxnSandbox/GetUserInfo/v2/";
var queryParams = new Dictionary<string, string>
{
{ "key", _STEAM_API_KEY?.Value ?? "" },
{ "appid", _STEAM_APP_ID?.Value ?? "" },
{ "steamid", steamUserData["steamid"] }
};
string? responseString = await Get(getUrl, queryParams);
return responseString;
}
// Steam InitTxn Web API Request
/// <summary>
/// Wrapper for Steam InitTxn Web API
/// https://partner.steamgames.com/doc/webapi/ISteamMicroTxn#InitTxn
/// </summary>
/// <param name="orderid">The orderId for the order to Initiatiate</param>
/// <param name="currency">The currency of the transaction, hardwired to USD for this experiment</param>
/// <param name="steamUserData">Steam User Data from the client</param>
/// <param name="productData">Product Data</param>
/// <returns>Standard Steam WEB API response with OK or Failure</returns>
private async Task<string?> InitTxn(string orderid, string currency, Dictionary<string, string> steamUserData, Dictionary<string, string> productData)
{
string postUrl = "https://partner.steam-api.com/ISteamMicroTxnSandbox/InitTxn/v3/";
// Create a WWWForm to hold the data
var formData = new Dictionary<string, string>
{
{ "appid", _STEAM_APP_ID?.Value ?? ""},
{ "key", _STEAM_API_KEY?.Value ?? "" },
{ "orderid", orderid},
{ "steamid", steamUserData["steamid"]},
{ "itemcount",steamUserData["itemCount"] },
{ "language", steamUserData["language"] },
{ "currency", currency },
{ "itemid[0]", productData["itemId"] },
{ "qty[0]", productData["quantity"] },
{ "amount[0]", productData["amount"] },
{ "description[0]",productData["description"] }
};
var responseString = await Post(postUrl, formData);
return responseString;
}
// Steam FinalizeTxn Web API Request
/// <summary>
/// Wrapper for Steam FinalizeTxn Web API
/// https://partner.steamgames.com/doc/webapi/ISteamMicroTxn#FinalizeTxn
/// </summary>
/// <param name="orderid">The orderId for the order to Finalize</param>
/// <returns>Standard Steam WEB API response with OK or Failure</returns>
//
private async Task<string?> FinalizeTxn(string orderid)
{
string postUrl = "https://partner.steam-api.com/ISteamMicroTxnSandbox/FinalizeTxn/v2/";
var formData = new Dictionary<string, string>
{
{ "appid", _STEAM_APP_ID?.Value ?? ""},
{ "key", _STEAM_API_KEY?.Value ?? ""},
{ "orderid", orderid}
};
var responseString = await Post(postUrl, formData);
return responseString;
}
#endregion
#region PRIVATE_UTILITY_METHODS
// Generic Async POST Method
/// <summary>
/// Generic Async POST Method
/// </summary>
/// <param name="postUrl">The URL to POST to</param>
/// <param name="formData">The Key/Value pairs to Post as a Dictionary<string,string></param>
/// <returns>A string containing the response from the HTTP POST</returns>
private async Task<string?> Post(string postUrl, Dictionary<string, string> formData)
{
try
{
// Form Encode POST body
var content = new FormUrlEncodedContent(formData);
_logger.LogInformation($"POST Request Body: {content.ToString()}");
// Make API POST Request
var response = await httpClient.PostAsync(postUrl, content);
var responseString = await response.Content.ReadAsStringAsync();
_logger.LogInformation($"POST Response: {responseString}");
return responseString;
}
catch (HttpRequestException ex)
{ // Catches non-success status codes (e.g., 404 Not Found, 500 Internal Server Error)
_logger.LogError($"Http POST Request Excetion Caught - Code {ex.StatusCode} : {ex.Message}");
return null;
}
catch (Exception ex)
{
// Catch other unexpected errors
_logger.LogError($"Unexpected Exception Caught during POST : {ex.Message}");
return null;
}
}
// Generic Async GET Method
/// <summary>
/// Generic Async GET Method
/// </summary>
/// <param name="getUrl">The URL to GET from</param>
/// <param name="queryParams">The Key/Value pairs to GET as a Dictionary<string,string></param>
/// <returns>A string containing the response from the HTTP GET</returns>
private async Task<string?> Get(string getUrl, Dictionary<string, string> queryParams)
{
try
{
var uriBuilder = new UriBuilder(getUrl);
var query = HttpUtility.ParseQueryString(uriBuilder.Query);
foreach (var param in queryParams)
{
query[param.Key] = param.Value;
}
uriBuilder.Query = query.ToString();
string fullUrl = uriBuilder.ToString();
var response = await httpClient.GetAsync(fullUrl);
string responseString = await response.Content.ReadAsStringAsync();
_logger.LogInformation($"GetUserInfo Response: {responseString}");
return responseString;
}
catch (HttpRequestException ex)
{ // Catches non-success status codes (e.g., 404 Not Found, 500 Internal Server Error)
_logger.LogError($"Http GET Request Excetion Caught - Code {ex.StatusCode} : {ex.Message}");
return null;
}
catch (Exception ex)
{
// Catch other unexpected errors
_logger.LogError($"Unexpected Exception Caught during GET : {ex.Message}");
return null;
}
}
/// <summary>
/// Get a specific product from the productId provided in the Steam User Data from the client
/// </summary>
/// <param name="steamUserData">Steam User Data from the client</param>
/// <returns>A specific product as a Dictionary<string,string></returns>
private async Task<Dictionary<string, string>> GetProductFromSteamUserData(Dictionary<string, string> steamUserData)
{
Dictionary<string, string> emptyProduct = new Dictionary<string, string>();
// Return if productId not found
if (steamUserData == null || !steamUserData.ContainsKey("productId") || steamUserData["productId"] == null)
{
_logger.LogError("steamUserData or productId is null.");
return emptyProduct;
}
return await GetProduct(steamUserData["productId"]);
}
/// <summary>
/// Get a specific product based on its productId
/// </summary>
/// <param name="productId">The Id for the product to find</param>
/// <returns>A specific product as a Dictionary</returns>
private async Task<Dictionary<string, string>> GetProduct(string productId)
{
// Return if productId null or empty
if (string.IsNullOrEmpty(productId))
{
_logger.LogError("productId is null or empty.");
return new Dictionary<string, string>();
}
// Fetch the list of all products
List<Dictionary<string, string>> productList = await FetchProducts();
// Find the product with the matching productId
var product = productList.Find(x => x["productId"] == productId);
// Return an empty product if product not found
if (product == null || product.Count == 0)
{
_logger.LogError($"Product with ID '{productId}' not found (null)or (empty)..");
return new Dictionary<string, string>(); ;
}
else
{
// SUCCESS we have info on this productId, return the product
return product;
}
}
#endregion
}
SteamOrders.cs
namespace SteamPurchasing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Unity.Services.CloudCode.Apis;
using Unity.Services.CloudCode.Core;
using Unity.Services.CloudCode.Shared;
using Unity.Services.CloudSave.Model;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Linq;
using Newtonsoft.Json;
public class SteamOrders
{
// SteamOrders Sub Classes Contains
// - Order - A Data Class containing a single order.
// - OrderHistory - Class containing List of Orders
// - OrderState - Enum for Order States
#region SteamOrders sub classes
public enum OrderState
{
INITIALIZED,
PLAYER_AUTHORISED,
STEAM_FINALIZED_SUCCESS,
REDEEMED,
FAILED
}
public class Order
{ // NOTE : I am using string instead of ulong to prevent loss of precision during JSON serialization/desrialization
public string? orderId;
public string? transId;
public string? productId;
public string? state;
public DateTime dateCreated;
public DateTime dateUpdated;
public int? errorCode;
public string? errorDescription;
}
public class OrderHistory
{
public List<Order> orders;
public OrderHistory()
{
orders = new List<Order>();
}
}
#endregion
private readonly ILogger<SteamOrders> _logger;
public OrderHistory orderHistory = new OrderHistory();
private const string ORDER_HISTORY_KEY = "orderHistory";
// SteamOrders constructor using dependency injection for Logger
public SteamOrders(ILogger<SteamOrders> logger)
{
_logger = logger;
orderHistory = new OrderHistory();
}
// Generate an orderId for the transaction.
// Steam requires a unique 64-bit ID for order
// For the purposes of this demo, we will use the current timestamp in milliseconds
// But a production implementation will need something more robust
public string GenereateOrderId()
{
_logger.LogInformation("SteamOrders : Generating Order ID");
return ((ulong)(DateTime.UtcNow - new DateTime(1970, 1, 1)).TotalMilliseconds).ToString();
}
/// <summary>
/// Get a particular order from the player's order history, based on the provided orderId
/// </summary>
/// <param name="context">IExecutionContext interface containing extra information about the current call.</param>
/// <param name="gameApiClient">IGameApiClient interface provides a way to access the Cloud Code C# Client SDKs.</param>
/// <param name="orderId">The orderId to search for</param>
/// <returns>A specific order from the player's order history</returns>
public async Task<SteamOrders.Order?> GetOrder(IExecutionContext context, IGameApiClient gameApiClient, string orderId)
{
// Load the player's order history
SteamOrders.OrderHistory? orderHistory = await LoadOrderHistory(context, gameApiClient);
if (orderHistory != null)
{
// Find and returnthe order provided as an input
return orderHistory.orders.Find(x => x.orderId == orderId);
}
else
{
// or return null
_logger.LogError($"Get Order - Failed to find orderid {orderId}");
return null;
}
}
// Load Order History
/// <summary>
/// Loads the player's order history from Cloud Save.
/// </summary>
/// <param name="context">IExecutionContext interface containing extra information about the current call.</param>
/// <param name="gameApiClient">IGameApiClient interface provides a way to access the Cloud Code C# Client SDKs.</param>
/// <returns>Returns an instance of SteamOrders.OrderHistory containing a list of orders." </returns>
public async Task<SteamOrders.OrderHistory?> LoadOrderHistory(IExecutionContext context, IGameApiClient gameApiClient)
{
try
{
// Check that there is a PlayerId
if (string.IsNullOrEmpty(context.PlayerId))
{
_logger.LogError("PlayerId is null or empty. Cannot load order history.");
return new SteamOrders.OrderHistory();
}
// Load the order history
var result = await gameApiClient.CloudSaveData.GetItemsAsync(
context,
context.AccessToken,
context.ProjectId,
context.PlayerId,
new List<string> { ORDER_HISTORY_KEY }
);
// Check that the resulting string is not null or empty
// then Return the Order History or a new empty Order History
string? firstResultValue = result?.Data?.Results?.FirstOrDefault()?.Value?.ToString(); ;
if (!string.IsNullOrEmpty(firstResultValue))
{
_logger.LogInformation($"Loaded Order History: {firstResultValue}");
return JsonConvert.DeserializeObject<SteamOrders.OrderHistory>(firstResultValue);
}
else
{
_logger.LogWarning("No order history found for player, creating a new Order History.");
return new SteamOrders.OrderHistory();
}
}
catch (ApiException ex)
{
_logger.LogError($"Failed to get data for playerId {context.PlayerId}. Error: {ex.Message}");
return new SteamOrders.OrderHistory();
}
}
/// Save Order History
/// <summary>
/// Saves a single order to the player's order history then saves the order history
/// </summary>
/// <param name="context">IExecutionContext interface containing extra information about the current call.</param>
/// <param name="gameApiClient">IGameApiClient interface provides a way to access the Cloud Code C# Client SDKs.</param>
/// <param name="order">The Order to be saved</param>
/// <returns></returns>
public async Task SaveOrder(IExecutionContext context, IGameApiClient gameApiClient, SteamOrders.Order order)
{
// Attempt to load the player'sorder history
SteamOrders.OrderHistory? orderHistory = await LoadOrderHistory(context, gameApiClient);
if (orderHistory == null)
{
//Create a new order history if one does not exist
orderHistory = new SteamOrders.OrderHistory();
orderHistory.orders.Add(order);
}
else
{
// Or find the order provided as an input, and update it
int index = orderHistory.orders.FindIndex(x => x.orderId == order.orderId);
if (index >= 0)
{
orderHistory.orders[index] = order;
_logger.LogInformation($"Updated order {JsonConvert.SerializeObject(orderHistory.orders[index])}");
}
else
{
_logger.LogInformation($"Unable to update order {JsonConvert.SerializeObject(order)}");
}
}
// Save the player's order history to CloudSave
await SaveOrderHistory(context, gameApiClient, orderHistory);
}
/// <summary>
/// Saves the player's order history to CloudSave
/// </summary>
/// <param name="context">IExecutionContext interface containing extra information about the current call.</param>
/// <param name="gameApiClient">IGameApiClient interface provides a way to access the Cloud Code C# Client SDKs.</param>
/// <param name="orderHistory">The player's order history</param>
/// <returns></returns>
/// <exception cref="ArgumentNullException"></exception>
public async Task SaveOrderHistory(IExecutionContext context, IGameApiClient gameApiClient, SteamOrders.OrderHistory orderHistory)
{
// Check that there is a PlayerId
if (string.IsNullOrEmpty(context.PlayerId))
{
_logger.LogError("PlayerId is null or empty. Cannot save order history.");
throw new ArgumentNullException(nameof(context.PlayerId), "PlayerId cannot be null or empty.");
}
// Save the player's order history to CloudSave
await gameApiClient.CloudSaveData.SetItemAsync(
context,
context.AccessToken,
context.ProjectId,
context.PlayerId,
new SetItemBody("orderHistory", orderHistory)
);
_logger.LogInformation($"Saved Order History{JsonConvert.SerializeObject(orderHistory)}");
}
}
Cloud Code Editor Script
Back in the editor, create a script called UGS_CloudCode, this script connects your project to the cloud code module we created above.
The following methods call the Cloud Code functions deployed in the backend module:
FetchProducts()
- Calls the FetchProducts() Cloud Code function
- Retrieves the product catalog from the backend
- Returns: List<Dictionary<string, string>> containing product info
- Uses SteamPurchasingBindings (auto-generated by Unity Cloud Code SDK)
SteamInitPurchase(Dictionary<string, string> steamUserData)
- Calls the SteamInitTxn() Cloud Code function
- Initiates a Steam purchase transaction
- Passes Steam user data (Steam ID, language, product ID, quantity)
- Re-enables purchase button after completion (success or failure)
- Handles CloudCodeException errors gracefully
SteamFinalizePurchase(string orderId)
- Calls the SteamFinalizeTxn() Cloud Code function
- Finalizes a transaction after the player authorizes payment in Steam
- Passes the order ID received from Steam callback
- Re-enables purchase button after completion
- Handles errors and logs results
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using Unity.Services.CloudCode;
using Unity.Services.CloudCode.GeneratedBindings;
using Unity.Services.CloudCode.Subscriptions;
using Newtonsoft.Json;
public class UGS_CloudCode
{
public event Action<IMessageReceivedEvent> OnCloudCodeSubscriptionMessageReceived;
public async Task Init()
{
await SubscribeToPlayerMessages();
}
// This method creates a subscription to player messages and logs out the messages received,
// the state changes of the connection, when the player is kicked and when an error occurs.
Task SubscribeToPlayerMessages()
{
// Register callbacks, which are triggered when a player message is received
var callbacks = new SubscriptionEventCallbacks();
//Debug.Log("Subscribing to Cloud Code player Push messages");
callbacks.MessageReceived += @event =>
{
OnCloudCodeSubscriptionMessageReceived?.Invoke(@event);
//Debug.Log($"Cloud Code Push Message Received: {JsonConvert.SerializeObject(@event, Formatting.Indented)}");
};
callbacks.ConnectionStateChanged += @event =>
{
//Debug.Log($"Got player subscription ConnectionStateChanged: {JsonConvert.SerializeObject(@event, Formatting.Indented)}");
};
callbacks.Kicked += () =>
{
//Debug.Log($"Got player subscription Kicked");
};
callbacks.Error += @event =>
{
// Debug.Log($"Got player subscription Error: {JsonConvert.SerializeObject(@event, Formatting.Indented)}");
};
return CloudCodeService.Instance.SubscribeToPlayerMessagesAsync(callbacks);
}
public async Task<List<Dictionary<string, string>>> FetchProducts()
{
// var module = new IAPCustomStoreModuleBindings(CloudCodeService.Instance);
var module = new SteamPurchasingBindings(CloudCodeService.Instance);
var productList = await module.FetchProducts();
/*module.SayHello("Laurie").ContinueWith(result =>
{
if (result.IsFaulted)
{
Debug.LogException(result.Exception);
}
else
{
Debug.Log("woof" + result.Result);
}
});
var result = await steamPurchasingModule.WaveGoodbye("FuckOff");
Debug.Log("WaveGoodbe" + result);*/
return productList;
//return null;
}
public async Task SteamInitPurchase(Dictionary<string, string> steamUserData)
{
try
{
// Call the function within the module and provide the parameters we defined in there
var module = new SteamPurchasingBindings(CloudCodeService.Instance);
var result = await module.SteamInitTxn(steamUserData);
Debug.Log("woof" + result);
// TODO move this when purchase callbacks are all hooked up
GameManager.Instance.ui.SetPurchaseButtonVisibility(true);
}
catch (CloudCodeException exception)
{
Debug.LogException(exception);
// TODO move this when purchase callbacks are all hooked up
GameManager.Instance.ui.SetPurchaseButtonVisibility(true);
}
}
public async Task SteamFinalizePurchase(string orderId)
{
Debug.Log($"Custom Steam Store :<color=yellow> Finishing Transaction for orderId {orderId}</color>");
try
{
// Call the function within the module and provide the parameters we defined in there
var module = new SteamPurchasingBindings(CloudCodeService.Instance);
var result = await module.SteamFinalizeTxn(orderId);
Debug.Log(result);
// TODO move this when purchase callbacks are all hooked up
GameManager.Instance.ui.SetPurchaseButtonVisibility(true);
}
catch (CloudCodeException exception)
{
Debug.LogException(exception);
// TODO move this when purchase callbacks are all hooked up
GameManager.Instance.ui.SetPurchaseButtonVisibility(true);
}
}
}
IAP
Custom Store Wrapper
Create a script called SteamStoreWrapper.cs, this script is a wrapper for our in-App Purchase custom store to allow us to access the store details and connection state.
using UnityEngine.Purchasing;
using UnityEngine.Purchasing.Extension;
public class SteamStoreWrapper : IStoreWrapper
{
private readonly Store m_StoreInstance;
private readonly string m_StoreName;
public SteamStoreWrapper(string storeName, Store storeInstance)
{
m_StoreName = storeName;
m_StoreInstance = storeInstance;
}
/// <summary>
/// Gets the store instance
/// </summary>
public Store instance => m_StoreInstance;
/// <summary>
/// Gets the store name
/// </summary>
public string name => m_StoreName;
/// <summary>
/// Gets the connection state of the store
/// </summary>
public ConnectionState GetStoreConnectionState()
{
// You may need to track this in your SteamStore implementation
// For now, return a default state
return ConnectionState.Connected;
}
}
UGS IAP
Create a script called UGS_IAP, This script is slightly longer as it manages in-app purchases on Steam through Unity's IAP v5 system. The flow of this script is shown below.
Initialization
- Registers a custom Steam store implementation
- Sets Steam as the default store
- Gets a StoreController instance
- Subscribes to purchase/product events
- Connects to the Steam store asynchronously
Store Registration
- Creates a custom SteamStore instance
- Wraps it in a SteamStoreWrapper
- Registers it with Unity's IAP services
Event Handling
- Subscribes to multiple event types:
- Connection: OnStoreConnected, OnStoreDisconnected
- Products: OnProductsFetched, OnProductsFetchFailed
- Purchases: OnPurchasePending, OnPurchaseConfirmed, OnPurchaseFailed
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.Purchasing;
public class UGS_IAP
{
private StoreController controller;
private bool storeIsInitialized = false;
private const string STEAM_STORE_NAME = "Steam";
public async void Init()
{
Debug.Log("UGS IAP: Starting initialization...");
try
{
RegisterSteamStore();
UnityIAPServices.SetStoreAsDefault(STEAM_STORE_NAME);
Debug.Log($"UGS IAP v5: Steam store set as default. Current default: {UnityIAPServices.GetDefaultStore()}");
// Get StoreController instance from UnityIAPServices
controller = UnityIAPServices.StoreController();
// Subscribe to all required events
SubscribeToEvents();
Debug.Log("UGS IAP v5: Connecting to Steam store...");
await controller.Connect();
}
catch (System.Exception e)
{
Debug.LogError($"UGS IAP v5: Initialization failed - {e.Message}");
}
}
private void RegisterSteamStore()
{
Debug.Log("UGS IAP v5: Registering custom Steam store...");
// Create your custom Steam store instance
var steamStore = new SteamStore();
// Wrap it
var steamStoreWrapper = new SteamStoreWrapper(STEAM_STORE_NAME, steamStore);
// Register it
UnityIAPServices.AddNewCustomStore(steamStoreWrapper);
Debug.Log("UGS IAP v5: <color=green>Steam store registered successfully</color>");
}
private void SubscribeToEvents()
{
// Connection events
controller.OnStoreConnected += OnStoreConnected;
controller.OnStoreDisconnected += OnStoreDisconnected;
// Product events
controller.OnProductsFetched += OnProductsFetched;
controller.OnProductsFetchFailed += OnProductsFetchFailed;
// Purchase events
controller.OnPurchasePending += OnPurchasePending;
controller.OnPurchaseConfirmed += OnPurchaseConfirmed;
controller.OnPurchaseFailed += OnPurchaseFailed;
}
public void Purchase(string productId)
{
if (!storeIsInitialized)
{
Debug.LogError("UGS IAP v5: <color=red>Cannot purchase - Store not initialized</color>");
return;
}
if (controller == null)
{
Debug.LogError("UGS IAP v5: <color=red>Cannot purchase - StoreController is null</color>");
return;
}
Debug.Log($"UGS IAP v5: Initiating purchase for {productId}");
// Initiate the purchase
// This will eventually trigger OnPurchasePending or OnPurchaseFailed
controller.PurchaseProduct(productId);
}
#region Store Connection Events
private void OnStoreConnected()
{
Debug.Log("UGS IAP v5: <color=green>Store Connected Successfully</color>");
// Now fetch products
FetchProducts();
}
/// <summary>
/// Called when store connection fails
/// </summary>
private void OnStoreDisconnected(StoreConnectionFailureDescription description)
{
Debug.Log($"UGS IAP v5: <color=red>Store Disconnected</color>");
Debug.Log($" Message: {description.message}");
storeIsInitialized = false;
}
#endregion
#region Product Fetching
private void FetchProducts()
{
var productDefinitions = new List<ProductDefinition>
{
new ProductDefinition("COINS_500", ProductType.Consumable),
new ProductDefinition("COINS_1000", ProductType.Consumable)
};
Debug.Log($"UGS IAP v5: Fetching {productDefinitions.Count} products...");
controller.FetchProducts(productDefinitions);
}
private void OnProductsFetched(List<Product> products)
{
Debug.Log($"UGS IAP v5: <color=green>Products Fetched Successfully</color> - {products.Count} products");
foreach (var product in products)
{
Debug.Log($" Product: {product.definition.id}");
Debug.Log($" Title: {product.metadata.localizedTitle}");
Debug.Log($" Price: {product.metadata.localizedPriceString}");
Debug.Log($" Type: {product.definition.type}");
}
storeIsInitialized = true;
}
private void OnProductsFetchFailed(ProductFetchFailed failure)
{
Debug.Log($"UGS IAP v5: <color=red>Product Fetch Failed</color>");
Debug.Log($" Reason: {failure.FailureReason}");
Debug.Log($" Failed products: {failure.FailedFetchProducts.Count}");
foreach (var productDef in failure.FailedFetchProducts)
{
Debug.Log($" - {productDef.id}");
}
storeIsInitialized = false;
}
#endregion
#region Purchase Flow
private void OnPurchasePending(PendingOrder order)
{
var product = GetFirstProductInOrder(order);
if (product == null)
{
Debug.LogError("UGS IAP v5: <color=red>No product found in pending order</color>");
return;
}
Debug.Log($"UGS IAP v5: <color=yellow>Purchase Pending</color> - {product.definition.id}");
Debug.Log($"UGS IAP v5: Confirming purchase for {product.definition.id}");
controller.ConfirmPurchase(order);
}
private void OnPurchaseConfirmed(Order order)
{
switch (order)
{
case ConfirmedOrder confirmedOrder:
HandleConfirmedOrder(confirmedOrder);
break;
case FailedOrder failedOrder:
HandleConfirmationFailed(failedOrder);
break;
default:
Debug.LogWarning("UGS IAP v5: Unknown order type in OnPurchaseConfirmed");
break;
}
}
private void HandleConfirmedOrder(ConfirmedOrder order)
{
var product = GetFirstProductInOrder(order);
Debug.Log($"UGS IAP v5: <color=green>Purchase Confirmed Successfully</color>");
Debug.Log($" Product: {product?.definition.id}");
Debug.Log($" Transaction complete!");
}
private void HandleConfirmationFailed(FailedOrder order)
{
var product = GetFirstProductInOrder(order);
Debug.LogError($"UGS IAP v5: <color=red>Purchase Confirmation Failed</color>");
Debug.LogError($" Product: {product?.definition.id}");
Debug.LogError($" Reason: {order.FailureReason}");
Debug.LogError($" Details: {order.Details}");
}
private void OnPurchaseFailed(FailedOrder order)
{
var product = GetFirstProductInOrder(order);
Debug.LogError($"UGS IAP v5: <color=red>Purchase Failed</color>");
Debug.LogError($" Product: {product?.definition.id}");
Debug.LogError($" Reason: {order.FailureReason}");
Debug.LogError($" Details: {order.Details}");
}
#endregion
#region Helper Methods
private Product GetFirstProductInOrder(Order order)
{
return order.CartOrdered.Items().First()?.Product;
}
#endregion
}
SteamStore
Create another new script called SteamStore, This extends Unity's Store to create a bridge between Unity IAP and Steam's Steamworks API, using a backend Cloud Code as middleware. The flow of this script is shown below.
Store Connection
- Verifies that Steam is initialized via SteamManager
- Reports success/failure to Unity IAP via callbacks
Product Fetching
- Retrieves product catalog from a backend service (Cloud Code) asynchronously
- Converts backend product data into Unity IAP ProductDescription objects
- Maps fields like product ID, name, description, price, and currency code
- Returns the product list to Unity IAP via ProductsCallback
Purchase Flow
- Takes the first item from the shopping cart
- Collects Steam user information:
- Steam ID
- User's language preference
- Product ID and quantity
- Sends this data to the backend via the Game Manager script
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using UnityEngine;
using UnityEngine.Purchasing;
using UnityEngine.Purchasing.Extension;
using Steamworks;
public class SteamStore : Store
{
public const string Name = "SteamStore";
//private StoreCallback storeCallback;
#region IStore Implementation
public override void Connect()
{
Debug.Log("Custom Steam Store v5: <color=yellow>Connecting...</color>");
// Initialize your Steam store backend connection here
// For example, verify Steam is initialized, check user login, etc.
// When connection is successful, notify the controller
if (SteamManager.Initialized)
{
Debug.Log("Custom Steam Store v5: <color=green>Connected</color>");
ConnectCallback.OnStoreConnectionSucceeded();
}
else
{
Debug.LogError("Custom Steam Store v5: <color=red>Connection Failed</color> - Steam not initialized");
}
}
public override void FetchProducts(IReadOnlyCollection<ProductDefinition> products)
{
Debug.Log($"Custom Steam Store v5: <color=yellow>Fetching {products.Count} products...</color>");
// Asynchronously fetch products from your backend
FetchProductsAsync(products);
}
public override void FetchPurchases()
{
//Debug.Log("Custom Steam Store v5: <color=yellow>Fetching existing purchases...</color>");
//FetchPurchasesAsync();
}
public override void Purchase(ICart cart)
{
// Get the first item from the cart
var item = cart.Items().FirstOrDefault();
if (item == null)
{
Debug.LogError("Custom Steam Store v5: <color=red>Empty cart</color>");
var failedOrder = new FailedOrder(
cart,
PurchaseFailureReason.Unknown,
"Cart is empty"
);
PurchaseCallback?.OnPurchaseFailed(failedOrder);
return;
}
var product = item.Product;
Debug.Log($"Custom Steam Store v5: <color=yellow>Purchasing {product.definition.id}...</color>");
// Asynchronously process the purchase
ProcessPurchaseAsync(cart, product);
}
public override void FinishTransaction(PendingOrder pendingOrder)
{
// Perform transaction related housekeeping
// TODO - still to be completed
//Debug.Log($"Custom Steam Store :<color=yellow> Finishing Transaction for Product {product.id}</color>");
}
public override void CheckEntitlement(ProductDefinition product)
{
Debug.Log($"Custom Steam Store v5: <color=yellow>Checking entitlement for {product.id}...</color>");
//CheckEntitlementAsync(product);
}
#endregion
#region Asynchronous_Store_Backend_Calls
// Asynchronous Product Retrieval from Cloud Code
private async void FetchProductsAsync(IReadOnlyCollection<ProductDefinition> productDefinitions)
{
//List<ProductDescription> productDescriptions = new List<ProductDescription>();
List<ProductDescription> productDescriptions = new List<ProductDescription>();
// Fetch product information and invoke callback.OnProductsRetrieved();
try
{
// Fetch products from your backend (Cloud Code in this case)
var backendProducts = await GameManager.Instance.ugsCloudCode.FetchProducts();
Debug.Log($"Custom Steam Store v5: Retrieved {backendProducts.Count} products from backend");
// Convert backend products to Unity IAP Product objects
foreach (Dictionary<string, string> backendProduct in backendProducts)
{
string productId = backendProduct["productId"];
// Find the matching product definition
var productDef = productDefinitions.FirstOrDefault(p => p.id == productId);
if (productDef != null)
{
// Create product metadata
var productMetadata = new ProductMetadata(
backendProduct["amount"], // priceString
backendProduct["name"], // title
backendProduct["description"], // description
backendProduct["unityCurrencyId"], // isoCurrencyCode
(decimal)Convert.ToDouble(backendProduct["amount"]) // localizedPrice
);
// Create Product object
productDescriptions.Add(new ProductDescription(
backendProduct?["productId"],
productMetadata
));
Debug.Log($"Custom Steam Store v5: Product {productId} - {backendProduct["name"]} ({backendProduct["amount"]})");
}
else
{
Debug.LogWarning($"Custom Steam Store v5: Product {productId} not found in request");
}
}
// Notify success
}
catch (Exception e)
{
Debug.Log($"UGS_IAP:RetrieveProductsAsync - Exception {e.Message}");
}
ProductsCallback?.OnProductsFetched(productDescriptions);
}
private async void ProcessPurchaseAsync(ICart cart, Product product)
{
// Start the purchase flow and call either callback.OnPurchaseSucceeded() or callback.OnPurchaseFailed()
Debug.Log($"Custom Steam Store :<color=yellow> Purchasing Product {product.definition.id}</color>");
string twoLetterISOLanguageName = CultureInfo.CurrentCulture.TwoLetterISOLanguageName != null ? CultureInfo.CurrentCulture.TwoLetterISOLanguageName : "en";
Dictionary<string, string> SteamUserData = new Dictionary<string, string>
{
{ "steamid", SteamUser.GetSteamID().ToString()},
{ "language", twoLetterISOLanguageName},
{ "productId", product.definition.id},
{ "itemCount", "1"}
};
foreach (KeyValuePair<string, string> item in SteamUserData)
{
Debug.Log($"<color=yellow>SteamUserData - {item.Key} : {item.Value}</color>");
}
// TODO move the Cloud Code module out of the GameManager
await GameManager.Instance.ugsCloudCode.SteamInitPurchase(SteamUserData);
}
#endregion
}
Game Manager
Finally, we will create the GameManager script, this is essentially the "brain" of the application that connects all the other scripts and the backend to one place. The flow of this script is shown below.
Initializes UGS with "development" environment
- Sequentially initializes services in specific order:
- Authentication → Economy → Cloud Code
- Then Steam, Analytics, and IAP
- Subscribes to Cloud Code messages for real-time updates
- Updates UI and shows purchase buttons
Cloud Code Message Handling (lines 97-120):
- Listens for STEAM_PURCHASING messages
- When PURCHASE_SUCCESSFUL is received, refreshes the UI and re-enables purchase buttons
UI Updates
- Displays build version, Steam username, Player ID
- Fetches and displays coin balance from Economy service
Purchase Flow (lines 144-152):
- Disables buttons to prevent duplicate purchases
- Delegates to UGS_IAP to process the purchase
using UnityEngine;
using Unity.Services.Core;
using Unity.Services.Core.Environments;
using Steamworks;
using System.Collections.Generic;
using Unity.Services.CloudCode.Subscriptions;
using Unity.VisualScripting;
using System.Threading.Tasks;
[RequireComponent(typeof(SteamManager))]
[RequireComponent(typeof(Steam))]
public class GameManager : MonoBehaviour
{
// Game Manager is a Singleton
public static GameManager Instance { get; private set; }
// Various Unity Gaming Services
public UGS_Authentication ugsAuth;
public UGS_IAP ugsIAP;
public UGS_CloudCode ugsCloudCode;
public UGS_Economy ugsEconomy;
// All the Steam stuff is in here
public Steam steam;
// The UI Canvas
[SerializeField] public UI ui;
// Instanstiate Game Manager Instance and Unity Gaming Services on Awake.
void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(this);
}
else
{
Instance = this;
ugsAuth = new UGS_Authentication();
ugsCloudCode = new UGS_CloudCode();
ugsEconomy = new UGS_Economy();
ugsIAP = new UGS_IAP();
steam = GetComponent<Steam>();
}
}
// Start is called once before the first execution of Update after the MonoBehaviour is created
async void Start()
{
ui.SetPurchaseButtonVisibility(false);
// Initialize Unity Gaming Services
var options = new InitializationOptions();
options.SetEnvironmentName("development");
await UnityServices.InitializeAsync(options);
Debug.Log($"UGS State :: <color=green>{UnityServices.Instance.State}</color>");
// Initialize the various services / components we will are using. Doing it this way to
// strictly control order of initialization rather than putting them all on a Monobehaviour Start
await ugsAuth.Init();
await ugsEconomy.Init();
await ugsCloudCode.Init();
steam.Init();
ugsIAP.Init();
// Subscribe to Cloud Code Message Events
ugsCloudCode.OnCloudCodeSubscriptionMessageReceived += OnCloudCodeSubscriptionMessageReceived;
UpdateUI();
ui.SetPurchaseButtonVisibility(true);
}
void OnDestroy()
{
// Unsubscribe to Cloud Code Message Events
ugsCloudCode.OnCloudCodeSubscriptionMessageReceived -= OnCloudCodeSubscriptionMessageReceived;
}
private void OnCloudCodeSubscriptionMessageReceived(IMessageReceivedEvent messageReceivedEvent)
{
switch (messageReceivedEvent.MessageType)
{
case "STEAM_PURCHASING":
switch (messageReceivedEvent.Message)
{
case "PURCHASE_SUCCESSFUL":
Debug.Log("PURCHASE_SUCCESSFUL - Updating UI");
ui.SetPurchaseButtonVisibility(true);
UpdateUI();
break;
default:
Debug.Log("Unknown Cloud Code STEAM_PURCHAING Message Received");
break;
}
break;
default:
Debug.Log("Unknown Cloud Code Message Received.");
break;
}
}
// Update various UI components to provide user feedback.
private async void UpdateUI()
{
// Display Build Number in UI
ui.BuildNumber.text = Application.version;
// Display Steam Name in UI
ui.SteamName.text = $"Steam Name: {SteamFriends.GetPersonaName()}";
// Display PlayerID in UI
ui.PlayerId.text = $"Player ID: {ugsAuth.PlayerId.ToString()}";
// Fetch Coin Balanace and update UI
long coins = await ugsEconomy.GetCoinBalance();
ui.Coins.text = coins.ToString();
}
// Handle Purchase button clicks
public void PurchaseButton_Clicked(string productId)
{
// Hide Purchase Buttons to prevent multiple concurrent purchases.
ui.SetPurchaseButtonVisibility(false);
// Make the purchase
ugsIAP.Purchase(productId);
}
}
Testing
The Steam Overlay only injects itself when a game is launched from the Steam library. Launching your game directly from the Unity Editor or by double-clicking a standalone build often won't enable the overlay for microtransaction prompts. You will still be able to make the calls to cloud code and retrieve products but you will not be able to authorize the transaction to complete the purchase flow from the editor. To test this correctly, make sure to create a development build and upload it to Steam.