Server SDKs
C# SDK
Install the GameCoin SDK for .NET and use it from your game backend to manage wallets, the ledger, inventory, checkouts and refunds.
On this page
The C# SDK is the server SDK for backends written for .NET: ASP.NET, a worker service, a dedicated game server. It holds your secret key, so it belongs on your server, never in a browser or a game client. For the browser, use the browser SDK. Not sure which SDK you need? Start with Server SDKs.
Install
Note
The SDK is a pre-release (version 0.1.0) and is not published yet: it is not on NuGet. The package name below is the planned one and may change before the first release.
dotnet add package Apilow.GameCoinThe package targets net8.0 and netstandard2.1, so it also works with .NET 8 and with runtimes that support .NET Standard 2.1, such as the tooling of Unity. On .NET 8 it has no dependency (HttpClient and System.Text.Json are built in). On .NET Standard 2.1 its only dependency is System.Text.Json.
using Apilow.GameCoin;The examples on this page are top-level statements, so await works as is, and they rely on the implicit usings of the default console template (System, System.Linq, System.Threading.Tasks…).
Create the client
Create one client per game and environment, keep it, and share it: it is thread-safe and reuses its connections. In an ASP.NET application, register it as a singleton. The key decides both: gc_sk_test_… talks to the test environment, gc_sk_live_… to the live one. Read it from an environment variable or a secret store, never from your code.
using var gamecoin = new GameCoinClient(
Environment.GetEnvironmentVariable("GAMECOIN_SECRET_KEY")!,
new GameCoinOptions { BaseUrl = "https://gamecoin.apilow.com" });
Console.WriteLine(gamecoin.Environment); // test or live, read from the keyhttps://gamecoin.apilow.com is the address of this service, which is also the default (GameCoinClient.DefaultBaseUrl): you only need BaseUrl to point the SDK at another address. Every option is optional:
| Option | Default | Meaning |
|---|---|---|
BaseUrl | https://gamecoin.apilow.com | Origin of the service. The SDK appends /api/v1, drops trailing slashes and keeps a path prefix. Only http and https are accepted. |
Timeout | 30 seconds | A TimeSpan for each HTTP attempt: a retry gets a fresh one. |
MaxRetries | 2 | Retries after the first attempt, from 0 to 10: three attempts by default. 0 turns retries off. |
HttpClient | created for you | Your own HttpClient, for instance one from IHttpClientFactory. The SDK never touches its default headers and never disposes it. |
HttpMessageHandler | none | A handler that the SDK wraps in its own HttpClient: tests, proxies, logging. It is not disposed either. See Testing your integration. |
HttpClient and HttpMessageHandler are mutually exclusive.
using var tuned = new GameCoinClient(
Environment.GetEnvironmentVariable("GAMECOIN_SECRET_KEY")!,
new GameCoinOptions
{
BaseUrl = "https://gamecoin.apilow.com",
Timeout = TimeSpan.FromSeconds(10), // give up on an attempt after 10 seconds
MaxRetries = 3, // retry up to three times, four attempts in all
});
Console.WriteLine(tuned.Environment);The constructor refuses anything that is not a secret key, before any request, with an ArgumentException. A publishable key (gc_pk_…) gets a message that points to the browser SDK. The key is trimmed, so a trailing newline from a file does no harm. It never appears in an exception, and ToString() of the client shows its prefix only (gc_sk_test_…). The client is IDisposable: it disposes only the HttpClient it created itself.
Players and the PlayerRef helper
A player is a GameCoin player id, or your own id through PlayerRef.Ext():
var player = PlayerRef.Ext("user-42");
Console.WriteLine(player); // ext:user-42Any string works as your id (spaces, slashes, % and accents included): the SDK percent-encodes the whole reference exactly once. With a secret key, a write on an unknown ext: player creates it, so there is no registration step. A read of an unknown player fails with NOT_FOUND. To name a player by its GameCoin id, use PlayerRef.FromId(id): a plain string converts to it implicitly.
var profile = await gamecoin.Players.UpsertAsync(player, new PlayerProfile { DisplayName = "Alice", Country = "BE" });
var same = await gamecoin.Players.GetAsync(player);
Console.WriteLine($"{profile.Id == same.Id} {profile.ExternalId} {profile.Kind}"); // True user-42 external
// A one-hour token for the browser SDK of your game client
var token = await gamecoin.Players.CreateTokenAsync(player);
Console.WriteLine($"{token.Token.StartsWith("gc_pt_")} {token.ExpiresAt > DateTimeOffset.UtcNow}"); // True TrueIn a PlayerProfile, a null property is left unchanged. To clear a field, list it in Clear:
var cleared = await gamecoin.Players.UpsertAsync(player, new PlayerProfile { Clear = PlayerProfileFields.DisplayName });
Console.WriteLine($"{cleared.DisplayName ?? "null"} {cleared.Country}"); // null BEWallet
A balance has two buckets: Paid (bought with real money) and Bonus (granted or earned). A grant always credits Bonus. A spend takes from Bonus first, then from Paid, unless your game reverses that order in its settings.
var granted = await gamecoin.Wallet.GrantAsync(player, new GrantRequest { Currency = "gems", Amount = 100, Reason = "welcome_gift" });
Console.WriteLine($"{granted.Balance.Total} {granted.Balance.Currency}"); // 100 gems
var spent = await gamecoin.Wallet.SpendAsync(player, new SpendRequest { Currency = "gems", Amount = 30, Reason = "revive" });
Console.WriteLine($"{spent.Balance.Total} {spent.Entry.Type}"); // 70 spendAmount is a long of at least 1. Reason is a short text and Metadata a dictionary of strings: both are recorded on the ledger entry.
var reward = await gamecoin.Wallet.GrantAsync(player, new GrantRequest
{
Currency = "gold",
Amount = 25,
Reason = "daily_reward",
Metadata = new Dictionary<string, string> { ["day"] = "3" },
});
Console.WriteLine($"{reward.Entry.Type} {reward.Entry.BonusDelta} {reward.Balance.Total}"); // grant 25 25Wallet.GetAsync returns one balance per active currency, zeros included:
var wallet = await gamecoin.Wallet.GetAsync(player);
foreach (var balance in wallet.Balances)
{
Console.WriteLine($"{balance.Currency} {balance.Paid} {balance.Bonus} {balance.Total}");
}
// gems 0 70 70
// gold 0 25 25Ledger
The ledger is the immutable history of every change to a wallet, newest first. Ledger.ListAsync returns one page and a cursor; Limit is the page size, from 1 to 100 (20 by default).
var page = await gamecoin.Ledger.ListAsync(player, new LedgerQuery { Currency = "gems", Limit = 1 });
Console.WriteLine($"{page.Entries.Count} {page.NextCursor is not null}"); // 1 True
var older = await gamecoin.Ledger.ListAsync(player, new LedgerQuery { Currency = "gems", Limit = 1, Cursor = page.NextCursor });
Console.WriteLine(older.Entries[0].Type); // grantNextCursor is null on the last page. To avoid handling cursors yourself, iterate: Ledger.IterateAsync is an IAsyncEnumerable<LedgerEntry> that fetches each page only when the loop reaches it, so you can stop early.
await foreach (var entry in gamecoin.Ledger.IterateAsync(player, new LedgerQuery { Currency = "gems", Limit = 50 }))
{
Console.WriteLine($"{entry.CreatedAt:u} {entry.Type} {entry.PaidDelta + entry.BonusDelta} {entry.BalanceAfter.Total}");
if (entry.Type == LedgerEntryType.Grant)
{
break; // no further page is fetched
}
}Inventory and purchases
Items are consumable (they can be consumed) or durable (they cannot), and may have a maximum per player. Catalog.GetAsync lists the active currencies, items and packs of your game.
var catalog = await gamecoin.Catalog.GetAsync();
Console.WriteLine(string.Join(", ", catalog.Items.Select(item => $"{item.Sku} ({item.Type})"))); // potion (consumable), fire-sword (durable)var given = await gamecoin.Inventory.GrantAsync(player, new InventoryGrantRequest { Sku = "potion", Quantity = 3 });
Console.WriteLine(given.Quantity); // 3
var used = await gamecoin.Inventory.ConsumeAsync(player, new InventoryConsumeRequest { Sku = "potion" }); // Quantity defaults to 1
Console.WriteLine(used.Quantity); // 2
foreach (var item in await gamecoin.Inventory.ListAsync(player)) // owned items only (quantity above 0)
{
Console.WriteLine($"{item.Sku} x{item.Quantity}"); // potion x2
}Inventory.ConsumeAsync returns the item even when it reaches 0. Purchases.CreateAsync buys an item with its price in currency, atomically: the wallet is debited and the item added, or nothing happens.
var purchase = await gamecoin.Purchases.CreateAsync(player, new PurchaseRequest { Sku = "potion" });
Console.WriteLine($"{purchase.Balance.Total} {purchase.Item.Quantity} {purchase.Entry.Ref.ItemSku}"); // 50 3 potionAn item can limit how many a player owns. Going beyond fails with LIMIT_REACHED:
await gamecoin.Inventory.GrantAsync(player, new InventoryGrantRequest { Sku = "fire-sword" });
try
{
await gamecoin.Inventory.GrantAsync(player, new InventoryGrantRequest { Sku = "fire-sword" }); // limited to one per player
}
catch (GameCoinException ex)
{
Console.WriteLine(ex.Code == GameCoinErrorCodes.LimitReached); // True
}Checkout, orders and refunds
A checkout sells a currency pack for real money through a hosted payment page. You create the order, send the player to CheckoutUrl, and the coins and items are delivered when the payment succeeds.
var checkout = await gamecoin.Checkouts.CreateAsync(player, new CheckoutRequest
{
PackSku = "gems-500",
SuccessUrl = "https://example.com/shop/thanks", // where the player goes after paying
CancelUrl = "https://example.com/shop",
Country = "BE", // the buyer's country decides the VAT (EU countries only)
});
Console.WriteLine($"{checkout.Order.Status} {checkout.CheckoutUrl.StartsWith("http")}"); // pending True
var order = await gamecoin.Orders.GetAsync(checkout.Order.Id);
Console.WriteLine($"{order.Status} {order.AmountCents} {order.VatCents} {order.NetCents}"); // pending 499 87 412The test environment simulates payments: the hosted page offers « Payer » and « Refuser » buttons (it is in French for now), and no money moves. Real payments are not available yet. After a payment the order goes from pending to paid to fulfilled, and the coins and items are delivered. Poll Orders.GetAsync, or wait for the player to come back to your SuccessUrl, before telling them it worked. The statuses are the constants of OrderStatus.
Orders.RefundAsync takes the coins and items back from a paid order and returns the order, with status refunded. What a refund does to coins that the player already spent is explained in Refunds. An order that was never paid answers CONFLICT:
try
{
await gamecoin.Orders.RefundAsync(order.Id, new RefundRequest { Reason = "player request" });
}
catch (GameCoinException ex) when (ex.Code == GameCoinErrorCodes.Conflict)
{
Console.WriteLine($"not refundable: {ex.Message}");
}Warning
A refund has no idempotency key, so the SDK retries it on 429 only. After a network error, a timeout or a 5xx the call fails and the outcome is unknown: read the order back with Orders.GetAsync before you try again.
Idempotency and retries
Every call that changes something (Wallet.GrantAsync, Wallet.SpendAsync, Inventory.GrantAsync, Inventory.ConsumeAsync, Purchases.CreateAsync, Checkouts.CreateAsync) sends an Idempotency-Key. By default the SDK generates a UUID v4 once per call and reuses it for every retry of that call, so a retry can never apply a change twice.
Pass your own key in a RequestOptions to make a retry of your code safe too, for instance when a job may run twice. Use 1 to 100 printable ASCII characters, derived from the event (here a quest completion). The result tells whether the answer came from an earlier request:
var options = new RequestOptions { IdempotencyKey = "quest-17:user-42" };
var grantRequest = new GrantRequest { Currency = "gems", Amount = 10 };
var first = await gamecoin.Wallet.GrantAsync(player, grantRequest, options);
var again = await gamecoin.Wallet.GrantAsync(player, grantRequest, options);
Console.WriteLine($"{first.Replayed} {again.Replayed} {first.Entry.Id == again.Entry.Id}"); // False True TrueThe same key with a different request is refused with IDEMPOTENCY_CONFLICT. Keys are kept for 30 days. A replayed checkout returns the order in its current state. The rules behind this are on the Idempotency page.
Automatic retries run at most MaxRetries times (twice by default):
| Failure | Retried |
|---|---|
Network error, timeout, 5xx | Yes (not for Orders.RefundAsync) |
429 | Yes, after the Retry-After delay |
Any other 4xx | Never |
| The call was cancelled | Never: it throws OperationCanceledException |
The wait is 0.5 s × 2^attempt, with a random jitter of ±25 %. A Retry-After header wins, up to 30 seconds. Beyond that nothing is retried and the exception carries RetryAfter, so that you decide. Every read, Players.UpsertAsync and Players.CreateTokenAsync follow the same rules as the changes above.
Every call takes a CancellationToken, as its last argument. Cancelling it cancels the call and any retry in progress. Use a token with a deadline to bound a whole call, retries included, whereas Timeout bounds one attempt:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var latest = await gamecoin.Wallet.GetAsync(player, cts.Token);
Console.WriteLine(latest.Balances.Count); // 2Errors
Anything that goes wrong with a request is a GameCoinException:
| Property | Content |
|---|---|
Code | The API code (INSUFFICIENT_FUNDS, NOT_FOUND…), or NETWORK_ERROR or TIMEOUT (status 0), or INVALID_RESPONSE when the body is not the expected JSON. |
Status | The HTTP status, 0 when no response was received. |
Message | The message of the API, or a short description. Written for developers, not for your players. |
Details | The details of the API as an IReadOnlyDictionary<string, JsonElement> (required, available, fieldErrors…), empty when absent. |
RetryAfter | A TimeSpan? (the header is in seconds), set when the API sent Retry-After. |
try
{
await gamecoin.Wallet.SpendAsync(player, new SpendRequest { Currency = "gems", Amount = 1_000_000 });
}
catch (GameCoinException ex) when (ex.Code == GameCoinErrorCodes.InsufficientFunds)
{
Console.WriteLine($"needs {ex.Details["required"].GetInt64()}, has {ex.Details["available"].GetInt64()}"); // needs 1000000, has 60
}
catch (GameCoinException ex) when (ex.Code == GameCoinErrorCodes.RateLimited)
{
Console.WriteLine($"try again in {ex.RetryAfter}");
}The codes are constants of GameCoinErrorCodes: ValidationFailed, Unauthenticated, NotFound and so on. These are the ones you will meet:
| HTTP | Code | When |
|---|---|---|
| 400 | ValidationFailed | A field is invalid (Details["fieldErrors"]). |
| 401 | Unauthenticated | The secret key is missing, unknown or revoked. |
| 402 | PaymentFailed | The payment was refused, or no payment provider exists for this environment. |
| 403 | Forbidden | This kind of key is not allowed here. |
| 403 | PlayerBlocked | The player is blocked. |
| 403 | EnvNotEnabled | The live environment is used by a studio that is not verified. |
| 404 | NotFound | Unknown player, currency, item, pack or order. |
| 409 | InsufficientFunds | The wallet or the item quantity is too low (required and available in Details). |
| 409 | LimitReached | maxOwned, maxPerPlayer or the balance ceiling is exceeded. |
| 409 | IdempotencyConflict | The key was reused with another request. |
| 409 | Conflict | Invalid state change, such as refunding an unpaid order. |
| 429 | RateLimited | Too many requests (RetryAfter). |
| 500 | Internal | Unexpected error on the GameCoin side. |
| 0 | NetworkError, Timeout | No answer. The SDK has already retried. |
| any | InvalidResponse | The body is not the expected JSON. |
IdempotencyKeyRequired also exists, but the SDK always sends a key. See Errors for what each code means for your game.
Arguments that are wrong on their face never reach the network. They throw an ArgumentException (an ArgumentOutOfRangeException for a number), not a GameCoinException, and the message never contains the offending value:
try
{
await gamecoin.Wallet.GrantAsync(player, new GrantRequest { Currency = "gems", Amount = -5 });
}
catch (ArgumentException ex)
{
Console.WriteLine(ex.GetType().Name); // ArgumentOutOfRangeException
}Testing your integration
The HTTP layer is yours to replace: give HttpMessageHandler a handler of your own, and your tests need no network and no mocking library. A response that carries Retry-After: 0 is retried at once, so this example does not wait:
using System.Net;
using System.Net.Http.Headers;
public sealed class FlakyHandler : HttpMessageHandler
{
public List<string> Keys { get; } = new();
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Keys.Add(request.Headers.GetValues("Idempotency-Key").Single());
if (Keys.Count < 3)
{
var failure = new HttpResponseMessage(HttpStatusCode.BadGateway) { Content = new StringContent("Bad gateway") };
failure.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.Zero);
return Task.FromResult(failure);
}
const string body = """{"item": {"sku": "potion", "quantity": 1, "updatedAt": "2026-10-05T12:00:00.000Z"}}""";
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) });
}
}var handler = new FlakyHandler();
using var offline = new GameCoinClient("gc_sk_test_" + new string('x', 40), new GameCoinOptions { HttpMessageHandler = handler });
var gift = await offline.Inventory.GrantAsync(player, new InventoryGrantRequest { Sku = "potion" });
Console.WriteLine($"{gift.Quantity} {handler.Keys.Count} {handler.Keys.Distinct().Count()}"); // 1 3 1Two failures and a success: three attempts, and a single idempotency key sent three times. To turn the retries off in a test, set MaxRetries = 0.
To test against the real service without touching your players, use a test secret key (gc_sk_test_…) of your game. The test environment runs the same API with simulated payments, and its data never mixes with the live data: see Keys and environments. The Testing guide says what to try. Use a fresh player id for each run, so that every run starts from a zero balance:
using var sandbox = new GameCoinClient(
Environment.GetEnvironmentVariable("GAMECOIN_SECRET_KEY")!,
new GameCoinOptions { BaseUrl = "https://gamecoin.apilow.com" });
var fresh = PlayerRef.Ext($"test-{Guid.NewGuid():N}");
var start = await sandbox.Wallet.GrantAsync(fresh, new GrantRequest { Currency = "gems", Amount = 100 });
Console.WriteLine(start.Balance.Total); // 100Reference
player is a PlayerRef, from PlayerRef.Ext() or PlayerRef.FromId(), or a plain string holding a GameCoin player id. Every call is asynchronous, takes a CancellationToken last, and returns a Task. The calls marked with an asterisk also take an optional RequestOptions before the token, which can hold an IdempotencyKey.
| Method | Returns | HTTP request |
|---|---|---|
gamecoin.Catalog.GetAsync(ct) | Task<Catalog> | GET /catalog |
gamecoin.Players.UpsertAsync(player, PlayerProfile? profile = null, ct) | Task<Player> | PUT /players/{player} |
gamecoin.Players.GetAsync(player, ct) | Task<Player> | GET /players/{player} |
gamecoin.Players.CreateTokenAsync(player, ct) | Task<PlayerToken> | POST /players/{player}/tokens |
gamecoin.Wallet.GetAsync(player, ct) | Task<Wallet> | GET /players/{player}/wallet |
gamecoin.Wallet.GrantAsync(player, GrantRequest request, RequestOptions? options = null, ct) * | Task<Movement> | POST /players/{player}/wallet/grant |
gamecoin.Wallet.SpendAsync(player, SpendRequest request, RequestOptions? options = null, ct) * | Task<Movement> | POST /players/{player}/wallet/spend |
gamecoin.Ledger.ListAsync(player, LedgerQuery? query = null, ct) | Task<LedgerPage> | GET /players/{player}/ledger |
gamecoin.Ledger.IterateAsync(player, LedgerQuery? query = null, ct) | IAsyncEnumerable<LedgerEntry> | GET /players/{player}/ledger, page after page |
gamecoin.Inventory.ListAsync(player, ct) | Task<IReadOnlyList<InventoryItem>> | GET /players/{player}/inventory |
gamecoin.Inventory.GrantAsync(player, InventoryGrantRequest request, RequestOptions? options = null, ct) * | Task<InventoryItem> | POST /players/{player}/inventory/grant |
gamecoin.Inventory.ConsumeAsync(player, InventoryConsumeRequest request, RequestOptions? options = null, ct) * | Task<InventoryItem> | POST /players/{player}/inventory/consume |
gamecoin.Purchases.CreateAsync(player, PurchaseRequest request, RequestOptions? options = null, ct) * | Task<Purchase> | POST /players/{player}/purchases |
gamecoin.Checkouts.CreateAsync(player, CheckoutRequest request, RequestOptions? options = null, ct) * | Task<Checkout> | POST /players/{player}/checkouts |
gamecoin.Orders.GetAsync(string orderId, ct) | Task<Order> | GET /orders/{orderId} |
gamecoin.Orders.RefundAsync(string orderId, RefundRequest? request = null, ct) | Task<Order> | POST /orders/{orderId}/refund |
The requests are immutable records, set with object initializers. A required property left empty, or a number below 1, throws an ArgumentException before any request.
| Record | Properties |
|---|---|
PlayerProfile | DisplayName, Email, Country (string?), BirthYear (int?), and Clear (PlayerProfileFields flags: DisplayName, Email, Country, BirthYear) |
GrantRequest, SpendRequest | Currency and Amount (required, long), Reason, Metadata (IReadOnlyDictionary<string, string>) |
InventoryGrantRequest, InventoryConsumeRequest, PurchaseRequest | Sku (required), Quantity (long, 1 by default) |
LedgerQuery | Currency, Limit (int?, 1 to 100), Cursor |
CheckoutRequest | PackSku (required), SuccessUrl, CancelUrl, Country |
RefundRequest | Reason |
RequestOptions | IdempotencyKey |
The results are immutable records, with DateTimeOffset values for every timestamp and IReadOnlyList for collections:
| Type | Properties |
|---|---|
Movement | Entry (a LedgerEntry), Balance, Replayed |
Purchase | Entry, Balance, Item, Replayed |
Checkout | Order, CheckoutUrl, Replayed |
InventoryItem | Sku, Quantity, UpdatedAt, Replayed (meaningful on the result of GrantAsync and ConsumeAsync) |
LedgerPage | Entries, NextCursor (null on the last page) |
PlayerToken | Token, ExpiresAt |
Wallet | PlayerId, Balances |
The catalog lists CatalogCurrency, CatalogItem and CatalogPack records. The package also provides GameCoinClient (with DefaultBaseUrl and SdkVersion), GameCoinException, GameCoinErrorCodes, PlayerRef and a record for every object of the API (Player, Balance, Order…), with constants for their string values (OrderStatus, LedgerEntryType, ItemType, PlayerKind). The fields of each object match the API reference, where each resource has its page: catalog, players, wallet, ledger, inventory, purchases, checkouts and orders.