# C# SDK

> Install the GameCoin SDK for .NET and use it from your game backend to manage wallets, the ledger, inventory, checkouts and refunds.

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](/docs/sdk/browser). Not sure which SDK you need? Start with [Server SDKs](/docs/sdk/server).

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

```csharp
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.

```csharp
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:

| 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](#testing-your-integration). |

`HttpClient` and `HttpMessageHandler` are mutually exclusive.

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

```csharp
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.

```csharp
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`:

```csharp
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.

```csharp
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.

```csharp
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:

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

```csharp
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.

```csharp
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.

```csharp
var catalog = await gamecoin.Catalog.GetAsync();
Console.WriteLine(string.Join(", ", catalog.Items.Select(item => $"{item.Sku} ({item.Type})"))); // potion (consumable), fire-sword (durable)
```

```csharp
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.

```csharp
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`:

```csharp
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.

```csharp
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](/docs/guides/refunds). An order that was never paid answers `CONFLICT`:

```csharp
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:

```csharp
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](/docs/concepts/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:

```csharp
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`:

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

```csharp
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](/docs/concepts/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:

```csharp
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:

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

```csharp
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](/docs/concepts/keys-and-environments). The [Testing](/docs/guides/testing) guide says what to try. Use a fresh player id for each run, so that every run starts from a zero balance:

```csharp
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`.

| 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](/docs/api), where each resource has its page: [catalog](/docs/api/catalog), [players](/docs/api/players), [wallet](/docs/api/wallet), [ledger](/docs/api/ledger), [inventory](/docs/api/inventory), [purchases](/docs/api/purchases), [checkouts](/docs/api/checkouts) and [orders](/docs/api/orders).
