Skip to content
Skip the menu

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.

View as Markdown

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.

Bash
dotnet add package Apilow.GameCoin

The 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.

C#
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.

C#
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 key

https://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:

OptionDefaultMeaning
BaseUrlhttps://gamecoin.apilow.comOrigin of the service. The SDK appends /api/v1, drops trailing slashes and keeps a path prefix. Only http and https are accepted.
Timeout30 secondsA TimeSpan for each HTTP attempt: a retry gets a fresh one.
MaxRetries2Retries after the first attempt, from 0 to 10: three attempts by default. 0 turns retries off.
HttpClientcreated for youYour own HttpClient, for instance one from IHttpClientFactory. The SDK never touches its default headers and never disposes it.
HttpMessageHandlernoneA 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.

C#
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():

C#
var player = PlayerRef.Ext("user-42");
Console.WriteLine(player); // ext:user-42

Any 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.

C#
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 True

In a PlayerProfile, a null property is left unchanged. To clear a field, list it in Clear:

C#
var cleared = await gamecoin.Players.UpsertAsync(player, new PlayerProfile { Clear = PlayerProfileFields.DisplayName });
Console.WriteLine($"{cleared.DisplayName ?? "null"} {cleared.Country}"); // null BE

Wallet

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.

C#
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 spend

Amount 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.

C#
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 25

Wallet.GetAsync returns one balance per active currency, zeros included:

C#
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 25

Ledger

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).

C#
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); // grant

NextCursor 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.

C#
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.

C#
var catalog = await gamecoin.Catalog.GetAsync();
Console.WriteLine(string.Join(", ", catalog.Items.Select(item => $"{item.Sku} ({item.Type})"))); // potion (consumable), fire-sword (durable)
C#
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.

C#
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 potion

An item can limit how many a player owns. Going beyond fails with LIMIT_REACHED:

C#
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.

C#
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 412

The 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:

C#
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:

C#
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 True

The 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):

FailureRetried
Network error, timeout, 5xxYes (not for Orders.RefundAsync)
429Yes, after the Retry-After delay
Any other 4xxNever
The call was cancelledNever: 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:

C#
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var latest = await gamecoin.Wallet.GetAsync(player, cts.Token);
Console.WriteLine(latest.Balances.Count); // 2

Errors

Anything that goes wrong with a request is a GameCoinException:

PropertyContent
CodeThe API code (INSUFFICIENT_FUNDS, NOT_FOUND…), or NETWORK_ERROR or TIMEOUT (status 0), or INVALID_RESPONSE when the body is not the expected JSON.
StatusThe HTTP status, 0 when no response was received.
MessageThe message of the API, or a short description. Written for developers, not for your players.
DetailsThe details of the API as an IReadOnlyDictionary<string, JsonElement> (required, available, fieldErrors…), empty when absent.
RetryAfterA TimeSpan? (the header is in seconds), set when the API sent Retry-After.
C#
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:

HTTPCodeWhen
400ValidationFailedA field is invalid (Details["fieldErrors"]).
401UnauthenticatedThe secret key is missing, unknown or revoked.
402PaymentFailedThe payment was refused, or no payment provider exists for this environment.
403ForbiddenThis kind of key is not allowed here.
403PlayerBlockedThe player is blocked.
403EnvNotEnabledThe live environment is used by a studio that is not verified.
404NotFoundUnknown player, currency, item, pack or order.
409InsufficientFundsThe wallet or the item quantity is too low (required and available in Details).
409LimitReachedmaxOwned, maxPerPlayer or the balance ceiling is exceeded.
409IdempotencyConflictThe key was reused with another request.
409ConflictInvalid state change, such as refunding an unpaid order.
429RateLimitedToo many requests (RetryAfter).
500InternalUnexpected error on the GameCoin side.
0NetworkError, TimeoutNo answer. The SDK has already retried.
anyInvalidResponseThe 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:

C#
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:

C#
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) });
    }
}
C#
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 1

Two 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:

C#
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); // 100

Reference

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.

MethodReturnsHTTP 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.

RecordProperties
PlayerProfileDisplayName, Email, Country (string?), BirthYear (int?), and Clear (PlayerProfileFields flags: DisplayName, Email, Country, BirthYear)
GrantRequest, SpendRequestCurrency and Amount (required, long), Reason, Metadata (IReadOnlyDictionary<string, string>)
InventoryGrantRequest, InventoryConsumeRequest, PurchaseRequestSku (required), Quantity (long, 1 by default)
LedgerQueryCurrency, Limit (int?, 1 to 100), Cursor
CheckoutRequestPackSku (required), SuccessUrl, CancelUrl, Country
RefundRequestReason
RequestOptionsIdempotencyKey

The results are immutable records, with DateTimeOffset values for every timestamp and IReadOnlyList for collections:

TypeProperties
MovementEntry (a LedgerEntry), Balance, Replayed
PurchaseEntry, Balance, Item, Replayed
CheckoutOrder, CheckoutUrl, Replayed
InventoryItemSku, Quantity, UpdatedAt, Replayed (meaningful on the result of GrantAsync and ConsumeAsync)
LedgerPageEntries, NextCursor (null on the last page)
PlayerTokenToken, ExpiresAt
WalletPlayerId, 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.